diff --git a/.agents/skills/changelog-authoring/SKILL.md b/.agents/skills/changelog-authoring/SKILL.md index 1e5aa674..bfce1eba 100644 --- a/.agents/skills/changelog-authoring/SKILL.md +++ b/.agents/skills/changelog-authoring/SKILL.md @@ -55,6 +55,7 @@ Use `gh pr view --json number,title,body,author,mergedAt` for PR eviden ## Highlights and Ordering - Sort bullets by user impact, not commit order. Breaking changes first, then significant new capabilities or broad user-visible improvements, then smaller features, fixes, and visual polish. +- Keep the opening highlight block contiguous. Place every bold highlight before the first regular bullet; a regular bullet marks the end of the highlight block. - Mark only the strongest highlights with a bold area prefix, such as `- **Chat attachments:** ...`. Usually the first 1–3 bullets; fewer when the release lacks substantial changes, more only when clearly justified. - Treat a change as a highlight only when it introduces a substantial user-facing capability, materially changes a common workflow, or fixes a severe/widespread problem. Do not bold merely because a bullet is first, has a large diff, or was hard to implement. - Keep related platform bullets together only when that does not push a more important change too far down. diff --git a/CHANGELOG.md b/CHANGELOG.md index 18fe2122..d27d8310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,42 +4,55 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- **Chat scrolling rebuilt around your message.** Sending a message parks it near the top of the view and the reply streams into the space below it, so you read from where you asked instead of chasing the bottom. Streamed text arrives a paragraph at a time (code blocks line by line) with a soft fade, and the view glides after it in one continuous motion instead of snapping per line. Scrolling up during a stream immediately hands you the wheel — nothing yanks the view back — and the scroll-to-bottom pill appears on the left, carrying the model's working status while you're away from the live edge. Sending from anywhere mid-conversation jumps you straight to your new message, and opening a session goes straight to the newest message with no scroll animation. - 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. -- **Chat context attachments:** everything you attach to a message — diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues and PRs — now shows up in the conversation as a compact context card: a header naming the source, the captured content behind an expander, and your comment below it. Previously most of these arrived as a wall of raw text inside your message. -- **Faster session switching in large workspaces** (thanks @c-w-xiaohei): switching sessions no longer rebuilds the whole sidebar, returning to a recently viewed session restores its rendered messages instead of re-rendering them (file links included), and scrolling long conversations costs less. In a workspace with thousands of loaded sessions, end-to-end switch time dropped by roughly half. -- **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. -- Chat: comment on a reply — select text in a chat message (or in a rendered markdown preview in Files) and choose Comment to attach exactly that quote, with a source line range when it can be located, plus your note to the next message. The selection stays highlighted while you type, and the selection menu was restyled — Add to chat is now Add to input. -- Diff: comment like a review — hovering a line shows a + button in the gutter; clicking it, clicking a line, or dragging across lines opens the comment editor for that line or range. The comment editor and saved-comment cards match the chat's comment style. -- Composer: hovering or tapping a context chip above the input opens a stacked preview of everything attached, where a comment can be edited in place or an item removed before sending. + +## [1.21.0] - 2026-08-26 + +- **Chat scrolling rebuilt around your message.** Sending parks your message near the top and the reply streams in below it, gliding smoothly a paragraph at a time. Scrolling up immediately hands you the wheel; the scroll-to-bottom pill carries the model's working status while you're away. +- **Keyboard shortcuts redesigned:** single chords for everyday actions, a Cmd/Ctrl+K leader for two-step open/go actions, held Cmd/Ctrl+digit for session tabs and Cmd/Ctrl+Option+digit for panel surfaces. Shortcuts work on non-English keyboard layouts now, tooltips show the binding you actually have set, and old custom bindings reset once. The full map lives in Settings → Shortcuts (registry contributed by @ChangeHow — thanks!). +- **Chat context attachments:** diff comments, terminal selections, browser annotations, linked issues/PRs and the rest now appear in the conversation as compact context cards instead of walls of raw text. +- **Session tabs (opt-in):** the web/desktop header can show open sessions as browser-style tabs (Settings → General → Navigation). A tab switches the whole workspace; closing one never touches the session itself. +- Sessions: switching is much faster in large workspaces — the sidebar no longer rebuilds on switch and recently viewed sessions restore their rendered messages; end-to-end switch time roughly halved with thousands of loaded sessions (thanks to @c-w-xiaohei). +- Permission: cards answer to the keyboard Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons. The auto-accept toggle got Cmd/Ctrl+K, A. +- Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. +- Git: Cmd/Ctrl+Enter in the commit message box commits. Diff review moves between changed files with Alt+Down/Up, expanding a collapsed file on arrival. +- Chat: Cmd/Ctrl+Shift+T now cycles through every thinking level offered by the selected model instead of skipping levels after reaching the end (thanks to @nimobeeren). +- Panels: the context rail got a configure button — a dialog chooses which panels the rail shows. Hidden panels keep their data, stay reachable from the command palette, and leave the digit switcher, so digits always match the icons you see. +- Chat: comment on a reply — select text in a chat message (or a rendered markdown preview in Files) and choose Comment to attach exactly that quote, with a source line range when it can be located, plus your note. The selection stays highlighted while you type. +- Diff: comment like a review — hovering a line shows a + in the gutter; clicking or dragging across lines opens the comment editor for that range, styled like the chat's comments. +- Composer: hovering or tapping a context chip opens a stacked preview of everything attached, where a comment can be edited in place or an item removed before sending. - Mobile: the chat comment input overlays the composer exactly and rides the keyboard; Enter makes a new line there, with attach on the button. -- Terminal: terminals no longer vanish or die behind your back. Opening the app in another browser tab, on another device, or after a reload shows the terminals already running on the server instead of an empty list, and terminals sitting in background tabs are no longer closed by the server's idle cleanup while the app is open. -- Search: every searchable picker — branches, projects, agents, models, providers, stashes, SSH hosts, skills, archived sessions — now uses one matcher: best matches come first, multi-word queries match in any order, and punctuation doesn't matter (so "gpt4o" finds "gpt-4o"). Ctrl/Cmd+P also matches the whole file path, not just the file name, and the git branch and gitmoji pickers stopped silently dropping rows a second built-in filter didn't like. Sidebar session search and the Todos/Memory/Plans/Notes filters match the same way. -- Chat: @ file mentions rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible so identical-looking index.md rows are distinguishable. -- Chat: a new "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns the automatic following off entirely — your message still parks at the top on send, but the view never moves on its own afterwards. -- Mobile: narrowing a browser window past phone size switches into the mobile app layout (and back when widened) instead of squeezing the desktop layout. The old/new mobile layout setting is gone — phones always get the mobile layout. -- Chat: streamed code blocks are syntax-highlighted while they stream, and finished messages no longer jump when a code block's line numbers fill in at the end of a reply. -- Chat: finished replies no longer flicker — tool cards stopped re-rendering (and replaying their reveal animation) when they completed, and resizing the window no longer throws the conversation up and down while you're at the bottom. -- Chat: clicking the last item in the prompt rail now always lands on it, and rail jumps teleport instead of a long smooth scroll that could stop halfway. -- Mobile: scrolling during a streaming reply works again — a drag immediately takes over, the scroll-to-bottom pill shows up, and the load-older button no longer throws you to the bottom of the chat. -- Fixed file links in messages being checked twice against the filesystem, and against the wrong project directory on the first pass. +- Terminal: terminals no longer vanish behind your back — every tab and device shows the ones already running on the server, and background tabs survive the idle cleanup. +- 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. +- 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. +- Usage: the Command Code tile is gone — their official API exposes no usage data, so the tile could only fail. +- Desktop: a relay-paired default host no longer greets every restart with the "Remote Server Unreachable" screen — the stored direct address (often the pairing machine's own loopback) failing its probe now boots the app normally and connects over the relay, picking the direct route back up automatically when it answers again. +- Mobile: on Android browsers the composer now stays above the keyboard in the chat too — the keyboard could cover it with no way to scroll it into view; the draft screen's viewport pinning now covers the chat screen on Android. +- Auth: an expired OpenChamber login is announced within seconds by a banner with a Log in button, instead of being discovered through failing actions. Sending pauses until login, and a conversation that failed to load reloads itself afterwards. +- Chat: a failed send returns your typed prompt to the input — whatever the reason — instead of losing it to an error toast; a mid-send session switch lands it in that session's draft. +- Chat: opening a session or resizing panels could strand the view in a large empty space below the last message; the list now returns to the real end, and a width resize keeps a reader who was at the bottom at the bottom. +- Chat: prompt-rail and message jumps land exactly on the target once the layout finishes measuring, and clicking the last rail item always works. +- Desktop: two windows on different projects no longer hijack each other — one window's session switch could make the other adopt its project mid-typing. Notification clicks and openchamber:// links now open in one window instead of all of them. +- Git: the branch's PR badge no longer picks up a stranger's pull request — with contributor forks added as remotes, a fork's closed PR sharing only the branch name could show up on the local branch. +- Chat: streamed code blocks are syntax-highlighted while streaming, and finished messages no longer jump when line numbers fill in. +- Chat: finished replies no longer flicker — tool cards stopped replaying their reveal animation on completion, and window resizing no longer throws the conversation around at the bottom. +- Mobile: scrolling during a streaming reply works again — a drag immediately takes over, the pill shows up, and load-older no longer throws you to the bottom. +- Fixed file links in messages being checked twice, and against the wrong project directory on the first pass. - Fixed the selected project or session briefly jumping back to a previous choice when settings responses arrived out of order. -- Fixed sessions staying on "loading sessions" forever after the connection to OpenCode went half-open — stalled reads now time out and retry instead of holding bootstrap hostage (thanks @herjarsa). -- Files: previews of files above the editable size cap now show the whole file instead of the first 200k characters, virtualized so opening and scrolling a huge file no longer freezes the app (thanks @gaojunran). -- VSCode: the chat view no longer stays stuck on its loading screen on slow or remote connections (for example code-server behind a reverse proxy) — the connection status is re-sent until the webview is ready to hear it (thanks @VinciYan). -- Terminal: mobile keyboards no longer capitalize the first letter of every command on iOS and Android. -- Desktop: a freshly installed or updated build no longer keeps loading the previous version's interface from cache. -- Devices: re-pairing a phone (or logging in again) keeps the device's existing name in Connected Devices instead of resetting it to "OpenChamber Mobile". -- Relay: paired devices no longer get logged out when the app restarts (for example during an update) while another local OpenChamber process is running — the restarted app keeps serving them instead of a bystander process taking over. +- Fixed sessions staying on "loading sessions" forever after a half-open connection to OpenCode — stalled reads now time out and retry (thanks to @herjarsa). +- Files: previews above the editable size cap show the whole file, virtualized so huge files no longer freeze the app (thanks to @gaojunran). +- VSCode: the chat view no longer sticks on its loading screen on slow or remote connections (thanks to @VinciYan). +- Terminal: mobile keyboards no longer capitalize the first letter of every command. +- Desktop: a freshly installed or updated build no longer loads the previous version's interface from cache. +- Devices: re-pairing a phone keeps the device's existing name instead of resetting it to "OpenChamber Mobile". +- Relay: paired devices no longer get logged out when the app restarts while another local OpenChamber process is running. - Sessions: headers now find archived sessions too, so an archived session's title no longer goes missing. -- Files: the editor toolbar is now always docked under the file tabs; the floating hover toolbar and its setting were removed. -- UI: the chat's top and bottom scroll fades are back, and the first uncached open of a session fades the conversation in instead of popping. -- UI: the timeline dialog now fits small screens instead of squeezing the message list to a couple of rows (thanks to @gaojunran). -- Chat: OpenCode notices now share one style. -- UI: draft target menus stay inside the chat area instead of overlapping the header. -- UI: Linear and Cloudflare tools now show their own icons. -- UI: sidebar item tooltips no longer appear instantly on passing hover. -- UI: the btw panel's shadow is lighter, matching the composer. +- Files: the editor toolbar is always docked under the file tabs; the floating hover toolbar and its setting were removed. +- UI: the chat's scroll fades are back, the first uncached session open fades in, the timeline dialog fits small screens (thanks to @gaojunran), OpenCode notices share one style, draft target menus stay inside the chat area, Linear and Cloudflare tools show their own icons, sidebar tooltips no longer appear on passing hover, and the btw panel's shadow matches the composer. ## [1.20.0] - 2026-08-23 diff --git a/README.md b/README.md index 7241e03a..93fdf3c7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![GitHub stars](https://img.shields.io/github/stars/openchamber/openchamber?style=flat&labelColor=100F0F&color=66800B)](https://github.com/openchamber/openchamber/stargazers) [![GitHub release](https://img.shields.io/github/v/release/openchamber/openchamber?style=flat&labelColor=100F0F&color=205EA6)](https://github.com/openchamber/openchamber/releases/latest) [![Discord](https://img.shields.io/badge/Discord-join.svg?style=flat&labelColor=100F0F&color=8B7EC8&logo=discord&logoColor=FFFCF0)](https://discord.gg/ZYRSdnwwKA) -[![Support the project](https://img.shields.io/badge/Support-Project-black?style=flat&labelColor=100F0F&color=EC8B49&logo=ko-fi&logoColor=FFFCF0)](https://ko-fi.com/G2G41SAWNS) +[![Support the project](https://img.shields.io/badge/Support-Project-black?style=flat&labelColor=100F0F&color=EC8B49&logo=patreon&logoColor=FFFCF0)](https://www.patreon.com/openchamber) ## Run agent work. Keep control. Ship from anywhere. diff --git a/bun.lock b/bun.lock index 3155488f..b643770c 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -169,7 +169,7 @@ "@dnd-kit/utilities": "^3.2.2", "@legendapp/list": "3.3.8", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.4.0", "@simplewebauthn/browser": "13.3.0", @@ -243,7 +243,7 @@ "version": "1.20.0", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "adm-zip": "^0.6.0", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -270,7 +270,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "@simplewebauthn/server": "13.3.1", "bun-pty": "^0.4.5", "compression": "^1.8.1", @@ -1007,7 +1007,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.21", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-k6iHQ5C8wOPglk+LgFyYnst168cGMQYumgpbVoeXJ+iC1AtvwD5zmjuF8CxMze/y9G1K2bOeO6p9yRvA7eHZLA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.23", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-VouYbL8O2ynLq0atr5fjzCv8YLtFi/zKLQ/uYkCvTJ4CmUdA01XwPn8om+u3TvxnGEfQsBfmThGU141vt3sg5w=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], diff --git a/package.json b/package.json index 6e4cd2b6..1f2e1fc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.20.0", + "version": "1.21.0", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", @@ -115,7 +115,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/docs/content/docs/de/magic-prompts.mdx b/packages/docs/content/docs/de/magic-prompts.mdx index d6dd2c68..de0f8e50 100644 --- a/packages/docs/content/docs/de/magic-prompts.mdx +++ b/packages/docs/content/docs/de/magic-prompts.mdx @@ -21,6 +21,64 @@ Einige Prompts haben einen sichtbaren Teil (die Nachricht, die du sehen würdest Anders entschieden? Jeder Prompt hat **Auf Standard zurücksetzen**, und es gibt **Alle zurücksetzen**, wenn du überall neu anfangen möchtest. +## Wo jeder Prompt verwendet wird + +Für jeden Prompt steht unten, wo er läuft und was ihn auslöst. Prüfe den Auslöser vor der Bearbeitung, dann weißt du, welchen Ablauf du änderst. + +### Git + +| Prompt | Wo er läuft | Wann er ausgelöst wird | +| --- | --- | --- | +| Commit-Erstellung | Die Generieren-Schaltfläche im Commit-Feld der Git-Ansicht und im mobilen Changes-Bildschirm | Du erzeugst eine Commit-Nachricht. Ausgewählte Dateien und die letzten Commit-Betreffs des Branchs werden eingesetzt, sodass die Nachricht zum Stil deines Repos passt. | +| PR-Erstellung | Das Pull-Request-Anlegen-Formular im PR-Tab der Git-Ansicht | Du erzeugst Titel und Beschreibung eines PRs. Eingebaut werden Base- und Head-Branch, die Commits und geänderten Dateien dazwischen, dein zusätzlicher Kontext und die PR-Vorlage des Repos, falls vorhanden. | +| Merge/Rebase-Konfliktlösung | Der Konflikt-Dialog der Git-Ansicht, wenn ein Merge oder Rebase auf Konflikten stoppt | Du wählst "Resolve in current session" oder "Resolve in new session". Der Agent liest die konfliktbehafteten Dateien, schlägt eine Lösungsstrategie pro Datei vor und wartet auf deine Bestätigung, bevor er etwas ändert, staged oder fortfährt. | +| Cherry-pick-Konfliktlösung | Der Bereich "Re-integrate commits" einer Worktree-Sitzung | Beim Übertragen der Sitzungs-Commits auf den Zielbranch entsteht ein Konflikt und du übergibst ihn dem Agenten. Der Agent löst im temporären Worktree, staged die Dateien und setzt den Cherry-pick fort. | + +### GitHub + +| Prompt | Wo er läuft | Wann er ausgelöst wird | +| --- | --- | --- | +| PR-Review | Der "Link GitHub PR"-Picker im Anhänge-Menü des Composers und der neue Worktree-Dialog | Zwei Auslöser. Hängst du einen PR als Kontext an, werden die Anweisungen erzeugt und mit deiner nächsten Nachricht mitgesendet. Startest du eine Worktree-Sitzung aus einem PR, bildet der Prompt die erste Nachricht dieser Sitzung, mit dem vollständigen PR-Kontext. | +| Issue-Review | Der neue Worktree-Dialog, wenn der Worktree aus einem Issue startet | Die erste Nachricht der neuen Sitzung reviewed das Issue, mit Titel, Text und Kommentaren als Kontext. | +| Fehlgeschlagene PR-Checks / PR-Kommentare / einzelner PR-Kommentar | — | Wird heute von keinem Ablauf gesendet. Die PR-Ansicht löste sie früher über Ein-Klick-Review-Aktionen aus; fehlgeschlagene Checks und Kommentare werden jetzt als Chat-Kontext-Entwürfe angeheftet. Sie bleiben editierbar, damit bestehende Overrides weiter funktionieren. | + +### Planung + +| Prompt | Wo er läuft | Wann er ausgelöst wird | +| --- | --- | --- | +| Todo-Planung | Das Todos-Panel in der Projekt-Seitenleiste | Du schickst ein Todo an eine Sitzung oder eine neue Worktree-Sitzung. Der Todo-Text wird zur sichtbaren Nachricht; die Anweisungen machen daraus einen fragegesteuerten Planungsdialog statt sofort loszulegen. | +| Plan verbessern | Die Aktion "Improve" für einen gespeicherten Plan in der Plans-Ansicht | Du schickst einen gespeicherten Plan in den Verbesserungsfluss. Der Agent liest zuerst die Plandatei, schlägt dann Änderungen auf Basis des aktuellen Repo-Zustands vor und bietet an, dieselbe Datei zu bearbeiten. | +| Plan umsetzen | Die Aktion "Implement" für einen gespeicherten Plan | Du schickst einen gespeicherten Plan in den Umsetzungsfluss. Der Agent liest die Plandatei und setzt sie komplett um, ohne den Rahmen zu sprengen; nötige Plananpassungen schreibt er in dieselbe Datei zurück. | + +### Sitzung + +Die meisten davon treiben Slash-Befehle an, die du im Composer eingibst. Die meisten erscheinen auch als Starter-Chips im Entwurf einer neuen Sitzung. + +| Prompt | Wo er läuft | Wann er ausgelöst wird | +| --- | --- | --- | +| Codebase-Tour | `/explore` | Du möchtest einen Überblick über die Codebase. | +| Sitzungszusammenfassung | `/summary`, optional `/summary ` | Du fasst die bisherige Konversation zusammen — nützlich zur Übergabe an eine neue Sitzung. Benötigt eine bestehende Sitzung. | +| Workspace-Review | `/workspace-review` | Du lässt den Agenten den aktuellen Workspace-Diff auf Absicht, Korrektheit und Sicherheit prüfen. | +| Feature-Planung | `/plan-feature` | Du machst aus einer groben Feature-Idee über einen geführten Frage-Antwort-Dialog einen Umsetzungsplan. | +| Goal formulieren | `/craft-goal`, optional `/craft-goal ` | Du machst aus einer Idee ein überprüfbares Goal-Ziel für den Goal-Dialog. | +| Catch-up | `/catch-up` | Du kehrst zu einem Projekt zurück und fragst, wo es steht und wie es weitergeht. | +| Debugging | `/debug` | Du untersuchst einen Bug: Der Agent bildet Hypothesen, bestätigt die Ursache aus dem Code und schlägt erst dann eine Lösung vor. | +| Optionen abwägen | `/weigh` | Du weißt, was du bauen willst, aber nicht wie. Der Agent vergleicht zwei oder drei Ansätze und empfiehlt einen. | +| Fusion | Die Aktion "Run fusion" auf einer Multi-run-Gruppe | Du vereinigst die Ausgaben mehrerer Läufe zu einer Antwort. Die Lauf-Ausgaben werden hinter die Anweisungen angehängt. | + +### Prompts ohne Settings-Seite + +Einige Prompts laufen automatisch und haben keine editierbare Seite in den Einstellungen: + +| Prompt | Wann er ausgelöst wird | +| --- | --- | +| Geplante Aufgabe | `/schedule-task`, optional mit einer ersten Idee. Führt durch den Dialog, der eine geplante Aufgabe definiert. | +| Review-Übergabe | `/handoff-review` oder die Review-Schaltfläche in der Diff-Ansicht mit aktivierter Übergabe. Erzeugt die Übergabe in der Arbeitssitzung. | +| Startnachricht der Review-Sitzung | Die erste Nachricht der erzeugten Review-Sitzung — mit Übergabe, wenn eine erzeugt wurde, sonst ohne. | +| Review-Feedback / Umsetzungsantwort | Bringen Nachrichten zwischen den beiden Sitzungen hin und her: Review-Feedback geht zurück an die umsetzende Sitzung, die Antwort des Umsetzers zurück an die Review-Sitzung. | + ## Weiterführend - [Git- & GitHub-Workflows](/git/) — viele dieser Prompts treiben die Git-Abläufe an +- [Notizen, Todos & Pläne](/notes-todos-plans/) — die Todos und Pläne hinter den Planungs-Prompts +- [Multi-run](/multi-run/) — Laufgruppen und Fusion diff --git a/packages/docs/content/docs/es/magic-prompts.mdx b/packages/docs/content/docs/es/magic-prompts.mdx index 05a70599..8f7d3159 100644 --- a/packages/docs/content/docs/es/magic-prompts.mdx +++ b/packages/docs/content/docs/es/magic-prompts.mdx @@ -21,6 +21,64 @@ Algunos prompts tienen una parte visible (el mensaje que verías) y una parte de ¿Cambiaste de opinión? Cada prompt tiene **reset to default**, y hay un **reset all** si quieres empezar de cero en todas partes. +## Dónde se usa cada prompt + +Cada prompt de las tablas indica dónde se ejecuta y qué lo dispara. Revisa el disparador antes de editar, para saber qué flujo estás cambiando. + +### Git + +| Prompt | Dónde se ejecuta | Cuándo se dispara | +| --- | --- | --- | +| Generación de commit | El botón de generar en el cuadro de commit de la vista git, y la pantalla Changes en móvil | Generas un mensaje de commit. Se rellenan los archivos seleccionados y los asuntos de los commits recientes de la rama, para que el mensaje siga el estilo de tu repositorio. | +| Generación de PR | El formulario de creación de pull request en la pestaña PR de la vista git | Generas el título y el cuerpo de un PR. Se rellenan las ramas base y head, los commits y archivos cambiados entre ambas, tu contexto adicional y la plantilla de PR del repositorio si existe. | +| Resolución de conflicto de merge/rebase | El diálogo de conflictos en la vista git, cuando un merge o rebase se detiene por conflictos | Eliges "Resolve in current session" o "Resolve in new session". El agente lee los archivos en conflicto, propone una estrategia por archivo y espera tu confirmación antes de editar, hacer stage o continuar la operación. | +| Resolución de conflicto de cherry-pick | La sección "Re-integrate commits" de una sesión en worktree | Mover los commits de la sesión a la rama destino produce un conflicto y se lo pasas al agente. El agente resuelve dentro del worktree temporal, hace stage de los archivos y continúa el cherry-pick. | + +### GitHub + +| Prompt | Dónde se ejecuta | Cuándo se dispara | +| --- | --- | --- | +| Revisión de PR | El selector "Link GitHub PR" en el menú de adjuntos del composer, y el diálogo de nuevo worktree | Dos disparadores. Adjuntar un PR como contexto prepara las instrucciones, que se envían con tu siguiente mensaje. Crear una sesión de worktree desde un PR usa el prompt como primer mensaje de esa sesión, con el contexto completo del PR adjunto. | +| Revisión de issue | El diálogo de nuevo worktree, cuando el worktree parte de una issue | El primer mensaje de la nueva sesión revisa la issue, con su cuerpo y comentarios adjuntos como contexto. | +| Revisión de checks fallidos / comentarios de PR / comentario único de PR | — | Hoy no los envía ningún flujo. La vista de PR antes los disparaba con acciones de revisión de un clic; ahora los checks fallidos y los comentarios se fijan como borradores de contexto del chat. Siguen siendo editables para que las anulaciones existentes sigan funcionando. | + +### Planning + +| Prompt | Dónde se ejecuta | Cuándo se dispara | +| --- | --- | --- | +| Planificación desde todo | El panel Todos en la barra lateral del proyecto | Envías un todo a una sesión o a una nueva sesión en worktree. El texto del todo se convierte en el mensaje visible; las instrucciones lo convierten en un diálogo de planificación con preguntas en vez de saltar a implementar. | +| Mejorar plan | La acción "Improve" sobre un plan guardado en la vista Plans | Envías un plan guardado al flujo de mejora. El agente lee primero el archivo del plan, luego propone cambios basados en el estado actual del repositorio y se ofrece a editar ese mismo archivo. | +| Implementar plan | La acción "Implement" sobre un plan guardado | Envías un plan guardado al flujo de implementación. El agente lee el archivo del plan y lo implementa de principio a fin sin ampliar el alcance, y guarda ajustes del plan en el archivo cuando el propio plan resulta estar mal. | + +### Session + +La mayoría alimentan comandos de barra que se escriben en el composer. La mayoría también aparecen como chips de inicio en el borrador de una sesión nueva. + +| Prompt | Dónde se ejecuta | Cuándo se dispara | +| --- | --- | --- | +| Tour del código | `/explore` | Pides una orientación general del código. | +| Resumen de sesión | `/summary`, opcionalmente `/summary ` | Resumes la conversación hasta ahora, útil para pasar a una sesión nueva. Requiere una sesión existente. | +| Revisión del workspace | `/workspace-review` | Pides al agente revisar el diff actual del workspace en cuanto a intención, corrección y seguridad. | +| Planificación de feature | `/plan-feature` | Conviertes una idea rough de feature en un plan de implementación mediante un diálogo guiado de preguntas y respuestas. | +| Definir Goal | `/craft-goal`, opcionalmente `/craft-goal ` | Conviertes una idea en un objetivo Goal verificable para el diálogo de Goal. | +| Ponerse al día | `/catch-up` | Vuelves a un proyecto y preguntas en qué quedó y qué seguir. | +| Depuración | `/debug` | Investigas un bug: el agente forma hipótesis, confirma la causa raíz desde el código y solo entonces propone un arreglo. | +| Sopesar opciones | `/weigh` | Sabes qué construir pero no cómo. El agente compara dos o tres enfoques y recomienda uno. | +| Fusion | La acción "Run fusion" sobre un grupo de multi-run | Combinas los resultados de varias ejecuciones en una respuesta. Los resultados se añaden después de las instrucciones. | + +### Prompts sin página en Settings + +Algunos prompts se disparan automáticamente y no tienen página editable en Settings: + +| Prompt | Cuándo se dispara | +| --- | --- | +| Tarea programada | `/schedule-task`, opcionalmente con una idea inicial. Guía el diálogo que define una tarea programada. | +| Handoff de revisión | `/handoff-review`, o el botón Review en la vista de diff con el handoff activado. Genera el handoff en la sesión de trabajo. | +| Mensaje inicial de la sesión de revisión | El primer mensaje de la sesión de revisión generada, con el handoff cuando se produjo, o sin él. | +| Feedback de revisión / respuesta de implementación | Llevan mensajes entre las dos sesiones: el feedback del revisor vuelve a la sesión que implementa, y la respuesta del implementador regresa a la sesión de revisión. | + ## Relacionado - [Flujos de trabajo de Git y GitHub](/es/git/) — muchos de estos prompts impulsan los flujos de git +- [Notas, todos y planes](/es/notes-todos-plans/) — los todos y planes detrás de los prompts de Planning +- [Multi-run](/es/multi-run/) — grupos de ejecución y fusion diff --git a/packages/docs/content/docs/fr/magic-prompts.mdx b/packages/docs/content/docs/fr/magic-prompts.mdx index 2a7312e6..6314b953 100644 --- a/packages/docs/content/docs/fr/magic-prompts.mdx +++ b/packages/docs/content/docs/fr/magic-prompts.mdx @@ -21,6 +21,64 @@ Certains prompts ont une partie visible (le message que vous verriez) et une par Vous avez changé d’avis ? Chaque prompt possède **reset to default**, et il existe aussi **reset all** si vous voulez tout reprendre depuis le début. +## Où chaque prompt est utilisé + +Chaque prompt ci-dessous indique où il s’exécute et ce qui le déclenche. Vérifiez le déclencheur avant de modifier, pour savoir quel flux vous changez. + +### Git + +| Prompt | Où il s’exécute | Quand il se déclenche | +| --- | --- | --- | +| Génération de commit | Le bouton de génération dans la zone de commit de la vue git, et l’écran Changes sur mobile | Vous générez un message de commit. Les fichiers sélectionnés et les sujets des commits récents de la branche sont insérés, pour que le message respecte le style du dépôt. | +| Génération de PR | Le formulaire de création de pull request dans l’onglet PR de la vue git | Vous générez le titre et le corps d’une PR. Sont insérés les branches base et head, les commits et fichiers modifiés entre elles, votre contexte additionnel et le modèle de PR du dépôt s’il existe. | +| Résolution de conflit merge/rebase | Le dialogue de conflits dans la vue git, quand un merge ou un rebase s’arrête sur des conflits | Vous choisissez « Resolve in current session » ou « Resolve in new session ». L’agent lit les fichiers en conflit, propose une stratégie par fichier et attend votre confirmation avant de modifier, staging ou poursuivre l’opération. | +| Résolution de conflit cherry-pick | La section « Re-integrate commits » d’une session en worktree | Le déplacement des commits de la session vers la branche cible rencontre un conflit et vous le confiez à l’agent. L’agent résout dans le worktree temporaire, stage les fichiers et poursuit le cherry-pick. | + +### GitHub + +| Prompt | Où il s’exécute | Quand il se déclenche | +| --- | --- | --- | +| Relecture de PR | Le sélecteur « Link GitHub PR » dans le menu de pièces jointes du composer, et le dialogue de nouveau worktree | Deux déclencheurs. Attacher une PR comme contexte prépare les instructions, envoyées avec votre prochain message. Créer une session de worktree depuis une PR utilise le prompt comme premier message de la session, avec le contexte complet de la PR. | +| Relecture d’issue | Le dialogue de nouveau worktree, quand le worktree part d’une issue | Le premier message de la nouvelle session relit l’issue, avec son corps et ses commentaires attachés comme contexte. | +| Relecture de checks échoués / commentaires de PR / commentaire unique de PR | — | Aucun flux ne les envoie aujourd’hui. La vue PR les déclenchait avant via des actions de relecture en un clic ; désormais les checks échoués et les commentaires s’épinglent comme brouillons de contexte de chat. Ils restent modifiables pour que les overrides existants continuent de fonctionner. | + +### Planning + +| Prompt | Où il s’exécute | Quand il se déclenche | +| --- | --- | --- | +| Planification depuis un todo | Le panneau Todos dans la barre latérale du projet | Vous envoyez un todo vers une session ou une nouvelle session en worktree. Le texte du todo devient le message visible ; les instructions en font un dialogue de planification guidé par des questions plutôt qu’un passage direct à l’implémentation. | +| Améliorer un plan | L’action « Improve » sur un plan enregistré dans la vue Plans | Vous envoyez un plan enregistré dans le flux d’amélioration. L’agent lit d’abord le fichier du plan, propose ensuite des changements ancrés dans l’état actuel du dépôt et propose de modifier le même fichier. | +| Implémenter un plan | L’action « Implement » sur un plan enregistré | Vous envoyez un plan enregistré dans le flux d’implémentation. L’agent lit le fichier du plan et l’implémente de bout en bout sans élargir le périmètre, enregistrant les ajustements dans le fichier quand le plan lui-même s’avère erroné. | + +### Session + +La plupart alimentent des commandes slash saisies dans le composer. La plupart apparaissent aussi comme chips de départ sur le brouillon d’une nouvelle session. + +| Prompt | Où il s’exécute | Quand il se déclenche | +| --- | --- | --- | +| Tour du code | `/explore` | Vous demandez une vue d’ensemble du code. | +| Résumé de session | `/summary`, éventuellement `/summary ` | Vous résumez la conversation en cours — utile pour passer à une nouvelle session. Nécessite une session existante. | +| Relecture du workspace | `/workspace-review` | Vous demandez à l’agent de relire le diff actuel du workspace sous l’angle intention, correction et sécurité. | +| Planification de fonctionnalité | `/plan-feature` | Vous transformez une idée grossière de fonctionnalité en plan d’implémentation via un dialogue guidé de questions-réponses. | +| Formuler un Goal | `/craft-goal`, éventuellement `/craft-goal ` | Vous transformez une idée en objectif Goal vérifiable pour le dialogue Goal. | +| Se remettre dans le bain | `/catch-up` | Vous revenez sur un projet et demandez où en sont les choses et quoi reprendre. | +| Débogage | `/debug` | Vous investiguez un bug : l’agent forme des hypothèses, confirme la cause racine dans le code et seulement ensuite propose un correctif. | +| Peser les options | `/weigh` | Vous savez quoi construire mais pas comment. L’agent compare deux ou trois approches et en recommande une. | +| Fusion | L’action « Run fusion » sur un groupe multi-run | Vous combinez les sorties de plusieurs exécutions en une réponse. Les sorties des exécutions sont ajoutées après les instructions. | + +### Prompts sans page dans les Paramètres + +Quelques prompts se déclenchent automatiquement et n’ont pas de page modifiable dans les Paramètres : + +| Prompt | Quand il se déclenche | +| --- | --- | +| Tâche planifiée | `/schedule-task`, éventuellement avec une idée initiale. Guide le dialogue qui définit une tâche planifiée. | +| Handoff de relecture | `/handoff-review`, ou le bouton Review dans la vue diff avec handoff activé. Génère le handoff dans la session de travail. | +| Premier message de la session de relecture | Le message d’ouverture de la session de relecture générée — avec le handoff quand il a été produit, sans sinon. | +| Retour de relecture / réponse d’implémentation | Font circuler les messages entre les deux sessions : le retour du relecteur revient vers la session qui implémente, et la réponse de l’implémenteur repart vers la session de relecture. | + ## Pages liées - [Workflows Git et GitHub](/git/) — beaucoup de ces prompts alimentent les flux git +- [Notes, todos et plans](/notes-todos-plans/) — les todos et plans derrière les prompts Planning +- [Multi-run](/multi-run/) — groupes d’exécution et fusion diff --git a/packages/docs/content/docs/ja/magic-prompts.mdx b/packages/docs/content/docs/ja/magic-prompts.mdx index a31cc297..8209b16e 100644 --- a/packages/docs/content/docs/ja/magic-prompts.mdx +++ b/packages/docs/content/docs/ja/magic-prompts.mdx @@ -21,6 +21,64 @@ OpenChamber は、コミットメッセージの作成、PR の下書き、Issue 気が変わりましたか?各プロンプトには **reset to default** があり、すべてを最初からやり直したい場合は **reset all** もあります。 +## 各プロンプトが使われる場所 + +以下の表は、各プロンプトがどこで実行され、何がきっかけで動くかを示します。編集前にトリガーを確認し、どのフローを変えるのかを把握してください。 + +### Git + +| プロンプト | 実行される場所 | 動くタイミング | +| --- | --- | --- | +| コミット生成 | git ビューのコミット欄にある生成ボタン、およびモバイルの Changes 画面 | コミットメッセージを生成するとき。選択したファイルとブランチの直近コミットの件名が差し込まれ、メッセージがリポジトリの既存スタイルに合います。 | +| PR 生成 | git ビュー PR タブの pull request 作成フォーム | PR のタイトルと本文を生成するとき。base と head ブランチ、その間のコミットと変更ファイル、追加コンテキスト、リポジトリに PR テンプレートがあればそれも差し込まれます。 | +| merge/rebase コンフリクト解決 | merge や rebase がコンフリクトで止まったときの git ビューのコンフリクトダイアログ | "Resolve in current session" または "Resolve in new session" を選んだとき。エージェントはコンフリクトファイルを読み、ファイルごとの解決戦略を提案し、編集・stage・操作の再開の前に確認を待ちます。 | +| cherry-pick コンフリクト解決 | worktree セッションの "Re-integrate commits" セクション | セッションのコミットを対象ブランチへ移す途中でコンフリクトが起き、エージェントに任せたとき。エージェントは一時 worktree の中で解決し、ファイルを stage して cherry-pick を続けます。 | + +### GitHub + +| プロンプト | 実行される場所 | 動くタイミング | +| --- | --- | --- | +| PR レビュー | composer の添付メニューにある "Link GitHub PR" ピッカー、および新規 worktree ダイアログ | 2 つのトリガー。PR をコンテキストとして添付すると instructions が用意され、次のメッセージと一緒に送られます。PR から worktree セッションを始めると、このプロンプトがそのセッションの最初のメッセージになり、PR の完全なコンテキストが添付されます。 | +| Issue レビュー | Issue から worktree を作るときの新規 worktree ダイアログ | 新しいセッションの最初のメッセージが Issue をレビューし、本文とコメントがコンテキストとして添付されます。 | +| PR の失敗チェック / PR コメント / 個別 PR コメント | — | 現在はどのフローからも送信されません。以前は PR ビューのワンクリックレビューアクションから起動されましたが、今は失敗チェックとコメントがチャットコンテキストの下書きとしてピン留めされます。既存のオーバーライドが機能し続けるよう、編集可能なまま残っています。 | + +### Planning + +| プロンプト | 実行される場所 | 動くタイミング | +| --- | --- | --- | +| todo からの計画 | プロジェクトサイドバーの Todos パネル | todo をセッションまたは新しい worktree セッションへ送るとき。todo のテキストが見えるメッセージになり、instructions は実装へ飛ばず、質問主体の計画対話に変えます。 | +| 計画の改善 | Plans ビューの保存済み計画に対する "Improve" アクション | 保存済み計画を改善フローへ送るとき。エージェントはまず計画ファイルを読み、リポジトリの現在の状態に即した変更を提案し、同じファイルの編集を申し出ます。 | +| 計画の実装 | 保存済み計画に対する "Implement" アクション | 保存済み計画を実装フローへ送るとき。エージェントは計画ファイルを読み、スコープを広げずに最後まで実装し、計画自体に誤りが見つかった場合は調整を同じファイルへ保存します。 | + +### Session + +これらの多くは、composer に入力するスラッシュコマンドとして動きます。多くは新しいセッションの下書き画面でスターターチップとしても表示されます。 + +| プロンプト | 実行される場所 | 動くタイミング | +| --- | --- | --- | +| コードベースツアー | `/explore` | コードベースの概要を把握したいとき。 | +| セッション要約 | `/summary`、オプションで `/summary <トピック>` | ここまでの会話を要約します。新しいセッションへの引き継ぎに便利です。既存のセッションが必要です。 | +| ワークスペースレビュー | `/workspace-review` | 現在のワークスペース差分を意図・正確性・セキュリティの観点でレビューしてほしいとき。 | +| 機能計画 | `/plan-feature` | 大まかな機能アイデアを、質疑応答の対話を通じて実装計画に変えたいとき。 | +| Goal 作成 | `/craft-goal`、オプションで `/craft-goal <アイデア>` | アイデアを、Goal ダイアログで使える検証可能な Goal 目標に変えたいとき。 | +| キャッチアップ | `/catch-up` | プロジェクトに戻って、どこまで進んでいて次に何をするか知りたいとき。 | +| デバッグ | `/debug` | バグを調査するとき。エージェントは仮説を立て、コードから根本原因を確認してから修正を提案します。 | +| 選択肢の比較 | `/weigh` | 何を作るかは分かっているが作り方が分からないとき。エージェントが 2〜3 のアプローチを比較し、1 つを推奨します。 | +| Fusion | multi-run グループの "Run fusion" アクション | 複数ランの出力を 1 つの回答にまとめるとき。ランの出力は instructions の後に続けて添付されます。 | + +### Settings にページのないプロンプト + +一部のプロンプトは自動的に動き、Settings には編集ページがありません: + +| プロンプト | 動くタイミング | +| --- | --- | +| スケジュールタスク | `/schedule-task`、オプションで初期アイデアと一緒に。スケジュールタスクを定義する対話を進めます。 | +| レビュー用ハンドオフ | `/handoff-review`、またはハンドオフを有効にした diff ビューの Review ボタン。作業セッション内でハンドオフを生成します。 | +| レビューセッションの開始メッセージ | 生成されたレビューセッションの最初のメッセージ。ハンドオフが作られた場合はそれを含み、なければ含みません。 | +| レビューフィードバック / 実装応答 | 2 つのセッションの間でメッセージを運びます。レビュアーのフィードバックは実装セッションへ、実装者の応答はレビューセッションへ戻ります。 | + ## 関連 - [Git と GitHub ワークフロー](/git/) — これらのプロンプトの多くが Git フローを支えています +- [ノート、todo と計画](/notes-todos-plans/) — Planning プロンプトの背後にある todo と計画 +- [Multi-run](/multi-run/) — ラングループと fusion diff --git a/packages/docs/content/docs/ko/magic-prompts.mdx b/packages/docs/content/docs/ko/magic-prompts.mdx index e826e7b9..e7f17689 100644 --- a/packages/docs/content/docs/ko/magic-prompts.mdx +++ b/packages/docs/content/docs/ko/magic-prompts.mdx @@ -21,6 +21,64 @@ OpenChamber는 커밋 메시지 작성, PR 초안 작성, 이슈 검토, 충돌 마음이 바뀌었나요? 각 프롬프트에는 **reset to default**가 있고, 모든 곳에서 처음부터 다시 시작하려면 **reset all**이 있습니다. +## 각 프롬프트가 사용되는 곳 + +아래 표의 각 프롬프트는 실행되는 위치와 실행을 일으키는 트리거를 나타냅니다. 편집하기 전에 트리거를 확인해 어떤 흐름을 바꾸는지 알아두세요. + +### Git + +| 프롬프트 | 실행 위치 | 트리거 시점 | +| --- | --- | --- | +| 커밋 생성 | git 뷰 커밋 상자의 생성 버튼, 모바일 Changes 화면 | 커밋 메시지를 생성할 때. 선택한 파일과 브랜치의 최근 커밋 제목이 채워져 메시지가 저장소 스타일을 따르게 됩니다. | +| PR 생성 | git 뷰 PR 탭의 pull request 생성 폼 | PR 제목과 본문을 생성할 때. base와 head 브랜치, 그 사이의 커밋과 변경 파일, 추가 컨텍스트, 저장소에 PR 템플릿이 있으면 그것까지 채워집니다. | +| merge/rebase 충돌 해결 | merge나 rebase가 충돌로 멈췄을 때 git 뷰의 충돌 대화상자 | "Resolve in current session" 또는 "Resolve in new session"을 선택할 때. 에이전트가 충돌 파일을 읽고 파일별 해결 전략을 제안하며, 편집·stage·계속 진행 전에 확인을 기다립니다. | +| cherry-pick 충돌 해결 | worktree 세션의 "Re-integrate commits" 섹션 | 세션의 커밋을 대상 브랜치로 옮기다 충돌이 나서 에이전트에 맡길 때. 에이전트가 임시 worktree에서 해결하고 파일을 stage한 뒤 cherry-pick을 계속합니다. | + +### GitHub + +| 프롬프트 | 실행 위치 | 트리거 시점 | +| --- | --- | --- | +| PR 검토 | 작성기 첨부 메뉴의 "Link GitHub PR" 선택기, 새 worktree 대화상자 | 두 가지 트리거. PR을 컨텍스트로 첨부하면 지침이 준비되어 다음 메시지와 함께 전송됩니다. PR에서 worktree 세션을 시작하면 이 프롬프트가 그 세션의 첫 메시지가 되고 전체 PR 컨텍스트가 첨부됩니다. | +| 이슈 검토 | worktree를 이슈에서 시작할 때의 새 worktree 대화상자 | 새 세션의 첫 메시지가 이슈를 검토하며, 본문과 댓글이 컨텍스트로 첨부됩니다. | +| PR 실패 검사 / PR 댓글 / 단일 PR 댓글 검토 | — | 현재 어떤 흐름도 이것들을 보내지 않습니다. PR 뷰가 이전에는 원클릭 검토 액션으로 실행했지만, 이제 실패한 검사와 댓글은 채팅 컨텍스트 초안으로 고정됩니다. 기존 재정의가 계속 동작하도록 편집 가능한 상태로 남습니다. | + +### Planning + +| 프롬프트 | 실행 위치 | 트리거 시점 | +| --- | --- | --- | +| todo 계획 | 프로젝트 사이드바의 Todos 패널 | todo를 세션 또는 새 worktree 세션으로 보낼 때. todo 텍스트가 보이는 메시지가 되고, 지침은 바로 구현으로 넘어가지 않고 질문 중심의 계획 대화로 만듭니다. | +| 계획 개선 | Plans 뷰에서 저장된 계획의 "Improve" 액션 | 저장된 계획을 개선 흐름으로 보낼 때. 에이전트가 먼저 계획 파일을 읽고, 저장소 현재 상태에 근거한 변경을 제안하며 같은 파일을 편집하겠다고 제안합니다. | +| 계획 구현 | 저장된 계획의 "Implement" 액션 | 저장된 계획을 구현 흐름으로 보낼 때. 에이전트가 계획 파일을 읽고 범위를 늘리지 않고 끝까지 구현하며, 계획 자체가 잘못된 것으로 밝혀지면 조정을 같은 파일에 저장합니다. | + +### Session + +대부분 작성기에 입력하는 슬래시 명령으로 동작합니다. 대부분 새 세션 초안 화면의 시작 칩으로도 나타납니다. + +| 프롬프트 | 실행 위치 | 트리거 시점 | +| --- | --- | --- | +| 코드베이스 투어 | `/explore` | 코드베이스의 전체 개요를 요청할 때. | +| 세션 요약 | `/summary`, 선택적으로 `/summary <주제>` | 지금까지의 대화를 요약할 때 — 새 세션으로 넘길 때 유용합니다. 기존 세션이 필요합니다. | +| 작업 공간 검토 | `/workspace-review` | 현재 작업 공간 diff를 의도, 정확성, 보안 관점에서 검토해 달라고 요청할 때. | +| 기능 계획 | `/plan-feature` | 거친 기능 아이디어를 안내된 질문-답변 대화를 통해 구현 계획으로 만들 때. | +| Goal 만들기 | `/craft-goal`, 선택적으로 `/craft-goal <아이디어>` | 아이디어를 Goal 대화상자에 쓸 수 있는 검증 가능한 Goal 목표로 바꿀 때. | +| 따라잡기 | `/catch-up` | 프로젝트로 돌아와 어디까지 진행됐고 다음에 무엇을 할지 물을 때. | +| 디버깅 | `/debug` | 버그를 조사할 때: 에이전트가 가설을 세우고 코드에서 근본 원인을 확인한 뒤에야 수정을 제안합니다. | +| 옵션 저울질 | `/weigh` | 무엇을 만들지는 알지만 어떻게 할지 모를 때. 에이전트가 두세 가지 접근을 비교하고 하나를 추천합니다. | +| Fusion | multi-run 그룹의 "Run fusion" 액션 | 여러 실행의 출력을 하나의 답변으로 합칠 때. 실행 출력은 지침 뒤에 추가됩니다. | + +### Settings에 페이지가 없는 프롬프트 + +일부 프롬프트는 자동으로 실행되며 Settings에 편집 가능한 페이지가 없습니다: + +| 프롬프트 | 트리거 시점 | +| --- | --- | +| 예약 작업 | `/schedule-task`, 선택적으로 초기 아이디어와 함께. 예약 작업을 정의하는 대화를 이끕니다. | +| 검토 핸드오프 | `/handoff-review`, 또는 핸드오프를 켜고 diff 뷰의 Review 버튼. 작업 세션에서 핸드오프를 생성합니다. | +| 검토 세션 시작 메시지 | 생성된 검토 세션의 첫 메시지 — 핸드오프가 만들어졌으면 포함, 아니면 제외. | +| 검토 피드백 / 구현 응답 | 두 세션 사이에서 메시지를 전달합니다: 검토자 피드백은 구현 세션으로, 구현자 응답은 검토 세션으로 돌아갑니다. | + ## 관련 항목 - [Git & GitHub Workflows](/ko/git/) — 이러한 프롬프트 중 다수가 git 흐름을 구동합니다 +- [노트, todo와 계획](/ko/notes-todos-plans/) — Planning 프롬프트 뒤에 있는 todo와 계획 +- [Multi-run](/ko/multi-run/) — 실행 그룹과 fusion diff --git a/packages/docs/content/docs/magic-prompts.mdx b/packages/docs/content/docs/magic-prompts.mdx index ba830e09..93938ee5 100644 --- a/packages/docs/content/docs/magic-prompts.mdx +++ b/packages/docs/content/docs/magic-prompts.mdx @@ -21,6 +21,64 @@ Some prompts have a visible part (the message you'd see) and an instructions par Changed your mind? Each prompt has **reset to default**, and there's a **reset all** if you want to start over everywhere. +## Where each prompt is used + +Every prompt below lists where it runs and the trigger that fires it. Check the trigger before editing, so you know which flow you're changing. + +### Git + +| Prompt | Where it runs | When it fires | +| --- | --- | --- | +| Commit generation | The generate button in the git view's commit box, and the mobile Changes screen | You generate a commit message. The selected files and the branch's recent commit subjects are filled in, so the subject matches your repo's existing style. | +| PR generation | The create-pull-request form in the git view's PR tab | You generate a PR title and body. Filled with the base and head branches, the commits and changed files between them, your additional context, and the repo's PR template when one exists. | +| Merge/rebase conflict resolution | The conflicts dialog in the git view, when a merge or rebase stops on conflicts | You pick "Resolve in current session" or "Resolve in new session". The agent reads the conflicted files, proposes a per-file resolution strategy, and waits for your confirmation before editing, staging, or continuing the operation. | +| Cherry-pick conflict resolution | The "Re-integrate commits" section for a worktree session | Moving the session's commits onto the target branch hits a conflict and you hand it to the agent. The agent resolves inside the temporary worktree, stages the resolved files, and continues the cherry-pick. | + +### GitHub + +| Prompt | Where it runs | When it fires | +| --- | --- | --- | +| PR review | The "Link GitHub PR" picker in the composer's attach menu, and the new worktree dialog | Two triggers. Attaching a PR as context renders the instructions, which go out with your next message. Starting a worktree session from a PR uses the prompt as that session's opening message, with the full PR context attached. | +| Issue review | The new worktree dialog, when you start the worktree from an issue | The new session's opening message reviews the issue, with its body and comments attached as context. | +| PR failed checks / PR comments / single PR comment | — | Not sent by any flow today. The PR view used to fire these from one-click review actions; failed checks and comments now pin as chat-context drafts instead. They stay editable so existing overrides keep working. | + +### Planning + +| Prompt | Where it runs | When it fires | +| --- | --- | --- | +| Todo planning | The Todos panel in the project sidebar | You send a todo to a session or a new worktree session. The todo text becomes the visible message; the instructions turn it into a question-first planning dialogue instead of jumping straight to implementation. | +| Improve plan | The "Improve" action on a saved plan in the Plans view | You send a saved plan into an improve flow. The agent reads the plan file first, then proposes changes grounded in the current repo state and offers to edit the same file. | +| Implement plan | The "Implement" action on a saved plan | You send a saved plan into an implement flow. The agent reads the plan file and implements it end to end without expanding scope, saving plan adjustments back to the file when the plan itself turns out to be wrong. | + +### Session + +Most of these power slash commands typed in the composer. Most also appear as starter chips on a new-session draft. + +| Prompt | Where it runs | When it fires | +| --- | --- | --- | +| Codebase tour | `/explore` | You ask for a high-level orientation of the codebase. | +| Session summary | `/summary`, optionally `/summary ` | You summarize the conversation so far, useful for handing off to a new session. Needs an existing session. | +| Workspace review | `/workspace-review` | You ask the agent to review the current workspace diff for intent, correctness, and security. | +| Feature planning | `/plan-feature` | You turn a rough feature idea into an implementation plan through a guided question-and-answer dialogue. | +| Goal crafting | `/craft-goal`, optionally `/craft-goal ` | You turn an idea into a verifiable Goal objective for the Goal dialog. | +| Catch up | `/catch-up` | You return to a project and ask where things stand and what to pick up next. | +| Debugging | `/debug` | You investigate a bug: the agent forms hypotheses, confirms the root cause from the code, and only then proposes a fix. | +| Weigh options | `/weigh` | You know what you want to build but not how. The agent compares two or three approaches and recommends one. | +| Fusion | The "Run fusion" action on a multi-run group | You combine the outputs of several runs into one answer. The run outputs are appended after the instructions. | + +### Prompts without a Settings entry + +A few prompts fire automatically and have no editable page in Settings: + +| Prompt | When it fires | +| --- | --- | +| Scheduled task | `/schedule-task`, optionally with an initial idea. Guides the dialogue that defines a scheduled task. | +| Review handoff | `/handoff-review`, or the Review button in the diff view with handoff enabled. Generates the handoff in the working session. | +| Review session starter | The opening message of the generated review session, with the handoff when one was produced and without it otherwise. | +| Review feedback / implementation response | Shuttle messages between the two sessions: reviewer feedback goes back to the implementing session, and the implementer's response returns to the review session. | + ## Related - [Git & GitHub Workflows](/git/) — many of these prompts power the git flows +- [Notes, Todos & Plans](/notes-todos-plans/) — the todos and plans behind the Planning prompts +- [Multi-run](/multi-run/) — run groups and fusion diff --git a/packages/docs/content/docs/pl/magic-prompts.mdx b/packages/docs/content/docs/pl/magic-prompts.mdx index c54be855..aada9935 100644 --- a/packages/docs/content/docs/pl/magic-prompts.mdx +++ b/packages/docs/content/docs/pl/magic-prompts.mdx @@ -21,6 +21,64 @@ Niektóre prompty mają część widoczną (wiadomość, którą zobaczysz) i cz Zmieniłeś zdanie? Każdy prompt ma **reset to default**, a jest też **reset all**, jeśli chcesz zacząć wszystko od nowa. +## Gdzie używany jest każdy prompt + +Dla każdego prompta poniżej podano, gdzie się wykonuje i co go uruchamia. Sprawdź wyzwalacz przed edycją, żeby wiedzieć, który przepływ zmieniasz. + +### Git + +| Prompt | Gdzie się wykonuje | Kiedy się uruchamia | +| --- | --- | --- | +| Generowanie commita | Przycisk generowania w polu commita w widoku git oraz ekran Changes na mobile | Generujesz komunikat commita. Wstawiane są wybrane pliki i tematy ostatnich commitów gałęzi, dzięki czemu komunikat trzyma styl twojego repozytorium. | +| Generowanie PR | Formularz tworzenia pull requesta w zakładce PR widoku git | Generujesz tytuł i treść PR. Wstawiane są gałęzie base i head, commity i zmienione pliki między nimi, twój dodatkowy kontekst oraz szablon PR repozytorium, jeśli istnieje. | +| Rozwiązywanie konfliktu merge/rebase | Okno konfliktów w widoku git, gdy merge lub rebase zatrzyma się na konfliktach | Wybierasz "Resolve in current session" albo "Resolve in new session". Agent czyta pliki z konfliktem, proponuje strategię dla każdego pliku i czeka na twoje potwierdzenie przed edycją, stage'owaniem lub kontynuowaniem operacji. | +| Rozwiązywanie konfliktu cherry-pick | Sekcja "Re-integrate commits" sesji w worktree | Przenoszenie commitów sesji na gałąź docelową trafia na konflikt i przekazujesz go agentowi. Agent rozwiązuje konflikty w tymczasowym worktree, robi stage plików i kontynuuje cherry-pick. | + +### GitHub + +| Prompt | Gdzie się wykonuje | Kiedy się uruchamia | +| --- | --- | --- | +| Review PR | Selektor "Link GitHub PR" w menu załączników kompozytora oraz okno nowego worktree | Dwa wyzwalacze. Przypięcie PR jako kontekstu przygotowuje instrukcje, które wychodzą z twoją następną wiadomością. Utworzenie sesji worktree z PR używa prompta jako pierwszej wiadomości tej sesji, z pełnym kontekstem PR w załączeniu. | +| Review issue | Okno nowego worktree, gdy worktree startuje z issue | Pierwsza wiadomość nowej sesji przegląda issue, z jej treścią i komentarzami jako kontekstem. | +| Review nieudanych checków PR / komentarzy PR / pojedynczego komentarza PR | — | Dziś żaden przepływ ich nie wysyła. Widok PR uruchamiał je kiedyś akcjami review jednym kliknięciem; teraz nieudane checki i komentarze są przypinane jako szkice kontekstu czatu. Zostają edytowalne, aby istniejące nadpisania dalej działały. | + +### Planning + +| Prompt | Gdzie się wykonuje | Kiedy się uruchamia | +| --- | --- | --- | +| Planowanie z todo | Panel Todos w pasku bocznym projektu | Wysyłasz todo do sesji albo nowej sesji w worktree. Tekst todo staje się widoczną wiadomością; instrukcje zamieniają go w planistyczny dialog oparty na pytaniach, zamiast skakać od razu do implementacji. | +| Ulepsz plan | Akcja "Improve" na zapisanym planie w widoku Plans | Wysyłasz zapisany plan do przepływu ulepszania. Agent najpierw czyta plik planu, potem proponuje zmiany zakorzenione w aktualnym stanie repozytorium i proponuje edycję tego samego pliku. | +| Zaimplementuj plan | Akcja "Implement" na zapisanym planie | Wysyłasz zapisany plan do przepływu implementacji. Agent czyta plik planu i implementuje go od początku do końca bez rozszerzania zakresu, zapisując korekty planu z powrotem do pliku, gdy sam plan okaże się błędny. | + +### Session + +Większość z nich zasila komendy z ukośnikiem wpisywane w kompozytorze. Większość pojawia się też jako startowe chipy na szkicu nowej sesji. + +| Prompt | Gdzie się wykonuje | Kiedy się uruchamia | +| --- | --- | --- | +| Tour po kodzie | `/explore` | Prosisz o ogólną orientację w bazie kodu. | +| Podsumowanie sesji | `/summary`, opcjonalnie `/summary ` | Podsumowujesz dotychczasową rozmowę — przydatne do przekazania do nowej sesji. Wymaga istniejącej sesji. | +| Review workspace | `/workspace-review` | Prosisz agenta o przegląd aktualnego diffu workspace pod kątem intencji, poprawności i bezpieczeństwa. | +| Planowanie funkcji | `/plan-feature` | Zamieniasz surowy pomysł na funkcję w plan implementacji przez prowadzony dialog pytań i odpowiedzi. | +| Formułowanie Goal | `/craft-goal`, opcjonalnie `/craft-goal ` | Zamieniasz pomysł w weryfikowalny cel Goal do okna Goal. | +| Nadrobienie bieżące | `/catch-up` | Wracasz do projektu i pytasz, na czym stanęło i co dalej. | +| Debugowanie | `/debug` | Badasz buga: agent stawia hipotezy, potwierdza przyczynę źródłową w kodzie i dopiero wtedy proponuje poprawkę. | +| Ważenie opcji | `/weigh` | Wiesz, co zbudować, ale nie jak. Agent porównuje dwa-trzy podejścia i poleca jedno. | +| Fusion | Akcja "Run fusion" na grupie multi-run | Łączysz wyniki kilku uruchomień w jedną odpowiedź. Wyniki uruchomień są doklejane po instrukcjach. | + +### Prompty bez strony w Settings + +Kilka promptów uruchamia się automatycznie i nie ma edytowalnej strony w Settings: + +| Prompt | Kiedy się uruchamia | +| --- | --- | +| Zaplanowane zadanie | `/schedule-task`, opcjonalnie z początkowym pomysłem. Prowadzi dialog, który definiuje zaplanowane zadanie. | +| Handoff do review | `/handoff-review` albo przycisk Review w widoku diff z włączonym handoffem. Generuje handoff w sesji roboczej. | +| Wiadomość startowa sesji review | Pierwsza wiadomość wygenerowanej sesji review — z handoffem, gdy powstał, bez niego w przeciwnym razie. | +| Feedback z review / odpowiedź implementacji | Przenoszą wiadomości między dwiema sesjami: feedback recenzenta wraca do sesji implementującej, a odpowiedź implementatora wraca do sesji review. | + ## Powiązane - [Przepływy Git i GitHub](/pl/git/) — wiele z tych promptów napędza przepływy git +- [Notatki, todo i plany](/pl/notes-todos-plans/) — todo i plany stojące za promptami Planning +- [Multi-run](/pl/multi-run/) — grupy uruchomień i fusion diff --git a/packages/docs/content/docs/pt-br/magic-prompts.mdx b/packages/docs/content/docs/pt-br/magic-prompts.mdx index a4abf88d..2ef00ae1 100644 --- a/packages/docs/content/docs/pt-br/magic-prompts.mdx +++ b/packages/docs/content/docs/pt-br/magic-prompts.mdx @@ -21,6 +21,64 @@ Alguns prompts têm uma parte visível (a mensagem que você veria) e uma parte Mudou de ideia? Cada prompt tem **reset to default**, e há um **reset all** se você quiser recomeçar em tudo. +## Onde cada prompt é usado + +Cada prompt nas tabelas abaixo indica onde ele roda e o que o dispara. Confira o gatilho antes de editar, para saber qual fluxo você está mudando. + +### Git + +| Prompt | Onde roda | Quando dispara | +| --- | --- | --- | +| Geração de commit | O botão de gerar na caixa de commit da vista git, e a tela Changes no mobile | Você gera uma mensagem de commit. São preenchidos os arquivos selecionados e os assuntos dos commits recentes do branch, para a mensagem seguir o estilo do seu repositório. | +| Geração de PR | O formulário de criação de pull request na aba PR da vista git | Você gera título e corpo de um PR. São preenchidos os branches base e head, os commits e arquivos alterados entre eles, o contexto adicional que você escreveu e o template de PR do repositório, quando existe. | +| Resolução de conflito de merge/rebase | O diálogo de conflitos na vista git, quando um merge ou rebase para em conflitos | Você escolhe "Resolve in current session" ou "Resolve in new session". O agente lê os arquivos em conflito, propõe uma estratégia por arquivo e aguarda sua confirmação antes de editar, fazer stage ou continuar a operação. | +| Resolução de conflito de cherry-pick | A seção "Re-integrate commits" de uma sessão em worktree | Mover os commits da sessão para o branch de destino esbarra em um conflito e você passa para o agente. O agente resolve dentro do worktree temporário, faz stage dos arquivos e continua o cherry-pick. | + +### GitHub + +| Prompt | Onde roda | Quando dispara | +| --- | --- | --- | +| Revisão de PR | O seletor "Link GitHub PR" no menu de anexos do composer, e o diálogo de novo worktree | Dois gatilhos. Anexar um PR como contexto renderiza as instruções, que saem com a sua próxima mensagem. Criar uma sessão de worktree a partir de um PR usa o prompt como primeira mensagem dessa sessão, com o contexto completo do PR anexado. | +| Revisão de issue | O diálogo de novo worktree, quando o worktree parte de uma issue | A primeira mensagem da nova sessão revisa a issue, com o corpo e os comentários anexados como contexto. | +| Revisão de checks falhos / comentários de PR / comentário único de PR | — | Hoje nenhum fluxo os envia. A vista de PR antes os disparava com ações de revisão de um clique; agora checks falhos e comentários são fixados como rascunhos de contexto do chat. Continuam editáveis para que overrides existentes mantenham efeito. | + +### Planning + +| Prompt | Onde roda | Quando dispara | +| --- | --- | --- | +| Planejamento a partir de todo | O painel Todos na barra lateral do projeto | Você envia um todo para uma sessão ou uma nova sessão em worktree. O texto do todo vira a mensagem visível; as instruções transformam isso em um diálogo de planejamento guiado por perguntas, em vez de pular direto para a implementação. | +| Melhorar plano | A ação "Improve" sobre um plano salvo na vista Plans | Você envia um plano salvo para o fluxo de melhoria. O agente lê primeiro o arquivo do plano, propõe mudanças ancoradas no estado atual do repositório e se oferece para editar o mesmo arquivo. | +| Implementar plano | A ação "Implement" sobre um plano salvo | Você envia um plano salvo para o fluxo de implementação. O agente lê o arquivo do plano e o implementa do início ao fim sem ampliar o escopo, salvando ajustes do plano no arquivo quando o próprio plano se mostra errado. | + +### Session + +A maioria alimenta comandos de barra digitados no composer. A maioria também aparece como chips de partida no rascunho de nova sessão. + +| Prompt | Onde roda | Quando dispara | +| --- | --- | --- | +| Tour pelo código | `/explore` | Você pede uma orientação geral do código. | +| Resumo de sessão | `/summary`, opcionalmente `/summary ` | Você resume a conversa até aqui — útil para passar para uma nova sessão. Precisa de uma sessão existente. | +| Revisão do workspace | `/workspace-review` | Você pede ao agente para revisar o diff atual do workspace quanto a intenção, correção e segurança. | +| Planejamento de feature | `/plan-feature` | Você transforma uma ideia grosseira de feature em um plano de implementação por um diálogo guiado de perguntas e respostas. | +| Construir Goal | `/craft-goal`, opcionalmente `/craft-goal ` | Você transforma uma ideia em um objetivo Goal verificável para o diálogo de Goal. | +| Retomar o fio | `/catch-up` | Você volta a um projeto e pergunta onde as coisas pararam e o que fazer a seguir. | +| Depuração | `/debug` | Você investiga um bug: o agente levanta hipóteses, confirma a causa raiz no código e só então propõe uma correção. | +| Pesar opções | `/weigh` | Você sabe o que construir, mas não como. O agente compara duas ou três abordagens e recomenda uma. | +| Fusion | A ação "Run fusion" em um grupo de multi-run | Você combina as saídas de várias execuções em uma resposta. As saídas das execuções são anexadas depois das instruções. | + +### Prompts sem página no Settings + +Alguns prompts disparam automaticamente e não têm página editável no Settings: + +| Prompt | Quando dispara | +| --- | --- | +| Tarefa agendada | `/schedule-task`, opcionalmente com uma ideia inicial. Conduz o diálogo que define uma tarefa agendada. | +| Handoff de revisão | `/handoff-review`, ou o botão Review na vista de diff com handoff ativado. Gera o handoff na sessão de trabalho. | +| Mensagem inicial da sessão de revisão | A primeira mensagem da sessão de revisão gerada — com o handoff quando um foi produzido, sem ele caso contrário. | +| Feedback de revisão / resposta de implementação | Levam mensagens entre as duas sessões: o feedback do revisor volta para a sessão que implementa, e a resposta do implementador retorna à sessão de revisão. | + ## Relacionado - [Fluxos de Git e GitHub](/pt-br/git/) — muitos desses prompts alimentam os fluxos de git +- [Notas, todos e planos](/pt-br/notes-todos-plans/) — os todos e planos por trás dos prompts de Planning +- [Multi-run](/pt-br/multi-run/) — grupos de execução e fusion diff --git a/packages/docs/content/docs/uk/magic-prompts.mdx b/packages/docs/content/docs/uk/magic-prompts.mdx index 7141a0f1..84622208 100644 --- a/packages/docs/content/docs/uk/magic-prompts.mdx +++ b/packages/docs/content/docs/uk/magic-prompts.mdx @@ -21,6 +21,64 @@ OpenChamber використовує вбудовані промпти за ла Передумали? Кожен промпт має **reset to default**, а ще є **reset all**, якщо хочете почати спочатку всюди. +## Де використовується кожен промпт + +Для кожного промпту нижче вказано, де він виконується та який тригер його запускає. Перевірте тригер перед редагуванням, щоб розуміти, який процес ви змінюєте. + +### Git + +| Промпт | Де виконується | Коли спрацьовує | +| --- | --- | --- | +| Генерація коміту | Кнопка генерації в полі коміту у git-поданні та на мобільному екрані Changes | Ви генеруєте повідомлення коміту. Підставляються вибрані файли та теми нещодавніх комітів гілки, щоб стиль відповідав вашому репозиторію. | +| Генерація PR | Форма створення pull request у вкладці PR git-подання | Ви генеруєте заголовок і опис PR. Підставляються базова та головна гілки, коміти та змінені файли між ними, ваш додатковий контекст і PR-шаблон репозиторію, якщо він є. | +| Розв'язання конфліктів merge/rebase | Діалог конфліктів у git-поданні, коли merge або rebase зупинився на конфліктах | Ви обираєте "Resolve in current session" або "Resolve in new session". Агент читає конфліктні файли, пропонує стратегію розв'язання для кожного файлу та чекає на ваше підтвердження перед редагуванням, індексацією чи продовженням операції. | +| Розв'язання конфліктів cherry-pick | Розділ "Re-integrate commits" для сесії у worktree | Перенесення комітів сесії на цільову гілку впирається в конфлікт, і ви передаєте його агентові. Агент розв'язує конфлікти в тимчасовому worktree, індексує файли та продовжує cherry-pick. | + +### GitHub + +| Промпт | Де виконується | Коли спрацьовує | +| --- | --- | --- | +| Рев'ю PR | Пікер "Link GitHub PR" у меню вкладень композера та діалог нового worktree | Два тригери. Прикріплення PR як контексту готує інструкції, які надсилаються разом із вашим наступним повідомленням. Створення сесії у worktree з PR використовує промпт як перше повідомлення сесії з повним контекстом PR. | +| Рев'ю issue | Діалог нового worktree, коли worktree створюється з issue | Перше повідомлення нової сесії рев'ю issue, з тілом і коментарями як контекстом. | +| Рев'ю провалених перевірок PR / коментарів PR / окремого коментаря PR | — | Нині не надсилаються жодним процесом. Подання PR раніше запускало їх кнопками швидкого рев'ю; тепер провалені перевірки й коментарі прикріплюються як чернетки контексту чату. Вони лишаються редагованими, щоб наявні перевизначення продовжували працювати. | + +### Planning + +| Промпт | Де виконується | Коли спрацьовує | +| --- | --- | --- | +| Планування з todo | Панель Todos у проєктній бічній панелі | Ви надсилаєте todo в сесію або нову сесію у worktree. Текст todo стає видимим повідомленням; інструкції перетворюють його на планувальний діалог із питаннями, а не стрибок одразу в імплементацію. | +| Покращення плану | Дія "Improve" для збереженого плану у поданні Plans | Ви надсилаєте збережений план у потік покращення. Агент спершу читає файл плану, потім пропонує зміни на основі поточного стану репозиторію та пропонує відредагувати той самий файл. | +| Імплементація плану | Дія "Implement" для збереженого плану | Ви надсилаєте збережений план у потік імплементації. Агент читає файл плану та імплементує його від початку до кінця без розширення обсягу, зберігаючи корективи плану назад у файл, якщо сам план виявився хибним. | + +### Session + +Більшість із них живлять слеш-команди, які вводяться в композері. Більшість також доступні як стартові чіпи на чернетці нової сесії. + +| Промпт | Де виконується | Коли спрацьовує | +| --- | --- | --- | +| Тур кодовою базою | `/explore` | Ви просите загальний огляд кодової бази. | +| Підсумок сесії | `/summary`, необов'язково `/summary <тема>` | Ви підсумовуєте поточну розмову — зручно для передачі в нову сесію. Потрібна наявна сесія. | +| Рев'ю робочої області | `/workspace-review` | Ви просите агента переглянути поточний diff робочої області на намір, коректність і безпеку. | +| Планування фічі | `/plan-feature` | Ви перетворюєте грубу ідею фічі на план імплементації через керований діалог питань і відповідей. | +| Формулювання Goal | `/craft-goal`, необов'язково `/craft-goal <ідея>` | Ви перетворюєте ідею на перевірювану ціль Goal для діалогу Goal. | +| Catch up | `/catch-up` | Ви повертаєтеся до проєкту й питаєте, на чому зупинилися і що робити далі. | +| Дебаг | `/debug` | Ви досліджуєте баг: агент формує гіпотези, підтверджує кореневу причину з коду і лише тоді пропонує виправлення. | +| Зважування варіантів | `/weigh` | Ви знаєте, що будувати, але не як. Агент порівнює два-три підходи та рекомендує один. | +| Fusion | Дія "Run fusion" на групі multi-run | Ви об'єднуєте виводи кількох запусків в одну відповідь. Виводи запусків додаються після інструкцій. | + +### Промпти без сторінки в Settings + +Кілька промптів спрацьовують автоматично й не мають редагованих сторінок у Settings: + +| Промпт | Коли спрацьовує | +| --- | --- | +| Заплановане завдання | `/schedule-task`, необов'язково з початковою ідеєю. Веде діалог, який визначає заплановане завдання. | +| Handoff для рев'ю | `/handoff-review` або кнопка Review у поданні diff із увімкненим handoff. Генерує handoff у робочій сесії. | +| Стартове повідомлення сесії рев'ю | Перше повідомлення згенерованої сесії рев'ю — з handoff, якщо він створений, або без нього. | +| Відгук рев'ю / відповідь імплементатора | Переносять повідомлення між двома сесіями: відгук рев'юера повертається в сесію імплементації, а відповідь імплементатора — назад у сесію рев'ю. | + ## Пов'язане - [Робочі процеси Git і GitHub](/uk/git/) — багато з цих промптів живлять git-процеси +- [Нотатки, todo та плани](/uk/notes-todos-plans/) — todo і плани за промптами групи Planning +- [Multi-run](/uk/multi-run/) — групи запусків і fusion diff --git a/packages/docs/content/docs/zh-cn/magic-prompts.mdx b/packages/docs/content/docs/zh-cn/magic-prompts.mdx index 65d17398..b291644e 100644 --- a/packages/docs/content/docs/zh-cn/magic-prompts.mdx +++ b/packages/docs/content/docs/zh-cn/magic-prompts.mdx @@ -21,6 +21,64 @@ description: 自定义 OpenChamber 自动化流程背后的内置提示词。 改主意了?每个提示词都有 **reset to default**,如果你想在所有地方重新开始,还有一个 **reset all**。 +## 每个提示词在哪里使用 + +下表列出每个提示词的运行位置和触发时机。编辑前先看清触发条件,你就知道自己在改哪个流程。 + +### Git + +| 提示词 | 运行位置 | 触发时机 | +| --- | --- | --- | +| 提交信息生成 | git 视图提交框中的生成按钮,以及移动端 Changes 页面 | 你生成提交信息时。会填入选中的文件和分支最近的提交主题,让提交信息符合仓库的现有风格。 | +| PR 生成 | git 视图 PR 标签页中的创建 pull request 表单 | 你生成 PR 标题和正文时。会填入 base 和 head 分支、两者之间的提交和变更文件、你补充的附加上下文,以及仓库的 PR 模板(如果存在)。 | +| merge/rebase 冲突解决 | git 视图中的冲突对话框,当 merge 或 rebase 因冲突停止时 | 你选择 "Resolve in current session" 或 "Resolve in new session"。智能体会阅读冲突文件,为每个文件提出解决策略,并等你确认后才编辑、暂存或继续操作。 | +| cherry-pick 冲突解决 | worktree 会话的 "Re-integrate commits" 区域 | 把会话的提交迁移到目标分支时遇到冲突并交给智能体。智能体在临时 worktree 中解决冲突、暂存文件并继续 cherry-pick。 | + +### GitHub + +| 提示词 | 运行位置 | 触发时机 | +| --- | --- | --- | +| PR 审阅 | 输入框附件菜单中的 "Link GitHub PR" 选择器,以及新 worktree 对话框 | 两个触发点。把 PR 附为上下文时会渲染指令,并随你的下一条消息发出。从 PR 创建 worktree 会话时,该提示词成为会话的开场消息,并附上完整 PR 上下文。 | +| issue 审阅 | 新 worktree 对话框,当你从 issue 创建 worktree 时 | 新会话的开场消息会审阅该 issue,并将其正文和评论附为上下文。 | +| PR 失败检查 / PR 评论 / 单条 PR 评论 | — | 目前没有任何流程发送它们。PR 视图过去通过一键审阅操作触发这些提示词;现在失败的检查和评论会改为固定为聊天上下文草稿。保留它们是为了让已有的覆盖配置继续生效。 | + +### Planning + +| 提示词 | 运行位置 | 触发时机 | +| --- | --- | --- | +| todo 规划 | 项目侧边栏中的 Todos 面板 | 你把一个 todo 发送到会话或新的 worktree 会话。todo 文本成为可见消息;指令会把它变成先提问的规划对话,而不是直接开始实现。 | +| 改进计划 | Plans 视图中已保存计划上的 "Improve" 操作 | 你把已保存的计划送入改进流程。智能体先读取计划文件,再基于仓库当前状态提出修改,并主动提出编辑同一个文件。 | +| 实现计划 | 已保存计划上的 "Implement" 操作 | 你把已保存的计划送入实现流程。智能体读取计划文件并端到端地实现它,不扩大范围;当计划本身有问题时,会把计划调整保存回该文件。 | + +### Session + +其中大多数由在输入框中输入的斜杠命令驱动。大多数也会以启动芯片的形式出现在新会话草稿页上。 + +| 提示词 | 运行位置 | 触发时机 | +| --- | --- | --- | +| 代码库导览 | `/explore` | 你想要一份代码库的高层次概览。 | +| 会话总结 | `/summary`,可选 `/summary <主题>` | 你总结目前的对话 — 适合移交给新会话。需要已存在的会话。 | +| 工作区审阅 | `/workspace-review` | 你让智能体从意图、正确性和安全性角度审阅当前的工作区 diff。 | +| 功能规划 | `/plan-feature` | 你通过引导式问答对话,把粗略的功能想法变成实现计划。 | +| Goal 制定 | `/craft-goal`,可选 `/craft-goal <想法>` | 你把一个想法变成可用于 Goal 对话框的可验证 Goal 目标。 | +| 快速追平 | `/catch-up` | 你回到一个项目,想知道进展如何、接下来做什么。 | +| 调试 | `/debug` | 你调查一个 bug:智能体提出假设、从代码确认根因,然后才提出修复方案。 | +| 权衡选项 | `/weigh` | 你知道要做什么,但不知道怎么做。智能体会比较两三种方案并推荐其一。 | +| Fusion | multi-run 组上的 "Run fusion" 操作 | 你把多次运行的输出合并成一个答案。运行输出会附加在指令之后。 | + +### 没有 Settings 页面的提示词 + +少数提示词会自动触发,在 Settings 中没有可编辑的页面: + +| 提示词 | 触发时机 | +| --- | --- | +| 计划任务 | `/schedule-task`,可选附带初始想法。引导完成定义计划任务的对话。 | +| 审阅交接 | `/handoff-review`,或 diff 视图中启用交接时的 Review 按钮。在当前工作会话中生成交接内容。 | +| 审阅会话开场消息 | 生成的审阅会话的开场消息 — 生成了交接内容时包含它,否则不包含。 | +| 审阅反馈 / 实现响应 | 在两个会话之间传递消息:审阅者的反馈回到实现会话,实现者的响应返回审阅会话。 | + ## 相关内容 - [Git 与 GitHub 工作流](/zh-cn/git/) — 其中许多提示词为 git 流程提供动力 +- [笔记、todo 与计划](/zh-cn/notes-todos-plans/) — Planning 提示词背后的 todo 和计划 +- [Multi-run](/zh-cn/multi-run/) — 运行组与 fusion diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 0442f814..b2ede00a 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1369,7 +1369,7 @@ const maybeShowNativeNotification = (rawInput) => { notification.on('click', () => { focusForegroundWindow(); if (sessionId) { - emitToAllWindows('openchamber:open-session', { sessionId, directory }); + emitToPrimaryWindow('openchamber:open-session', { sessionId, directory }); } release(); }); @@ -1770,6 +1770,15 @@ const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => : probe?.status === 'wrong-service' ? 'wrong-service' : 'ok'; + // A relay-capable host is not a recovery case just because its stored + // direct URL failed the http probe — that URL is often the pairing + // creator's own loopback (unreachable here, or worse, someone else's + // service). The relay leg is activated in the renderer's relay restore, + // which cannot run from a recovery screen: boot to main on the local + // substrate and let it pick direct-or-relay. + if (status !== 'ok' && sanitizeHostRelayForStorage(host.relay)) { + return { target: 'remote', status: 'ok', hostId: host.id, url: host.apiUrl || host.url, ...availability }; + } return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url, ...availability }; }; @@ -1997,6 +2006,18 @@ const emitToAllWindows = (event, detail) => { } }; +// Session navigation must land in ONE window. Broadcasting it makes every +// open window adopt the same session, hijacking whatever the other windows +// were doing. +const emitToPrimaryWindow = (event, detail) => { + const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()); + if (windows.length === 0) return; + const target = (state.mainWindow && !state.mainWindow.isDestroyed()) + ? state.mainWindow + : windows.find((window) => window.isFocused()) || windows.find((window) => window.isVisible()) || windows[0]; + emitToWindow(target, event, detail); +}; + const setTaskbarProgress = (value) => { if (process.platform !== 'win32') return; for (const browserWindow of BrowserWindow.getAllWindows()) { @@ -2278,7 +2299,7 @@ const dispatchDeepLink = (link) => { } if (link.type === 'session' && link.value) { - emitToAllWindows('openchamber:open-session', { sessionId: link.value }); + emitToPrimaryWindow('openchamber:open-session', { sessionId: link.value }); return; } if (link.type === 'host' && link.value) { @@ -3013,12 +3034,24 @@ const resolveInitialUrl = async () => { } } + const defaultHostRelayCapable = Boolean( + config.defaultHostId + && config.defaultHostId !== LOCAL_HOST_ID + && sanitizeHostRelayForStorage(config.hosts.find((entry) => entry.id === config.defaultHostId)?.relay), + ); if (apiBaseUrl && apiBaseUrl !== localUrl) { remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000, clientToken, requestHeaders); - if (remoteProbe.status === 'unreachable') { + if (remoteProbe.status === 'unreachable' && !defaultHostRelayCapable) { remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000, clientToken, requestHeaders); } - if (remoteProbe.status === 'unreachable') { + // The renderer's relay restore owns transport selection for relay-capable + // hosts; any failed direct probe falls back to the local substrate. + if (remoteProbe.status !== 'ok' && defaultHostRelayCapable) { + apiBaseUrl = localUrl || ''; + clientToken = localUrl ? readDesktopLocalClientToken() : ''; + requestHeaders = {}; + initialUrl = localUiUrl; + } else if (remoteProbe.status === 'unreachable') { state.unreachableHosts.add(apiBaseUrl); apiBaseUrl = localUrl || ''; clientToken = localUrl ? readDesktopLocalClientToken() : ''; diff --git a/packages/electron/package.json b/packages/electron/package.json index b4d03bec..da27bdd8 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.20.0", + "version": "1.21.0", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index c9648636..f42fae11 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.20.0", + "version": "1.21.0", "private": true, "type": "module", "main": "src/main.tsx", @@ -45,7 +45,7 @@ "@dnd-kit/utilities": "^3.2.2", "@legendapp/list": "3.3.8", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.4.0", "@simplewebauthn/browser": "13.3.0", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index f213c11f..9cd9e831 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -20,7 +20,6 @@ import { useAgentMemorySync } from '@/hooks/useAgentMemorySync'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useConfigStore } from '@/stores/useConfigStore'; -import { hasModifier } from '@/lib/utils'; import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop'; import { getInjectedBootOutcome, @@ -723,25 +722,12 @@ function App({ apis }: AppProps) { useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled }); + // Palette-only action: the memory debug panel has no keyboard shortcut. React.useEffect(() => { - if (embeddedSessionChat) { - return; - } - - const handleKeyDown = (e: KeyboardEvent) => { - const isDebugShortcut = hasModifier(e) - && e.shiftKey - && !e.altKey - && (e.code === 'KeyD' || e.key.toLowerCase() === 'd'); - - if (isDebugShortcut) { - e.preventDefault(); - setShowMemoryDebug(prev => !prev); - } - }; - - window.addEventListener('keydown', handleKeyDown, true); - return () => window.removeEventListener('keydown', handleKeyDown, true); + if (embeddedSessionChat) return; + const handleToggle = () => setShowMemoryDebug((previous) => !previous); + window.addEventListener('openchamber:memory-debug-toggle', handleToggle); + return () => window.removeEventListener('openchamber:memory-debug-toggle', handleToggle); }, [embeddedSessionChat]); React.useEffect(() => { diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 4d30dc00..5c4a5079 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -12,6 +12,7 @@ import { SettingsView } from '@/components/views/SettingsView'; import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; @@ -772,6 +773,23 @@ export function MobileApp({ apis }: MobileAppProps) { }; }, [isNativeMobileApp, handleNativeResume]); + // A confirmed mid-session auth expiry (classified centrally from live 401 + // traffic) runs the same seq-guarded re-probe the resume path uses: it ends + // in needs-login → the native welcome screen with the auth-expired notice. + // The shared web banner never renders on native (the session gate is not + // mounted here), so this is the only surface reacting to the signal. + React.useEffect(() => { + if (!isNativeMobileApp) return; + return useAuthSessionStore.subscribe((store, previous) => { + if (store.state === 'expired' && previous.state !== 'expired') { + handleNativeResume(); + // The probe ladder owns the outcome from here; the shared store goes + // back to 'ok' so a later expiry can signal again. + useAuthSessionStore.getState().markAuthenticated(); + } + }); + }, [isNativeMobileApp, handleNativeResume]); + React.useEffect(() => { registerRuntimeAPIs(apis); return () => registerRuntimeAPIs(null); diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index 22e9705b..cd1f33ce 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -15,7 +15,7 @@ import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { resetStreamingState } from '@/sync/streaming'; -import { useGlobalSessionStatusStore, replaceGlobalSessionStatusById } from '@/sync/global-session-status'; +import { replaceGlobalSessionStatusById } from '@/sync/global-session-status'; import { resetSessionOrdering } from '@/sync/session-ordering'; import { resetSessionActivityTiming } from '@/sync/session-activity-timing'; import { syncDesktopSettings } from '@/lib/persistence'; diff --git a/packages/ui/src/components/auth/AuthExpiredBanner.tsx b/packages/ui/src/components/auth/AuthExpiredBanner.tsx new file mode 100644 index 00000000..986b7484 --- /dev/null +++ b/packages/ui/src/components/auth/AuthExpiredBanner.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; + +/** + * Non-blocking notice that the OpenChamber session expired mid-work. It never + * takes the screen on its own: work stays visible and interactive, and only + * the explicit "Log in" click hands control to the session gate's full login + * flow (password, passkey, desktop shell — all already there). + */ +export const AuthExpiredBanner: React.FC = () => { + const { t } = useI18n(); + const authState = useAuthSessionStore((store) => store.state); + const markReauthenticating = useAuthSessionStore((store) => store.markReauthenticating); + + if (authState !== 'expired') { + return null; + } + + return ( + // Below the header on purpose: the header row can be a window-drag region + // on desktop, where nothing under the cursor is clickable. +
+
+ + {t('sessionAuth.expired.banner')} + +
+
+ ); +}; diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 553804cc..702d879b 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -12,6 +12,8 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { installAuthSessionFocusWatch, useAuthSessionStore } from '@/lib/runtime-auth-expiry'; +import { AuthExpiredBanner } from './AuthExpiredBanner'; import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts'; @@ -351,6 +353,7 @@ export const SessionAuthGate: React.FC = ({ const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null); const passwordInputRef = React.useRef(null); const hasResyncedRef = React.useRef(skipAuth); + const hasBootstrapResyncedRef = React.useRef(skipAuth); React.useEffect(() => { if (typeof window === 'undefined') { @@ -557,6 +560,27 @@ export const SessionAuthGate: React.FC = ({ } }, [skipAuth, state]); + // Mid-session expiry: the banner asks for a re-login by flipping the shared + // auth store to 'reauthenticating'; the gate answers with its own status + // check, which lands in the full 'locked' flow on a genuine 401. A + // successful login resolves the store back to 'ok'. + const authSessionState = useAuthSessionStore((store) => store.state); + React.useEffect(() => { + if (!skipAuth) installAuthSessionFocusWatch(); + }, [skipAuth]); + React.useEffect(() => { + if (skipAuth) return; + if (authSessionState === 'reauthenticating') { + void checkStatusRef.current?.(); + } + }, [authSessionState, skipAuth]); + React.useEffect(() => { + if (skipAuth) return; + if (state === 'authenticated' && useAuthSessionStore.getState().state !== 'ok') { + useAuthSessionStore.getState().markAuthenticated(); + } + }, [skipAuth, state]); + React.useEffect(() => { if (state === 'locked' && passwordInputRef.current) { passwordInputRef.current.focus(); @@ -570,10 +594,18 @@ export const SessionAuthGate: React.FC = ({ } if (state === 'authenticated' && !hasResyncedRef.current) { hasResyncedRef.current = true; + // First authentication of this page load is bootstrap: adopt the + // persisted workspace pointers. A re-login after mid-session expiry is + // not — this window already has its own workspace, and the shared + // settings document may carry another window's pointers. + const isBootstrapResync = !hasBootstrapResyncedRef.current; + hasBootstrapResyncedRef.current = true; void (async () => { await initializeAppearancePreferences(); - await syncDesktopSettings(); - await applyPersistedDirectoryPreferences(); + await syncDesktopSettings({ adoptWorkspace: isBootstrapResync }); + if (isBootstrapResync) { + await applyPersistedDirectoryPreferences(); + } })(); } }, [skipAuth, state]); @@ -983,5 +1015,10 @@ export const SessionAuthGate: React.FC = ({ ); } - return <>{children}; + return ( + <> + {skipAuth ? null : } + {children} + + ); }; diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 0873eee2..d3c71b23 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -18,6 +18,7 @@ import { StatusRowContainer } from './StatusRowContainer'; import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { PromptNavigatorRail } from './components/PromptNavigatorRail'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { useScrollShadow } from '@/components/ui/useScrollShadow'; import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll'; import { useChatTimelineController } from './hooks/useChatTimelineController'; @@ -645,6 +646,8 @@ export const ChatContainer: React.FC = ({ suspendPartUpdatesForMessageId: streamingMessageId, }); const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; + const authSessionExpired = useAuthSessionStore((store) => store.state !== 'ok'); + const wasAuthExpiredRef = React.useRef(false); const sessionMessageLoadState = useSessionMessageLoadState( currentSessionId ?? '', effectiveSessionDirectory, @@ -1170,6 +1173,23 @@ export const ChatContainer: React.FC = ({ void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory); }, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]); + // A load that failed while the session was expired retries itself the + // moment the re-login lands — the error screen should never outlive its + // cause. + React.useEffect(() => { + if (authSessionExpired) { + wasAuthExpiredRef.current = true; + return; + } + if (wasAuthExpiredRef.current) { + wasAuthExpiredRef.current = false; + if (sessionMessageLoadState.status === 'error') { + retrySessionLoad(); + } + } + }, [authSessionExpired, retrySessionLoad, sessionMessageLoadState.status]); + + React.useEffect(() => { if (!active || !currentSessionId) return; if (lastScrolledSessionKeyRef.current === currentSessionKey) return; @@ -1298,10 +1318,20 @@ export const ChatContainer: React.FC = ({

{t('chat.container.sessionLoadError.title')}

-

{t('chat.container.sessionLoadError.description')}

- +

+ {authSessionExpired + ? t('chat.container.sessionLoadError.authDescription') + : t('chat.container.sessionLoadError.description')} +

+ {authSessionExpired ? ( + + ) : ( + + )} ); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index f088d16d..1c5a40f1 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -77,6 +77,8 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { usePermissionStore } from '@/stores/permissionStore'; import { togglePermissionAutoAccept } from './permissionAutoAccept'; +import { useKeybind } from '@/hooks/useKeybind'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { extractGitChangedFiles } from './changedFiles'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; @@ -417,7 +419,6 @@ const ChatInputComponent: React.FC = ({ const ensureGitStatus = useGitStore((state) => state.ensureStatus); const fetchGitStatus = useGitStore((state) => state.fetchStatus); const clearGitDiffCache = useGitStore((state) => state.clearDiffCache); - const [showAbortStatus, setShowAbortStatus] = React.useState(false); const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept); const [isNarrowComposer, setIsNarrowComposer] = React.useState(false); const [attachmentPreview, setAttachmentPreview] = React.useState({ @@ -695,7 +696,6 @@ const ChatInputComponent: React.FC = ({ attachments, }; }, [resolveInlineFileMention]); - const abortTimeoutRef = React.useRef | null>(null); const prevWasAbortedRef = React.useRef(false); // Issue linking state @@ -964,6 +964,14 @@ const ChatInputComponent: React.FC = ({ const queuedMessageId = options?.queuedMessageId; const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; const capturedTarget = messageQueueTarget; + // An expired session cannot deliver anything: keep the prompt in the + // composer and point at the login banner instead of burning the send + // on a guaranteed 401. + if (useAuthSessionStore.getState().state !== 'ok') { + toast.error(t('sessionAuth.expired.sendBlocked')); + return; + } + // Snapshot the draft and current-session identity before the first // async gap so a later sidebar selection cannot reroute the send. const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null; @@ -1382,10 +1390,25 @@ const ChatInputComponent: React.FC = ({ console.error('Message send failed:', rawMessage || error); restoreConsumedDrafts(); - const currentInput = composerRef.current?.getValue() ?? messageRef.current; - if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) { - setMessage(inputSnapshot.message); - writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); + // A failed send returns the typed prompt no matter WHY it failed — + // auth, network, server, anything. Losing a long prompt to a toast + // is the one outcome this handler must never produce. + if (inputSnapshot.message) { + if (currentChatDraftIdentityRef.current !== chatDraftIdentity) { + // The user switched sessions mid-send: restore into that + // session's persisted draft, not the visible composer. + writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); + } else { + const currentInput = composerRef.current?.getValue() ?? messageRef.current; + if (!currentInput || currentInput === inputSnapshot.message) { + setMessage(inputSnapshot.message); + writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); + } else { + // New typing already lives in the composer; the failed + // prompt joins it instead of clobbering either text. + useInputStore.getState().setPendingInputText(inputSnapshot.message, 'append'); + } + } } const isSoftNetworkError = @@ -1696,29 +1719,15 @@ const ChatInputComponent: React.FC = ({ containerRef: dropZoneRef, }); - const startAbortIndicator = React.useCallback(() => { - if (abortTimeoutRef.current) { - clearTimeout(abortTimeoutRef.current); - abortTimeoutRef.current = null; - } - - setShowAbortStatus(true); - - abortTimeoutRef.current = setTimeout(() => { - setShowAbortStatus(false); - abortTimeoutRef.current = null; - }, 1800); - }, []); const handleAbort = React.useCallback(() => { clearAbortPrompt(); - startAbortIndicator(); // btw mode: the stop button stops the fork's turn, not the main // session's. const abortTarget = isBtwActive && btwSessionId ? btwSessionId : currentSessionId; void abortCurrentOperation(abortTarget || undefined); - }, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive, startAbortIndicator]); + }, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive]); const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => { const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction); @@ -2562,31 +2571,20 @@ const ChatInputComponent: React.FC = ({ t, ]); - React.useEffect(() => { - const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId; - if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) { - startAbortIndicator(); - if (currentSessionId) { - acknowledgeSessionAbort(currentSessionId); - } - } - prevWasAbortedRef.current = pendingAbortBanner; - }, [ - abortPromptSessionId, - acknowledgeSessionAbort, - currentSessionId, - showAbortStatus, - startAbortIndicator, - ]); + useKeybind('toggle_permission_auto_accept', () => { + if (!isPermissionAutoAcceptInteractive) return false; + handlePermissionAutoAcceptToggle(); + }); + // Acknowledging the abort record is what lets the working chip resume for + // the next run; the old "Aborted" banner that used to accompany it is gone. React.useEffect(() => { - return () => { - if (abortTimeoutRef.current) { - clearTimeout(abortTimeoutRef.current); - abortTimeoutRef.current = null; - } - }; - }, []); + const pendingAbort = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId; + if (!prevWasAbortedRef.current && pendingAbort && currentSessionId) { + acknowledgeSessionAbort(currentSessionId); + } + prevWasAbortedRef.current = pendingAbort; + }, [abortPromptSessionId, acknowledgeSessionAbort, currentSessionId]); return ( <> @@ -2657,7 +2655,6 @@ const ChatInputComponent: React.FC = ({ directory={currentSessionDirectoryForSync ?? currentDirectory} /> = ({ }, [chatRenderMode, isMessageCompleted, isUser, visibleParts]); - const assistantTextParts = React.useMemo(() => { - if (isUser) { - return []; - } - return visibleParts.filter((part) => part.type === 'text'); - }, [isUser, visibleParts]); - const toolParts = React.useMemo(() => { if (isUser) { return []; @@ -545,19 +538,6 @@ const ChatMessage: React.FC = ({ const shouldHideUserMessage = isUser && displayParts.length === 0; - // Message is considered to have an "open step" if info.finish is not yet present - const hasOpenStep = typeof messageFinish !== 'string'; - - const shouldCoordinateRendering = React.useMemo(() => { - if (isUser) { - return false; - } - if (assistantTextParts.length === 0 || toolParts.length === 0) { - return hasOpenStep; - } - return true; - }, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]); - const themeVariant = currentTheme?.metadata.variant; const isDarkTheme = React.useMemo(() => { if (themeVariant) { diff --git a/packages/ui/src/components/chat/ComposerStatusBar.tsx b/packages/ui/src/components/chat/ComposerStatusBar.tsx index e8c1ebda..883aa098 100644 --- a/packages/ui/src/components/chat/ComposerStatusBar.tsx +++ b/packages/ui/src/components/chat/ComposerStatusBar.tsx @@ -116,13 +116,11 @@ const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => { const EMPTY_TODOS: TodoItem[] = []; interface ComposerStatusBarProps { - showAbortStatus?: boolean; showTodos?: boolean; leftAccessory?: React.ReactNode; } export const ComposerStatusBar: React.FC = ({ - showAbortStatus, showTodos = true, leftAccessory, }) => { @@ -186,7 +184,7 @@ export const ComposerStatusBar: React.FC = ({ const hasTodoContent = showTodos && statusSummary.left > 0; const hasLeftAccessory = Boolean(leftAccessory); - const hasContent = Boolean(showAbortStatus) || hasTodoContent || hasLeftAccessory; + const hasContent = hasTodoContent || hasLeftAccessory; const popoverRef = React.useRef(null); React.useEffect(() => { @@ -252,16 +250,7 @@ export const ComposerStatusBar: React.FC = ({
{/* Left: abort status | pending-changes accessory */}
- {showAbortStatus ? ( -
- - -
- ) : leftAccessory ? ( - leftAccessory - ) : null} + {leftAccessory ?? null}
{/* Right: todos dropdown */} diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 4197f067..07ce00a3 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1534,6 +1534,54 @@ const MessageList = React.forwardRef(({ return true; }, [allEntries.length]); + // A navigation scroll lands on estimates: an unmounted target teleports + // to its estimated offset, and even a mounted one drifts when neighbours + // finish measuring a frame later. This settle loop re-aligns the target to + // the requested viewport position until the layout stops moving, and backs + // off the moment the user touches the scroll. + const settleNavigationTarget = React.useCallback(( + findElement: () => HTMLElement | null, + desiredOffsetTop: number, + ) => { + const container = resolveScrollContainer(); + if (!container || typeof window === 'undefined') { + return; + } + let frames = 0; + let stable = 0; + let cancelled = false; + const cancelOnUserInput = () => { + cancelled = true; + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + }; + container.addEventListener('touchstart', cancelOnUserInput, { passive: true }); + container.addEventListener('wheel', cancelOnUserInput, { passive: true }); + const step = () => { + if (cancelled) return; + const element = findElement(); + if (element) { + const delta = element.getBoundingClientRect().top + - container.getBoundingClientRect().top + - desiredOffsetTop; + if (Math.abs(delta) > 0.5) { + container.scrollTop += delta; + stable = 0; + } else { + stable += 1; + } + } + frames += 1; + if (stable >= ANCHOR_HOLD_STABLE_FRAMES || frames >= ANCHOR_HOLD_MAX_FRAMES) { + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + return; + } + window.requestAnimationFrame(step); + }; + window.requestAnimationFrame(step); + }, [resolveScrollContainer]); + const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => { const container = resolveScrollContainer(); if (!container) { @@ -1569,14 +1617,19 @@ const MessageList = React.forwardRef(({ if (!container) { return false; } - const turnElement = container.querySelector(`[data-turn-id="${turnId}"]`); + const findTurnElement = () => container.querySelector(`[data-turn-id="${turnId}"]`); + const turnElement = findTurnElement(); if (turnElement) { turnElement.scrollIntoView({ behavior, block: 'start' }); + if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0); return true; } - - return scrollHistoryIndexIntoView(index); + if (!scrollHistoryIndexIntoView(index)) { + return false; + } + if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0); + return true; }, scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => { @@ -1586,8 +1639,12 @@ const MessageList = React.forwardRef(({ return false; } - return scrollMessageElementIntoView(messageId, behavior) + const didScroll = scrollMessageElementIntoView(messageId, behavior) || scrollHistoryIndexIntoView(index); + if (didScroll && behavior !== 'smooth') { + settleNavigationTarget(() => findMessageElement(messageId), 50); + } + return didScroll; }, holdViewportAnchor: (anchor) => { @@ -1730,7 +1787,7 @@ const MessageList = React.forwardRef(({ return () => { objectRef.current = null; }; - }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, turnIndexMap, ref]); + }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, settleNavigationTarget, turnIndexMap, ref]); const anchoredEndSpace = React.useMemo(() => { const resolved = resolveChatListAnchoredEndSpace( diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 7a953090..4863a164 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -324,7 +324,9 @@ export const ModelControls: React.FC = ({ const providers = useConfigStore((state) => state.providers); const currentProviderId = useConfigStore((state) => state.currentProviderId); const currentModelId = useConfigStore((state) => state.currentModelId); - const currentVariant = useConfigStore((state) => state.currentVariant); + const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant); + const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection); + const currentVariant = currentVariantSelection.override ?? undefined; const currentAgentName = useConfigStore((state) => state.currentAgentName); const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant); const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent); @@ -332,6 +334,7 @@ export const ModelControls: React.FC = ({ const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); const setModel = useConfigStore((state) => state.setModel); const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant); + const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride); const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants); const setAgent = useConfigStore((state) => state.setAgent); const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider); @@ -693,6 +696,30 @@ export const ModelControls: React.FC = ({ return variants ? Object.keys(variants) : []; }, [providers]); + const resolveInheritedVariantForModel = React.useCallback((providerId: string, modelId: string, agentName?: string | null) => { + const variantOptions = getModelVariantOptions(providerId, modelId); + if (variantOptions.length === 0) return undefined; + + let currentInherited: string | undefined; + if (currentProviderId === providerId && currentModelId === modelId) { + currentInherited = currentVariantSelection.inherited + ?? (currentVariantSelection.override === null || currentVariantSelection.override === undefined + ? effectiveCurrentVariant + : undefined); + } + + const effectiveAgentName = agentName ?? uiAgentName ?? currentAgentName; + const agent = effectiveAgentName ? agents.find((candidate) => candidate.name === effectiveAgentName) : undefined; + const agentVariant = ( + agent?.model?.providerID === providerId + && agent.model.modelID === modelId + ) ? agent.variant : undefined; + const candidates = currentSessionId + ? [agentVariant, settingsDefaultVariant, currentInherited] + : [currentInherited, agentVariant, settingsDefaultVariant]; + return candidates.find((candidate) => candidate !== undefined && variantOptions.includes(candidate)); + }, [agents, currentAgentName, currentModelId, currentProviderId, currentSessionId, currentVariantSelection, effectiveCurrentVariant, getModelVariantOptions, settingsDefaultVariant, uiAgentName]); + const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string) => { const variantOptions = getModelVariantOptions(providerId, modelId); if (variantOptions.length === 0) { @@ -711,10 +738,6 @@ export const ModelControls: React.FC = ({ return currentVariant; } - if (!currentSessionId && settingsDefaultVariant && variantOptions.includes(settingsDefaultVariant)) { - return settingsDefaultVariant; - } - return undefined; }, [ currentAgentName, @@ -724,7 +747,6 @@ export const ModelControls: React.FC = ({ currentVariant, getAgentModelVariantForSession, getModelVariantOptions, - settingsDefaultVariant, uiAgentName, ]); @@ -748,7 +770,10 @@ export const ModelControls: React.FC = ({ } manualVariantSelectionRef.current = true; - setCurrentVariant(variant); + setCurrentVariantOverride( + variant ?? null, + resolveInheritedVariantForModel(providerId, modelId, agentNameOverride), + ); addRecentEffort(providerId, modelId, variant); const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName(); @@ -759,9 +784,11 @@ export const ModelControls: React.FC = ({ addRecentEffort, currentSessionId, getModelVariantOptions, + resolveInheritedVariantForModel, resolveLiveAgentName, saveAgentModelVariantForSession, setCurrentVariant, + setCurrentVariantOverride, ]); const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => { @@ -1121,18 +1148,21 @@ export const ModelControls: React.FC = ({ } if (currentVariant && !availableVariants.includes(currentVariant)) { - setCurrentVariant(undefined); + setCurrentVariantOverride( + null, + resolveInheritedVariantForModel(currentProviderId, currentModelId), + ); return; } // Draft state (no session yet): seed from settings default, but don't override // user selection while drafting. if (!currentSessionId) { - if (!currentVariant && !manualVariantSelectionRef.current) { + if (currentVariantSelection.override === undefined && !manualVariantSelectionRef.current) { const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant) ? settingsDefaultVariant : undefined; - setCurrentVariant(desired); + setCurrentVariantOverride(desired ?? null, desired); } return; } @@ -1144,13 +1174,14 @@ export const ModelControls: React.FC = ({ currentModelId, ); - const resolvedSaved = savedVariant && availableVariants.includes(savedVariant) - ? savedVariant - : settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant) - ? settingsDefaultVariant - : undefined; - - setCurrentVariant(resolvedSaved); + const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId); + if (savedVariant && availableVariants.includes(savedVariant)) { + setCurrentVariantOverride(savedVariant, inheritedVariant); + } else if (currentVariantSelection.override === null) { + setCurrentVariantOverride(null, inheritedVariant); + } else { + setCurrentVariant(inheritedVariant); + } manualVariantSelectionRef.current = false; }, [ availableVariants, @@ -1160,8 +1191,12 @@ export const ModelControls: React.FC = ({ currentProviderId, currentModelId, currentVariant, + currentVariantSelection.override, + effectiveCurrentVariant, getAgentModelVariantForSession, + resolveInheritedVariantForModel, setCurrentVariant, + setCurrentVariantOverride, settingsDefaultVariant, ]); diff --git a/packages/ui/src/components/chat/PermissionCard.tsx b/packages/ui/src/components/chat/PermissionCard.tsx index cd63576b..851d70d3 100644 --- a/packages/ui/src/components/chat/PermissionCard.tsx +++ b/packages/ui/src/components/chat/PermissionCard.tsx @@ -10,6 +10,10 @@ import { Icon } from "@/components/icon/Icon"; import { DiffPreview, WritePreview } from './DiffPreview'; import { useI18n } from '@/lib/i18n'; import { getVisiblePermissionPatterns } from './permissionCardPatterns'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; + +// Newest pending card owns the keyboard; older cards wait their turn. +const activePermissionCardIds: string[] = []; const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = { margin: 0, @@ -126,6 +130,33 @@ export const PermissionCard: React.FC = ({ } }; + const handleResponseRef = React.useRef(handleResponse); + handleResponseRef.current = handleResponse; + + React.useEffect(() => { + if (hasResponded) return; + activePermissionCardIds.push(permission.id); + const handleKeyDown = (event: KeyboardEvent) => { + if (activePermissionCardIds.at(-1) !== permission.id) return; + if (!event.altKey || event.metaKey || event.ctrlKey) return; + const response = event.key === 'Enter' + ? (event.shiftKey ? 'always' as const : 'once' as const) + : event.key === 'Backspace' && !event.shiftKey + ? 'reject' as const + : null; + if (!response) return; + event.preventDefault(); + event.stopPropagation(); + void handleResponseRef.current(response); + }; + window.addEventListener('keydown', handleKeyDown, true); + return () => { + window.removeEventListener('keydown', handleKeyDown, true); + const index = activePermissionCardIds.lastIndexOf(permission.id); + if (index !== -1) activePermissionCardIds.splice(index, 1); + }; + }, [hasResponded, permission.id]); + if (hasResponded) { return null; } @@ -380,6 +411,7 @@ export const PermissionCard: React.FC = ({ > Allow Once + {formatShortcutForDisplay('alt+enter')} {permission.always.length > 0 ? ( @@ -436,6 +468,7 @@ export const PermissionCard: React.FC = ({ > Always Allow + {formatShortcutForDisplay('alt+shift+enter')} )} @@ -459,6 +492,7 @@ export const PermissionCard: React.FC = ({ > Deny + {formatShortcutForDisplay('alt+backspace')} {isResponding && ( diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index a577a3d2..0b1efbb9 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -1,11 +1,9 @@ import React from "react"; import { useSessionUIStore } from '@/sync/session-ui-store'; import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder"; -import { Icon } from "@/components/icon/Icon"; -import { useI18n } from "@/lib/i18n"; // The floating assistant-status chip that hovers above the composer while the -// agent works ("Claude is working…", abort notice). ONLY that. The composer's +// agent works ("Claude is working…"). ONLY that. The composer's // own bar — pending changes, todos dropdown — is ComposerStatusBar: they used // to share this component, and every restyle of this chip (glass, placement) // silently dragged the composer bar and its dropdown along with it. @@ -17,10 +15,8 @@ interface StatusRowProps { statusText?: string | null; isGenericStatus?: boolean; isWaitingForPermission?: boolean; - wasAborted?: boolean; abortActive?: boolean; retryInfo?: { attempt?: number; next?: number } | null; - showAbortStatus?: boolean; agentName?: string; modelName?: string | null; providerId?: string | null; @@ -31,19 +27,16 @@ export const StatusRow: React.FC = ({ statusText = null, isGenericStatus, isWaitingForPermission, - wasAborted, abortActive, retryInfo, - showAbortStatus, agentName, modelName, providerId, }) => { - const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive); - const hasContent = isWorking || Boolean(wasAborted) || Boolean(showAbortStatus); + const shouldRenderPlaceholder = !abortActive; + const hasContent = isWorking; if (!hasContent) { return null; @@ -63,14 +56,7 @@ export const StatusRow: React.FC = ({ a shrink-to-fit wrapper around it always collapsed to zero. */}
- {showAbortStatus ? ( -
- - -
- ) : shouldRenderPlaceholder ? ( + {shouldRenderPlaceholder ? ( { - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const abortRecord = useSessionUIStore( - React.useCallback((state) => { - if (!currentSessionId) { - return null; - } - return state.sessionAbortFlags?.get(currentSessionId) ?? null; - }, [currentSessionId]), - ); const { activeModel, working } = useAssistantStatus(); const currentAgentName = useConfigStore((state) => state.currentAgentName); const providers = useConfigStore((state) => state.providers); @@ -35,16 +25,13 @@ export const StatusRowContainer: React.FC = React.memo(() => { return getProviderModelDisplayName(provider, activeModel.modelId) || null; }, [activeModel, providers]); - const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged); - return ( /Android/i.test(navigator.userAgent); + export interface MobileViewportPinOptions { isMobile: boolean; /** Composer expanded to fullscreen on mobile. */ @@ -96,12 +105,14 @@ export function useMobileViewportPin(options: MobileViewportPinOptions): void { }; }, [editorRef, formRef, isFullscreen, isMobile]); - // Draft screen with the keyboard up: anchor the normal-height composer to - // the visible bottom. The chat screen does not need this — its own - // focused-field reveal works there. + // Keyboard up: anchor the normal-height composer to the visible bottom. + // Draft screen on every mobile browser; chat screen only on Android, + // where neither viewport resizing nor the focused-field reveal can be + // relied on (iOS chat keeps the browser's own reveal). React.useLayoutEffect(() => { if (!isMobile || isCapacitorApp()) return; - if (!isDraftScreen || isFullscreen || !isFocused) return; + if (isFullscreen || !isFocused) return; + if (!isDraftScreen && !isAndroidBrowser()) return; const vv = window.visualViewport; const form = formRef.current; if (!vv || !form) return; diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 886aad6e..5368d208 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Input } from '@/components/ui/input'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation'; import { Select, SelectContent, @@ -26,6 +27,7 @@ import { useI18n } from '@/lib/i18n'; import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; +import { useKeybind } from '@/hooks/useKeybind'; import type { Theme } from '@/types/theme'; import { normalizePath } from '../attachments/filePaths'; import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget'; @@ -106,14 +108,48 @@ export function DraftTargetSelectors(props: DraftTargetProps) { onDirectoryChange, theme, } = props; + const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null); + const projectTriggerRef = React.useRef(null); + const worktreeTriggerRef = React.useRef(null); + const handlePickerKeyDown = (event: React.KeyboardEvent) => { + if (openPicker === null || !shouldDismissDropdown(event)) return; + event.preventDefault(); + event.stopPropagation(); + setOpenPicker(null); + }; + + useKeybind('open_draft_project_picker', () => { + projectTriggerRef.current?.focus(); + setOpenPicker('project'); + }); + useKeybind('open_draft_worktree_picker', () => { + if (!showBranchSelector) return false; + worktreeTriggerRef.current?.focus(); + setOpenPicker('worktree'); + }); + + const handleProjectChange = (projectId: string) => { + onProjectChange(projectId); + setOpenPicker(null); + }; + + const handleDirectoryChange = (directory: string) => { + onDirectoryChange(directory); + setOpenPicker(null); + }; return (
setOpenPicker(open ? 'worktree' : null)} + onValueChange={handleDirectoryChange} + disableGlobalShortcuts > @@ -145,11 +186,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) { {selectedBranchLabel ?? t('chat.chatInput.branch')} - + {projectRootBranchOption ? ( {t('chat.chatInput.projectRoot')} - + {projectRootBranchOption.label} @@ -168,13 +209,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{worktreeBranchOptions.map((option) => ( - + {option.pending ? '⏳ ' : ''}{option.label} ))} {selectedDirectory && !selectedBranchIsKnown ? ( - + {selectedBranchLabel} ) : null} diff --git a/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx b/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx index 74251911..d740de34 100644 --- a/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx +++ b/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx @@ -5,7 +5,12 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useI18n } from '@/lib/i18n'; -import { cn, isMacOS } from '@/lib/utils'; +import { + formatShortcutForDisplay, + getEffectiveShortcutCombo, +} from '@/lib/shortcuts'; +import { cn } from '@/lib/utils'; +import { useUIStore } from '@/stores/useUIStore'; type FocusModeButtonProps = { footerIconButtonClass: string; @@ -17,6 +22,12 @@ type FocusModeButtonProps = { export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) { const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props; const { t } = useI18n(); + const expandInputShortcutOverride = useUIStore((state) => state.shortcutOverrides.expand_input); + const expandInputCombo = getEffectiveShortcutCombo( + 'expand_input', + expandInputShortcutOverride === undefined ? undefined : { expand_input: expandInputShortcutOverride }, + ); + const shortcut = expandInputCombo ? formatShortcutForDisplay(expandInputCombo) : null; return ( @@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
{t('chat.chatInput.focusMode.label')} - - {isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'} - + {shortcut ? {shortcut} : null}
diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 35a17a4d..c02d4fc3 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -1343,16 +1343,6 @@ const AssistantMessageBody = React.memo(({ return resolved ? { id: resolved.id, path: resolved.path } : null; }, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]); - const hasTools = toolParts.length > 0; - - const hasPendingTools = React.useMemo(() => { - return toolParts.some((toolPart) => { - const state = (toolPart as Record).state as Record | undefined ?? {}; - const status = state?.status; - return status === 'pending' || status === 'running' || status === 'started'; - }); - }, [toolParts]); - const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => { const state = (toolPart as Record).state as Record | undefined ?? {}; const status = state?.status; @@ -1381,42 +1371,6 @@ const AssistantMessageBody = React.memo(({ return isActiveTool(toolPart) || isToolFinalized(toolPart); }, [isActiveTool, isToolFinalized]); - const allToolsFinalized = React.useMemo(() => { - if (toolParts.length === 0) { - return true; - } - if (hasPendingTools) { - return false; - } - return toolParts.every((toolPart) => isToolFinalized(toolPart)); - }, [toolParts, hasPendingTools, isToolFinalized]); - - const reasoningParts = React.useMemo(() => { - return visibleParts.filter((part) => part.type === 'reasoning'); - }, [visibleParts]); - - const reasoningComplete = React.useMemo(() => { - if (reasoningParts.length === 0) { - return true; - } - return reasoningParts.every((part) => { - const time = (part as Record).time as { end?: number } | undefined; - return typeof time?.end === 'number'; - }); - }, [reasoningParts]); - - // Message is considered to have an "open step" if info.finish is not yet present - const hasOpenStep = typeof messageFinish !== 'string'; - - const shouldHoldForReasoning = - reasoningParts.length > 0 && - hasTools && - (hasPendingTools || hasOpenStep || !allToolsFinalized); - - const shouldHoldTools = awaitingMessageCompletion - || (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized)); - const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning; - const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion; const handleForkClick = React.useCallback( diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index bcdca931..f3a8f530 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -18,6 +18,7 @@ import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; +import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat'; import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects'; interface TextSelectionMenuProps { @@ -106,7 +107,7 @@ export const TextSelectionMenu: React.FC = ({ containerR const openRafRef = React.useRef(null); const mouseUpTimeoutRef = React.useRef(null); const isMenuVisibleRef = React.useRef(false); - const createSession = useSessionUIStore((state) => state.createSession); + const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft); @@ -156,6 +157,8 @@ export const TextSelectionMenu: React.FC = ({ containerR React.useEffect(() => { return () => { + activeAddToChatCleanupRef.current?.(); + activeAddToChatCleanupRef.current = null; if (openRafRef.current !== null) { window.cancelAnimationFrame(openRafRef.current); openRafRef.current = null; @@ -169,6 +172,8 @@ export const TextSelectionMenu: React.FC = ({ containerR const hideMenu = React.useCallback(() => { pendingSelectionRef.current = null; + activeAddToChatCleanupRef.current?.(); + activeAddToChatCleanupRef.current = null; setCommentRects(null); if (!isMenuVisibleRef.current) { @@ -209,12 +214,30 @@ export const TextSelectionMenu: React.FC = ({ containerR return Math.min(Math.max(anchorX, minX), maxX); }, []); + const addMarkdownToChat = React.useCallback((markdownText: string) => { + const markdownBlock = wrapMarkdownSelectionForChat(markdownText); + setPendingInputText(markdownBlock, 'append'); + + hideMenu(); + + window.getSelection()?.removeAllRanges(); + queueMicrotask(() => { + focusChatInput(); + }); + }, [hideMenu, setPendingInputText]); + const showMenu = React.useCallback(() => { if (!pendingSelectionRef.current) return; const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current; const shouldAnimateIn = !position.show; + activeAddToChatCleanupRef.current?.(); + activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({ + addToChat: () => addMarkdownToChat(markdownText), + dismiss: hideMenu, + }); + // Position menu above the selection const menuX = isMobile ? rect.left + rect.width / 2 @@ -241,7 +264,7 @@ export const TextSelectionMenu: React.FC = ({ containerR openRafRef.current = null; }); } - }, [getDesktopClampedX, isMobile, position.show]); + }, [addMarkdownToChat, getDesktopClampedX, hideMenu, isMobile, position.show]); React.useLayoutEffect(() => { if (!position.show || isMobile || !menuRef.current) { @@ -428,18 +451,8 @@ export const TextSelectionMenu: React.FC = ({ containerR const handleAddToChat = React.useCallback(() => { if (!selectedTextMarkdown) return; - - const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown); - setPendingInputText(markdownBlock, 'append'); - - hideMenu(); - - // Clear selection - window.getSelection()?.removeAllRanges(); - queueMicrotask(() => { - focusChatInput(); - }); - }, [selectedTextMarkdown, setPendingInputText, hideMenu]); + addMarkdownToChat(selectedTextMarkdown); + }, [addMarkdownToChat, selectedTextMarkdown]); const handleOpenComment = React.useCallback(() => { if (!selectedTextMarkdown) return; @@ -473,18 +486,6 @@ export const TextSelectionMenu: React.FC = ({ containerR }); }, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]); - const handleCreateNewSession = React.useCallback(async () => { - if (!selectedText) return; - - const session = await createSession(undefined, null, null); - if (session) { - setPendingInputText(selectedText, 'replace'); - } - - hideMenu(); - window.getSelection()?.removeAllRanges(); - }, [selectedText, createSession, setPendingInputText, hideMenu]); - const currentSession = React.useMemo(() => { if (!currentSessionId) { return null; @@ -686,22 +687,6 @@ export const TextSelectionMenu: React.FC = ({ containerR {t('chat.textSelection.actions.addToInput')} - - {!isVSCodeRuntime() ? ( -
- - - -
- - {!isVSCodeRuntime() ? ( <> diff --git a/packages/ui/src/components/comments/InlineCommentInput.tsx b/packages/ui/src/components/comments/InlineCommentInput.tsx index 8af08eaf..c9abd28d 100644 --- a/packages/ui/src/components/comments/InlineCommentInput.tsx +++ b/packages/ui/src/components/comments/InlineCommentInput.tsx @@ -3,6 +3,7 @@ import { cn } from '@/lib/utils'; import { Icon } from '@/components/icon/Icon'; import { useDeviceInfo } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; export interface InlineCommentInputProps { initialText?: string; @@ -37,6 +38,7 @@ export function InlineCommentInput({ const { isMobile } = useDeviceInfo(); const [text, setText] = React.useState(initialText); const textareaRef = useRef(null); + const saveShortcut = formatShortcutForDisplay('mod+enter'); void isEditing; const handleTextChange = (value: string) => { @@ -166,7 +168,9 @@ export function InlineCommentInput({ value={text} onChange={(e) => handleTextChange(e.target.value)} onKeyDown={handleKeyDown} - placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')} + placeholder={isMobile + ? t('inlineComment.input.placeholderShort') + : t('inlineComment.input.placeholder', { shortcut: saveShortcut })} className={cn( 'min-w-0 flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60', isMobile ? 'py-1.5 text-base leading-6' : 'py-1.5' diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 8c24e134..aa52be5d 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -452,10 +452,13 @@ export const ContextPanel: React.FC = () => { // Lets an agent's browser.open create the tab it needs when none is open yet. // Registered from the panel because opening a tab is panel state, not - // something the browser view itself can do before it exists. + // something the browser view itself can do before it exists. Background on + // purpose: an agent working a page must not pop the panel open (or steal + // the active surface) under the user — the tab mounts invisibly, and the + // rail is where the user opens it when curious. React.useEffect(() => { if (!effectiveDirectory) return; - return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url)); + return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url, { reveal: false })); }, [effectiveDirectory, openContextBrowser]); const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs); const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath); diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index 6b3c147a..23308012 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -36,6 +36,7 @@ import { cn } from '@/lib/utils'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitStatus } from '@/stores/useGitStore'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; +import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog'; const RAIL_TOOLTIP_DELAY_MS = 150; // Hold the surface-switch modifier for this long before revealing the order @@ -161,6 +162,7 @@ export const ContextPanelRail: React.FC = () => { const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined)); const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible); const contextRailOrder = useUIStore((state) => state.contextRailOrder); + const contextRailHiddenSurfaces = useUIStore((state) => state.contextRailHiddenSurfaces); const setContextRailOrder = useUIStore((state) => state.setContextRailOrder); const openContextSurface = useUIStore((state) => state.openContextSurface); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); @@ -256,12 +258,15 @@ export const ContextPanelRail: React.FC = () => { const surfaces = React.useMemo(() => { return getVisibleContextRailSurfaces({ railOrder: contextRailOrder, + hiddenSurfaces: contextRailHiddenSurfaces, planModeEnabled, isVSCode: isVSCodeRuntime(), screenWidth, tabs, }); - }, [contextRailOrder, planModeEnabled, screenWidth, tabs]); + }, [contextRailHiddenSurfaces, contextRailOrder, planModeEnabled, screenWidth, tabs]); + + const [isSurfacesDialogOpen, setIsSurfacesDialogOpen] = React.useState(false); const handleDragEnd = React.useCallback((event: DragEndEvent) => { const { active, over } = event; @@ -331,6 +336,24 @@ export const ContextPanelRail: React.FC = () => { })} + {/* Outside the sortable list on purpose: this button takes no digit, + cannot be dragged, and configures the rail rather than living on it. */} + + + + + + {t('contextRail.configure.open')} + + + ); }; diff --git a/packages/ui/src/components/layout/ContextRailSurfacesDialog.tsx b/packages/ui/src/components/layout/ContextRailSurfacesDialog.tsx new file mode 100644 index 00000000..211b465a --- /dev/null +++ b/packages/ui/src/components/layout/ContextRailSurfacesDialog.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useI18n } from '@/lib/i18n'; +import { useUIStore } from '@/stores/useUIStore'; +import { SettingsCheckboxRow } from '@/components/sections/shared/SettingsSection'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { sortContextSurfaces } from '@/lib/surfaces/registry'; + +/** + * Which surfaces the context rail shows. Everything is on by default and the + * choice is stored as the *hidden* set, so a surface added in a later release + * appears for everyone rather than staying invisible to whoever had saved + * settings before it existed. Hidden surfaces also leave the digit shortcuts + * (the rail and the shortcut share one visibility filter). + */ +export const ContextRailSurfacesDialog: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; +}> = ({ open, onOpenChange }) => { + const { t } = useI18n(); + const contextRailOrder = useUIStore((state) => state.contextRailOrder); + const hidden = useUIStore((state) => state.contextRailHiddenSurfaces); + const setSurfaceVisible = useUIStore((state) => state.setContextRailSurfaceVisible); + const setHiddenSurfaces = useUIStore((state) => state.setContextRailHiddenSurfaces); + + // The full registry in the user's rail order — including surfaces a runtime + // filter currently drops, so a choice made on desktop is editable anywhere. + const surfaces = React.useMemo(() => sortContextSurfaces(contextRailOrder), [contextRailOrder]); + + const allVisible = hidden.length === 0; + const noneVisible = surfaces.every((surface) => hidden.includes(surface.id)); + + return ( + + + + {t('contextRail.configure.dialogTitle')} + {t('contextRail.configure.dialogDescription')} + + +
+ {surfaces.map((surface) => ( + setSurfaceVisible(surface.id, checked)} + label={t(surface.labelKey)} + ariaLabel={t(surface.labelKey)} + /> + ))} +
+ + {!allVisible ? ( +
+ {noneVisible ? ( + {t('contextRail.configure.noneWarning')} + ) : } + +
+ ) : null} +
+
+ ); +}; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index fd69095a..2d48185d 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -38,7 +38,8 @@ import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControl import { UpdateDialog } from '@/components/ui/UpdateDialog'; import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device'; import { cn } from '@/lib/utils'; -import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo, type ShortcutActionId } from '@/lib/shortcuts'; +import { useKeybinds } from '@/hooks/useKeybind'; import { } from '@/lib/quota/model-families'; @@ -256,7 +257,7 @@ type DesktopServicesMenuProps = { isDesktopServicesOpen: boolean; setIsDesktopServicesOpen: React.Dispatch>; refreshCurrentInstanceLabel: () => Promise; - shortcutLabel: (actionId: string) => string; + shortcutLabel: (actionId: ShortcutActionId) => string; remoteUpdateInfo: UpdateInfo | null; remoteUpdateChecking: boolean; remoteUpdateError: string | null; @@ -433,7 +434,6 @@ export const Header: React.FC = () => { const { t } = useI18n(); const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); const openContextOverview = useUIStore((state) => state.openContextOverview); - const openContextPlan = useUIStore((state) => state.openContextPlan); const closeContextPanel = useUIStore((state) => state.closeContextPanel); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const sessionTabsEnabled = useUIStore((state) => state.sessionTabsEnabled); @@ -485,8 +485,6 @@ export const Header: React.FC = () => { const pathSegments = activeProject.path.split(/[\\/]/).filter(Boolean); return pathSegments[pathSegments.length - 1] ?? null; }, [activeProject]); - const quotaResults = useQuotaStore((state) => state.results); - const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const { isMobile } = useDeviceInfo(); @@ -1264,21 +1262,6 @@ export const Header: React.FC = () => { const isContextPanelActive = activeContextMode === 'context'; - const handleOpenContextPlan = React.useCallback(() => { - const directory = normalize(openDirectory || ''); - if (!directory) { - return; - } - - const panelState = useUIStore.getState().contextPanelByDirectory[directory]; - if (getActiveContextMode(panelState) === 'plan') { - closeContextPanel(directory); - return; - } - - openContextPlan(directory); - }, [closeContextPanel, openContextPlan, openDirectory]); - const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS; // Left padding the header needs to clear the OS window controls (macOS @@ -1445,67 +1428,26 @@ export const Header: React.FC = () => { } }, [isDesktopApp]); - const shortcutLabel = React.useCallback((actionId: string) => { + const shortcutLabel = React.useCallback((actionId: ShortcutActionId) => { return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); }, [shortcutOverrides]); - // Desktop keeps instances only: quota and MCP now live in the work-status - // panel, which reports them per session rather than per window. The mobile - // menu below is untouched — it has no panel to defer to. - const servicesTabs = React.useMemo(() => { - const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: React.ReactNode }> = []; - if (isDesktopApp) { - base.push({ value: 'instance', label: t('layout.services.instance'), icon: }); - } - return base; - }, [isDesktopApp, t]); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides); - if (eventMatchesShortcut(e, toggleServicesCombo)) { - e.preventDefault(); - - if (isDesktopServicesOpen) { - setIsDesktopServicesOpen(false); - } else { - setIsDesktopServicesOpen(true); - void refreshCurrentInstanceLabel(); - } + useKeybinds({ + rename_current_session: () => { + if (!currentSessionId || isMobile) return false; + beginHeaderSessionRename(); + }, + toggle_services_menu: () => { + if (isDesktopServicesOpen) { + setIsDesktopServicesOpen(false); return; } - - // The desktop menu holds one destination now, so this shortcut opens it - // rather than cycling. The binding is kept: it is user-configurable and - // silently dropping it would break existing setups. - const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides); - if (eventMatchesShortcut(e, cycleServicesCombo)) { - e.preventDefault(); - if (servicesTabs.length === 0) return; - setIsDesktopServicesOpen(true); - void refreshCurrentInstanceLabel(); - return; - } - - const toggleContextPlanCombo = getEffectiveShortcutCombo('toggle_context_plan', shortcutOverrides); - if (eventMatchesShortcut(e, toggleContextPlanCombo)) { - e.preventDefault(); - handleOpenContextPlan(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [ - shortcutOverrides, - isDesktopServicesOpen, - servicesTabs, - quotaResults.length, - fetchAllQuotas, - refreshCurrentInstanceLabel, - handleOpenContextPlan, - ]); + setIsDesktopServicesOpen(true); + void refreshCurrentInstanceLabel(); + }, + }); const desktopSidebarActions = ( <> diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx index 627964e7..ca4be95c 100644 --- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx @@ -42,6 +42,7 @@ export const DefaultsSettings: React.FC = () => { const setModel = useConfigStore((state) => state.setModel); const setAgent = useConfigStore((state) => state.setAgent); const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant); + const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride); const setSettingsDefaultModel = useConfigStore((state) => state.setSettingsDefaultModel); const setSettingsDefaultVariant = useConfigStore((state) => state.setSettingsDefaultVariant); const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent); @@ -210,7 +211,7 @@ export const DefaultsSettings: React.FC = () => { setDefaultVariant(newValue); setSettingsDefaultVariant(newValue); if (!chatHasOwnModel) { - setCurrentVariant(newValue); + setCurrentVariantOverride(newValue ?? null, newValue); } try { @@ -219,7 +220,7 @@ export const DefaultsSettings: React.FC = () => { console.warn('Failed to save default variant:', error); } }, - [chatHasOwnModel, setCurrentVariant, setSettingsDefaultVariant] + [chatHasOwnModel, setCurrentVariantOverride, setSettingsDefaultVariant] ); const handleAgentChange = React.useCallback( diff --git a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx index 1956615c..4fd8f752 100644 --- a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx @@ -1,12 +1,7 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - SettingsSection, - SettingsFieldRow, -} from '@/components/sections/shared/SettingsSection'; +import { SettingsFieldRow, SettingsSection } from '@/components/sections/shared/SettingsSection'; import { useUIStore } from '@/stores/useUIStore'; -import { cn } from '@/lib/utils'; import { updateDesktopSettings } from '@/lib/persistence'; import { isVSCodeRuntime } from '@/lib/desktop'; import { @@ -14,314 +9,128 @@ import { getCustomizableShortcutActions, getEffectiveShortcutCombo, getEffectiveShortcutPrefix, - isRiskyBrowserShortcut, - keyToShortcutToken, - normalizeCombo, UNASSIGNED_SHORTCUT, + type ShortcutActionId, + type ShortcutCategory, type ShortcutCombo, + type CustomizableShortcutAction, } from '@/lib/shortcuts'; import { useI18n } from '@/lib/i18n'; +import { ShortcutRecordingDialog } from './ShortcutRecordingDialog'; -const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']); - -const keyboardEventToCombo = (event: React.KeyboardEvent): ShortcutCombo | null => { - if (MODIFIER_KEYS.has(event.key.toLowerCase())) { - return null; - } - - const parts: string[] = []; - - if (event.metaKey || event.ctrlKey) { - parts.push('mod'); - } - if (event.shiftKey) { - parts.push('shift'); - } - if (event.altKey) { - parts.push('alt'); - } - - const keyToken = keyToShortcutToken(event.key); - if (!keyToken) { - return null; - } - - parts.push(keyToken); - return normalizeCombo(parts.join('+')); -}; - -// Prefix capture for chord-style shortcuts (e.g. "switch context panel -// surface"): a bare modifier press is accepted so the prefix can be just the -// primary modifier (default) or a modifier + key chord like `mod+p`. -const keyboardEventToPrefixCombo = (event: React.KeyboardEvent): ShortcutCombo | null => { - const parts: string[] = []; - - if (event.metaKey || event.ctrlKey) { - parts.push('mod'); - } - if (event.shiftKey) { - parts.push('shift'); - } - if (event.altKey) { - parts.push('alt'); - } - - if (MODIFIER_KEYS.has(event.key.toLowerCase())) { - return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; - } - - const keyToken = keyToShortcutToken(event.key); - if (!keyToken) { - return null; - } - - parts.push(keyToken); - return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; -}; +const CATEGORIES: ShortcutCategory[] = ['session', 'models', 'panels', 'navigation', 'application']; export const KeyboardShortcutsSettings: React.FC = () => { const { t } = useI18n(); - const tUnsafe = React.useCallback((key: string) => t(key as Parameters[0]), [t]); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const setShortcutOverride = useUIStore((state) => state.setShortcutOverride); const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride); const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides); + const [editingAction, setEditingAction] = React.useState(null); const actions = React.useMemo(() => { const all = getCustomizableShortcutActions(); - if (!isVSCodeRuntime()) { - return all; - } - return all.filter((action) => action.id !== 'toggle_prompt_navigator'); + return isVSCodeRuntime() ? all.filter((action) => action.id !== 'toggle_prompt_navigator') : all; }, []); - const actionLabel = React.useCallback((id: string, fallbackLabel: string): string => { - const key = `settings.openchamber.keyboardShortcuts.action.${id}.label`; - const translated = tUnsafe(key); - return translated === key ? fallbackLabel : translated; - }, [tUnsafe]); - - const [capturingActionId, setCapturingActionId] = React.useState(null); - const [draftByAction, setDraftByAction] = React.useState>({}); - const [errorText, setErrorText] = React.useState(''); - const [warningText, setWarningText] = React.useState(''); - const [pendingOverwrite, setPendingOverwrite] = React.useState<{ - actionId: string; - combo: ShortcutCombo; - conflictActionId: string; - } | null>(null); - - const persistShortcutOverrides = React.useCallback((nextOverrides: Record) => { + const persist = (nextOverrides: Record) => { void updateDesktopSettings({ shortcutOverrides: nextOverrides }); - }, []); - - const findConflict = React.useCallback((actionId: string, combo: ShortcutCombo): string | null => { - const normalized = normalizeCombo(combo); - for (const action of actions) { - if (action.id === actionId) { - continue; - } - const existing = getEffectiveShortcutCombo(action.id, shortcutOverrides); - if (normalizeCombo(existing) === normalized) { - return action.id; - } - } - return null; - }, [actions, shortcutOverrides]); - - const saveCombo = React.useCallback((actionId: string, combo: ShortcutCombo) => { - const normalized = normalizeCombo(combo); - const conflictActionId = findConflict(actionId, normalized); - if (conflictActionId) { - setPendingOverwrite({ actionId, combo: normalized, conflictActionId }); - setErrorText(''); - return; - } - - const nextOverrides = { ...shortcutOverrides, [actionId]: normalized }; - setShortcutOverride(actionId, normalized); - persistShortcutOverrides(nextOverrides); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(isRiskyBrowserShortcut(normalized) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : ''); - setDraftByAction((current) => { - const rest = { ...current }; - delete rest[actionId]; - return rest; - }); - }, [findConflict, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]); - - const confirmOverwrite = React.useCallback(() => { - if (!pendingOverwrite) { - return; - } - - const nextOverrides = { - ...shortcutOverrides, - [pendingOverwrite.conflictActionId]: UNASSIGNED_SHORTCUT, - [pendingOverwrite.actionId]: pendingOverwrite.combo, - }; - setShortcutOverride(pendingOverwrite.conflictActionId, UNASSIGNED_SHORTCUT); - setShortcutOverride(pendingOverwrite.actionId, pendingOverwrite.combo); - persistShortcutOverrides(nextOverrides); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : ''); - setDraftByAction((current) => { - const rest = { ...current }; - delete rest[pendingOverwrite.actionId]; - return rest; - }); - }, [pendingOverwrite, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]); - - const resetOne = React.useCallback((actionId: string) => { + }; + const save = ( + actionId: ShortcutActionId, + combo: ShortcutCombo, + replaceActionId?: ShortcutActionId, + ) => { + const nextOverrides = { ...shortcutOverrides, [actionId]: combo }; + if (replaceActionId) nextOverrides[replaceActionId] = UNASSIGNED_SHORTCUT; + setShortcutOverride(actionId, combo); + if (replaceActionId) setShortcutOverride(replaceActionId, UNASSIGNED_SHORTCUT); + persist(nextOverrides); + }; + const resetOne = (actionId: ShortcutActionId) => { const nextOverrides = { ...shortcutOverrides }; delete nextOverrides[actionId]; clearShortcutOverride(actionId); - persistShortcutOverrides(nextOverrides); - setDraftByAction((current) => { - const rest = { ...current }; - delete rest[actionId]; - return rest; - }); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(''); - }, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]); + persist(nextOverrides); + }; + const shortcutDisplay = (action: CustomizableShortcutAction): string => { + const isPrefixStyle = 'prefixStyle' in action && action.prefixStyle; + const combo = isPrefixStyle + ? getEffectiveShortcutPrefix(action.id, shortcutOverrides) + : getEffectiveShortcutCombo(action.id, shortcutOverrides); + const formatted = formatShortcutForDisplay( + combo, + t('settings.openchamber.keyboardShortcuts.unassigned'), + ); + if (!isPrefixStyle || !combo || combo === UNASSIGNED_SHORTCUT) return formatted; + const suffix = action.id === 'switch_session_tab' + ? t('settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix') + : t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix'); + return `${formatted}${suffix}`; + }; return ( - { - resetAllShortcutOverrides(); - persistShortcutOverrides({}); - setDraftByAction({}); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(''); - }} - > - {t('settings.openchamber.keyboardShortcuts.actions.resetAll')} - - )} - > - {(errorText || warningText || pendingOverwrite) && ( -
- {pendingOverwrite && ( -
- - {t('settings.openchamber.keyboardShortcuts.overwritePrompt')} - -
- - -
+ <> + {CATEGORIES.map((category, categoryIndex) => { + const categoryActions = actions.filter((action) => action.category === category); + if (categoryActions.length === 0) return null; + return ( + { + resetAllShortcutOverrides(); + persist({}); + }}> + {t('settings.openchamber.keyboardShortcuts.actions.resetAll')} + + ) : undefined} + > +
+ {categoryActions.map((action) => ( + + + {shortcutDisplay(action)} + + + {action.id in shortcutOverrides ? ( + + ) : null} + + ))}
- )} - {errorText && ( -
- {errorText} -
- )} - {warningText && ( -
- {warningText} -
- )} -
- )} - -
- {actions.map((action, index) => { - const isSurfaceSwitch = action.id === 'switch_context_surface'; - const effective = isSurfaceSwitch - ? getEffectiveShortcutPrefix(action.id, shortcutOverrides) - : getEffectiveShortcutCombo(action.id, shortcutOverrides); - const draft = draftByAction[action.id]; - const displayCombo = draft ?? effective; - const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective); - const isUnassignedDisplay = displayCombo === '' || normalizeCombo(displayCombo) === UNASSIGNED_SHORTCUT; - const displayValue = capturingActionId === action.id - ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') - : isSurfaceSwitch && !isUnassignedDisplay - ? `${formatShortcutForDisplay(displayCombo)}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}` - : formatShortcutForDisplay(displayCombo); - - return ( -
0 && "border-t border-border/40")}> - - { - setCapturingActionId(action.id); - setErrorText(''); - }} - onBlur={() => { - if (capturingActionId === action.id) { - setCapturingActionId(null); - } - }} - onKeyDown={(event) => { - event.preventDefault(); - event.stopPropagation(); - - if (event.key === 'Escape') { - setCapturingActionId(null); - return; - } - - const combo = isSurfaceSwitch ? keyboardEventToPrefixCombo(event) : keyboardEventToCombo(event); - if (!combo) { - return; - } - - setDraftByAction((current) => ({ - ...current, - [action.id]: combo, - })); - setCapturingActionId(null); - setPendingOverwrite(null); - setErrorText(''); - }} - className="h-7 w-40 min-w-0 typography-ui-label text-center" - /> - - - -
- ); - })} -
- + + ); + })} + { + if (!open) setEditingAction(null); + }} + /> + ); }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index f7d203fc..20f973ed 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -62,6 +62,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import type { TerminalShellOption } from '@/lib/api/types'; import { isTerminalShell } from '@/lib/terminalShell'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; interface Option { id: T; @@ -1480,7 +1481,10 @@ export const OpenChamberVisualSettings: React.FC label={t('settings.openchamber.visual.field.terminalQuickKeys')} ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')} settingsItem="appearance.terminal-quick-keys" - info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip')} + info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip', { + control: formatShortcutForDisplay('ctrl'), + alt: formatShortcutForDisplay('alt'), + })} /> )}
diff --git a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts new file mode 100644 index 00000000..b0cce183 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test'; +import { settleShortcutRecordingState, updateShortcutRecordingState } from './ShortcutRecordingDialog'; + +const emptyState = { chords: [], livePreview: null, settled: false }; + +function keyEvent(key: string, modifiers: Partial> = {}) { + const code = /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : /^[0-9]$/.test(key) ? `Digit${key}` : key; + return { key, code, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers }; +} + +describe('ShortcutRecordingDialog recording state', () => { + test('previews modifiers and clears the preview when they are released', () => { + const pressed = updateShortcutRecordingState(emptyState, keyEvent('Control', { ctrlKey: true, shiftKey: true }), 'keydown'); + expect(pressed.livePreview).toBe('mod+shift'); + expect(updateShortcutRecordingState(pressed, keyEvent('Control'), 'keyup').livePreview).toBeNull(); + }); + + test('waits after the first chord and settles when a second chord is recorded', () => { + const first = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown'); + const second = updateShortcutRecordingState(first, keyEvent('p'), 'keydown'); + const third = updateShortcutRecordingState(second, keyEvent('x'), 'keydown'); + expect(first.chords).toEqual(['mod+s']); + expect(first.settled).toBe(false); + expect(second.chords).toEqual(['mod+s', 'p']); + expect(second.settled).toBe(true); + expect(third.chords).toEqual(['x']); + expect(third.settled).toBe(false); + }); + + test('settles a single chord for timeout and Confirm validation', () => { + const waiting = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown'); + expect(settleShortcutRecordingState(waiting)).toEqual({ chords: ['mod+s'], livePreview: null, settled: true }); + }); + + test('records at most three simultaneous keys', () => { + const previous = { chords: ['mod+k'], livePreview: null, settled: false }; + const threeKeys = updateShortcutRecordingState( + previous, + keyEvent('s', { ctrlKey: true, shiftKey: true }), + 'keydown', + ); + const fourKeys = updateShortcutRecordingState( + previous, + keyEvent('s', { ctrlKey: true, metaKey: true, shiftKey: true }), + 'keydown', + ); + + expect(threeKeys.chords).toEqual(['mod+k', 'mod+shift+s']); + expect(fourKeys.chords).toEqual(['mod+k']); + }); + + test('ignores repeat and IME events', () => { + expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), repeat: true }, 'keydown')).toEqual(emptyState); + expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), isComposing: true }, 'keydown')).toEqual(emptyState); + }); + + test('records Enter and Escape while Backspace removes the final chord', () => { + const state = { chords: ['mod+k', 'mod+p'], livePreview: null, settled: true }; + expect(updateShortcutRecordingState(emptyState, keyEvent('Enter'), 'keydown').chords).toEqual(['enter']); + expect(updateShortcutRecordingState(emptyState, keyEvent('Escape'), 'keydown').chords).toEqual(['escape']); + expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').chords).toEqual(['mod+k']); + expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').settled).toBe(false); + expect(updateShortcutRecordingState({ chords: ['mod+k'], livePreview: null, settled: false }, keyEvent('Backspace'), 'keydown')).toEqual(emptyState); + }); +}); diff --git a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx new file mode 100644 index 00000000..a75d2333 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx @@ -0,0 +1,315 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + formatShortcutForDisplay, + getShortcutBindingConflicts, + isRiskyBrowserShortcut, + keyToShortcutToken, + resolveShortcutEventKey, + normalizeCombo, + type ShortcutActionId, + type ShortcutBindingConflict, + type ShortcutCombo, + type CustomizableShortcutAction, +} from '@/lib/shortcuts'; +import { useI18n } from '@/lib/i18n'; + +const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']); +const MAX_SHORTCUT_KEY_COUNT = 3; +const SECOND_CHORD_TIMEOUT_MS = 3000; + +interface RecordingKeyboardEvent { + altKey: boolean; + code: string; + ctrlKey: boolean; + isComposing: boolean; + key: string; + metaKey: boolean; + repeat: boolean; + shiftKey: boolean; +} + +interface ShortcutRecordingState { + chords: ShortcutCombo[]; + livePreview: ShortcutCombo | null; + settled: boolean; +} + +interface ShortcutRecordingDialogProps { + action: CustomizableShortcutAction | null; + overrides: Record; + onSave: ( + actionId: ShortcutActionId, + combo: ShortcutCombo, + replaceActionId?: ShortcutActionId, + ) => void; + onOpenChange: (open: boolean) => void; +} + +function getPhysicalKeyCount( + event: Pick, + includeEventKey = false, +): number { + const keys = new Set(); + if (event.altKey) keys.add('alt'); + if (event.ctrlKey) keys.add('control'); + if (event.metaKey) keys.add('meta'); + if (event.shiftKey) keys.add('shift'); + if (includeEventKey) keys.add(event.key.toLowerCase()); + return keys.size; +} + +function isCustomizableConflict( + conflict: ShortcutBindingConflict, +): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } { + return conflict.action.customizable; +} + +function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null { + if (getPhysicalKeyCount(event) > MAX_SHORTCUT_KEY_COUNT) return null; + const parts: string[] = []; + if (event.metaKey || event.ctrlKey) parts.push('mod'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; +} + +function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null { + if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null; + if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null; + + const key = keyToShortcutToken(resolveShortcutEventKey(event)); + if (!key) return null; + + const parts: string[] = []; + if (event.metaKey || event.ctrlKey) parts.push('mod'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + parts.push(key); + return normalizeCombo(parts.join('+')); +} + +function modifierKeyUpToCombo(event: React.KeyboardEvent): ShortcutCombo | null { + const key = event.key.toLowerCase(); + if (!MODIFIER_KEYS.has(key)) return null; + if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null; + + const parts: string[] = []; + if (event.metaKey || event.ctrlKey || key === 'meta' || key === 'control') parts.push('mod'); + if (event.shiftKey || key === 'shift') parts.push('shift'); + if (event.altKey || key === 'alt') parts.push('alt'); + return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; +} + +// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition +export function settleShortcutRecordingState(state: ShortcutRecordingState): ShortcutRecordingState { + return state.chords.length > 0 ? { ...state, livePreview: null, settled: true } : state; +} + +// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition +export function updateShortcutRecordingState( + state: ShortcutRecordingState, + event: RecordingKeyboardEvent, + phase: 'keydown' | 'keyup', +): ShortcutRecordingState { + if (event.repeat || event.isComposing) return state; + if (phase === 'keyup') { + return { ...state, livePreview: getModifierPreview(event) }; + } + + if (event.key === 'Backspace') { + return { chords: state.chords.slice(0, -1), livePreview: null, settled: false }; + } + + const chord = keyboardEventToCombo(event); + if (chord) { + if (state.settled) { + return { chords: [chord], livePreview: null, settled: false }; + } + const chords = state.chords.length < 2 ? [...state.chords, chord] : state.chords; + return { + chords, + livePreview: null, + settled: chords.length === 2, + }; + } + + return { ...state, livePreview: getModifierPreview(event) }; +} + +export const ShortcutRecordingDialog: React.FC = ({ + action, + overrides, + onSave, + onOpenChange, +}) => { + const { t } = useI18n(); + const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey); + const conflictActionLabel = (conflict: ShortcutBindingConflict) => ( + conflict.action.customizable + ? actionLabel(conflict.action) + : formatShortcutForDisplay(conflict.action.defaultBinding) + ); + const [recording, setRecording] = React.useState({ chords: [], livePreview: null, settled: false }); + const recordingRef = React.useRef(null); + + React.useEffect(() => { + if (!action) return; + setRecording({ chords: [], livePreview: null, settled: false }); + recordingRef.current?.focus(); + }, [action]); + + const waitingForSecondChord = recording.chords.length === 1 && !recording.settled; + + React.useEffect(() => { + if (!waitingForSecondChord) return; + const timeout = window.setTimeout( + () => setRecording(settleShortcutRecordingState), + SECOND_CHORD_TIMEOUT_MS, + ); + return () => window.clearTimeout(timeout); + }, [waitingForSecondChord]); + + const combo = normalizeCombo(recording.chords.join(' ')); + const conflicts = React.useMemo( + () => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [], + [action, combo, overrides], + ); + const protectedConflict = conflicts.find((conflict) => ( + !conflict.action.customizable && conflict.kind !== 'contextual-prefix' + )); + const customizableConflicts = conflicts.filter(isCustomizableConflict); + const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix'); + const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact'); + const contextualPrefixConflict = conflicts.find((conflict) => conflict.kind === 'contextual-prefix'); + + const close = () => onOpenChange(false); + const confirm = () => { + if (!recording.settled) setRecording(settleShortcutRecordingState); + if (!action || !combo || protectedConflict || prefixConflict) return; + onSave(action.id, combo, exactConflict?.action.id); + close(); + }; + const handleRecordingEvent = (event: React.KeyboardEvent, phase: 'keydown' | 'keyup') => { + event.preventDefault(); + event.stopPropagation(); + + const isPrefixStyleAction = Boolean(action && 'prefixStyle' in action && action.prefixStyle); + if (phase === 'keyup' && isPrefixStyleAction && recording.chords.length === 0) { + const modifierCombo = modifierKeyUpToCombo(event); + if (modifierCombo) { + setRecording({ chords: [modifierCombo], livePreview: null, settled: true }); + return; + } + } + const nextRecording = updateShortcutRecordingState(recording, { + altKey: event.altKey, + code: event.nativeEvent.code, + ctrlKey: event.ctrlKey, + isComposing: event.nativeEvent.isComposing, + key: event.key, + metaKey: event.metaKey, + repeat: event.repeat, + shiftKey: event.shiftKey, + }, phase); + setRecording(isPrefixStyleAction && nextRecording.chords.length > 1 + ? recording + : nextRecording); + }; + + return ( + { + if (!open) { + eventDetails.cancel(); + } + }} + > + + + + {action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''} + + {t('settings.openchamber.keyboardShortcuts.dialog.instructions')} + + +
handleRecordingEvent(event, 'keydown')} + onKeyUp={(event) => handleRecordingEvent(event, 'keyup')} + onBlur={() => setRecording((current) => ({ ...current, livePreview: null }))} + > +
+ {recording.chords.map((chord, index) => ( + + {formatShortcutForDisplay(chord)} + + ))} + {recording.livePreview ? ( + + {formatShortcutForDisplay(recording.livePreview)} + + ) : null} + {recording.chords.length === 0 && !recording.livePreview ? ( + + {t('settings.openchamber.keyboardShortcuts.dialog.recording')} + + ) : null} +
+
+ + {recording.settled && protectedConflict ? ( +

+ {t('settings.openchamber.keyboardShortcuts.error.internalConflict')} +

+ ) : recording.settled && prefixConflict ? ( +

+ {t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })} +

+ ) : null} + {recording.settled && exactConflict && !protectedConflict && !prefixConflict ? ( +

+ {t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })} +

+ ) : null} + {recording.settled && contextualPrefixConflict && !protectedConflict && !prefixConflict ? ( +

+ {t('settings.openchamber.keyboardShortcuts.warning.contextualPrefix', { + action: conflictActionLabel(contextualPrefixConflict), + })} +

+ ) : null} + {recording.settled && combo && isRiskyBrowserShortcut(combo) ? ( +

+ {t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')} +

+ ) : null} + + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index e9e75314..1700286e 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -24,6 +24,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; import { opencodeClient } from '@/lib/opencode/client'; import { useI18n } from '@/lib/i18n'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; import { isFilesystemError, type FilesystemErrorReason, @@ -360,9 +361,7 @@ export const DirectoryExplorerDialog: React.FC = ( const hasHighlightedBrowseItem = Boolean( highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled)) ); - const submitModifierLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) - ? '⌘' - : 'Ctrl'; + const submitModifierLabel = formatShortcutForDisplay('mod'); const submitActionLabel = isAlreadyAdded ? t('directoryExplorerDialog.actions.alreadyAdded') : isCloneMode diff --git a/packages/ui/src/components/session/SessionSwitcherDropdown.tsx b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx index 91a874cc..017e6b56 100644 --- a/packages/ui/src/components/session/SessionSwitcherDropdown.tsx +++ b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx @@ -11,7 +11,11 @@ import { Icon } from '@/components/icon/Icon'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionStatus } from '@/sync/sync-context'; import { useSessionUnseenCount } from '@/sync/notification-store'; -import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/shell/useSwitcherItems'; +import { + findSwitcherItemAncestorIds, + useSwitcherItems, + type SwitcherItem, +} from '@/components/session/sidebar/shell/useSwitcherItems'; import { useUIStore } from '@/stores/useUIStore'; import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { formatSessionCompactDateLabel } from './sidebar/utils'; @@ -22,6 +26,7 @@ import { cn } from '@/lib/utils'; type SecondaryMeta = SwitcherItem['secondaryMeta']; type SwitcherVariant = 'default' | 'compact'; +const NEW_SESSION_SWITCHER_TARGET = 'new-session'; type SessionSwitcherDropdownProps = { children: React.ReactNode; @@ -40,7 +45,7 @@ export function SessionSwitcherDropdown({ const setOpen = useUIStore((state) => state.setSessionDropdownOpen); return ( - + {children} state.currentSessionId); + const isNewSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open === true); + const items = useSwitcherItems(true, { scopeProjectId, currentSessionId }); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const { t } = useI18n(); @@ -79,6 +86,9 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP }, [onSelect, openNewSessionDraft]); const [expandedParents, setExpandedParents] = React.useState>(new Set()); + const contentRef = React.useRef(null); + const initialFocusCompleteRef = React.useRef(false); + const initialTarget = isNewSessionDraftOpen ? NEW_SESSION_SWITCHER_TARGET : currentSessionId; const toggleParent = React.useCallback((sessionId: string) => { setExpandedParents((prev) => { const next = new Set(prev); @@ -91,10 +101,36 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP }); }, []); + React.useLayoutEffect(() => { + if (initialFocusCompleteRef.current || !initialTarget) return; + + const ancestorIds = initialTarget === NEW_SESSION_SWITCHER_TARGET + ? [] + : findSwitcherItemAncestorIds(items, initialTarget); + if (!ancestorIds) return; + + if (ancestorIds.some((id) => !expandedParents.has(id))) { + setExpandedParents((previous) => new Set([...previous, ...ancestorIds])); + return; + } + + const animationFrame = requestAnimationFrame(() => { + const item = Array.from( + contentRef.current?.querySelectorAll('[data-switcher-item-id]') ?? [], + ).find((element) => element.dataset.switcherItemId === initialTarget); + if (!item) return; + item.focus(); + item.scrollIntoView({ block: 'nearest' }); + initialFocusCompleteRef.current = true; + }); + return () => cancelAnimationFrame(animationFrame); + }, [expandedParents, initialTarget, items]); + return ( -
+
window.cancelAnimationFrame(raf); }, [enabled, isSessionSearchOpen, sessionSearchInputRef]); + // The open_session_list shortcut lands here when the sidebar is visible: + // the session list is already on screen, so the shortcut opens its search. + React.useEffect(() => { + if (!enabled || typeof window === 'undefined') { + return; + } + const handleOpenRequest = () => { + setIsSessionSearchOpen(true); + sessionSearchInputRef.current?.focus(); + sessionSearchInputRef.current?.select(); + }; + window.addEventListener('openchamber:sidebar-session-search', handleOpenRequest); + return () => window.removeEventListener('openchamber:sidebar-session-search', handleOpenRequest); + }, [enabled, setIsSessionSearchOpen, sessionSearchInputRef]); + React.useEffect(() => { if (!enabled || !isSessionSearchOpen || typeof document === 'undefined') { return; diff --git a/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.test.ts b/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.test.ts new file mode 100644 index 00000000..daa8fc4c --- /dev/null +++ b/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; + +import { + findSwitcherItemAncestorIds, + selectSwitcherParents, + type SwitcherItem, +} from './useSwitcherItems'; + +const session = (id: string, options: { parentID?: string; archived?: boolean; projectId?: string } = {}): Session => ({ + id, + parentID: options.parentID, + time: options.archived ? { archived: Date.now() } : undefined, + projectId: options.projectId ?? 'project-a', +} as unknown as Session); + +const selectParents = (sessions: Session[], currentSessionId: string | null, scopeProjectId: string | null = null): Session[] => ( + selectSwitcherParents(sessions, new Set(), new Map(), scopeProjectId, currentSessionId, (item) => (item as Session & { projectId: string }).projectId) +); + +describe('session switcher initial selection', () => { + test('finds all local ancestors for a current child session', () => { + const items: SwitcherItem[] = [{ + node: { session: session('root'), worktree: null, children: [{ session: session('parent', { parentID: 'root' }), worktree: null, children: [{ session: session('child', { parentID: 'parent' }), worktree: null, children: [] }] }] }, + projectId: 'project-a', groupDirectory: null, secondaryMeta: null, + }]; + + expect(findSwitcherItemAncestorIds(items, 'child')).toEqual(['root', 'parent']); + expect(findSwitcherItemAncestorIds(items, 'missing')).toBeNull(); + }); + + test('replaces the final recent slot with the current root and excludes invalid current sessions', () => { + const roots = Array.from({ length: 8 }, (_, index) => session(`root-${index}`)); + const child = session('child', { parentID: 'root-7' }); + + expect(selectParents([...roots, child], 'child').map((item) => item.id)).toEqual([ + 'root-0', 'root-1', 'root-2', 'root-3', 'root-4', 'root-5', 'root-7', + ]); + expect(selectParents([...roots, child], 'missing').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id)); + expect(selectParents([...roots, child], 'child', 'project-b').map((item) => item.id)).toEqual([]); + expect(selectParents([...roots.slice(0, 7), session('archived', { archived: true })], 'archived').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id)); + expect(selectParents([...roots, session('archived-child', { archived: true, parentID: 'root-7' })], 'archived-child').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id)); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.ts index 8e0f5e77..8bdc5b31 100644 --- a/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.ts +++ b/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.ts @@ -27,6 +27,7 @@ const MAX_PARENT_SESSIONS = 7; type SwitcherItemsOptions = { scopeProjectId?: string | null; + currentSessionId?: string | null; /** How many parent sessions to return (default 7 — the desktop dropdown). */ maxParents?: number; }; @@ -46,8 +47,69 @@ const formatProjectLabel = (project: { label?: string | null; path: string } | n return segments[segments.length - 1] ?? null; }; +export const findSwitcherItemAncestorIds = (items: SwitcherItem[], sessionId: string): string[] | null => { + const visit = (node: SessionNode, ancestors: string[]): string[] | null => { + if (node.session.id === sessionId) return ancestors; + for (const child of node.children) { + const result = visit(child, [...ancestors, node.session.id]); + if (result) return result; + } + return null; + }; + + for (const item of items) { + const result = visit(item.node, []); + if (result) return result; + } + return null; +}; + +export const selectSwitcherParents = ( + activeSessions: Session[], + pinnedSessionIds: Set, + sessionOrderRanks: Map, + scopeProjectId: string | null, + currentSessionId: string | null, + getProjectId: (session: Session) => string | null, + maxParents = MAX_PARENT_SESSIONS, + isExcluded?: (session: Session) => boolean, +): Session[] => { + const sessionsById = new Map(activeSessions.map((session) => [session.id, session])); + const isEligibleParent = (session: Session): boolean => { + if (session.time?.archived) return false; + if (isExcluded?.(session)) return false; + // SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions. + if ((session as Session & { parentID?: string | null }).parentID) return false; + return !scopeProjectId || getProjectId(session) === scopeProjectId; + }; + const parents = activeSessions + .filter(isEligibleParent) + .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); + + const currentSession = currentSessionId ? sessionsById.get(currentSessionId) ?? null : null; + let currentRoot: Session | null = currentSession?.time?.archived ? null : currentSession; + const visited = new Set(); + while (currentRoot) { + // SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions. + const parentId = (currentRoot as Session & { parentID?: string | null }).parentID; + if (!parentId) break; + if (visited.has(parentId)) { + currentRoot = null; + break; + } + visited.add(parentId); + currentRoot = sessionsById.get(parentId) ?? null; + } + + const currentRootIndex = currentRoot && isEligibleParent(currentRoot) ? parents.indexOf(currentRoot) : -1; + if (currentRootIndex >= maxParents) { + return [...parents.slice(0, Math.max(0, maxParents - 1)), currentRoot!]; + } + return parents.slice(0, maxParents); +}; + export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => { - const { scopeProjectId = null, maxParents = MAX_PARENT_SESSIONS } = options; + const { scopeProjectId = null, currentSessionId = null, maxParents = MAX_PARENT_SESSIONS } = options; const activeSessions = useGlobalSessionsStore((state) => state.activeSessions); const projects = useProjectsStore((state) => state.projects); const pinnedSessionIds = useSessionPinnedStore((state) => state.ids); @@ -116,19 +178,17 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); }); - const parents = activeSessions - .filter((session) => !session.time?.archived) + const parents = selectSwitcherParents( + activeSessions, + pinnedSessionIds, + sessionOrderRanks, + scopeProjectId, + currentSessionId, + (session) => findProjectForDirectory(resolveGlobalSessionDirectory(session))?.id ?? null, + maxParents, // btw forks stay hidden until promoted to a full session - .filter((session) => !isBtwSession(session)) - .filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session))) - .filter((session) => !(session as Session & { parentID?: string | null }).parentID) - .filter((session) => { - if (!scopeProjectId) return true; - const directory = resolveGlobalSessionDirectory(session); - return findProjectForDirectory(directory)?.id === scopeProjectId; - }) - .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)) - .slice(0, maxParents); + (session) => isBtwSession(session) || (isVSCode && isChatDirectoryPath(resolveGlobalSessionDirectory(session))), + ); const buildNode = (session: Session): SessionNode => { const childSessions = childrenByParent.get(session.id) ?? []; @@ -158,7 +218,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions }, }; }); - }, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); + }, [activeSessions, branchesByDirectory, currentSessionId, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); return items; }; diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 77a111e6..5f2d5c86 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -37,7 +37,8 @@ import { toast } from '@/components/ui'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import type { Session } from '@opencode-ai/sdk/v2'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; -import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo, shortcutRegistry } from '@/lib/shortcuts'; +import { showOpenCodeStatus } from '@/lib/openCodeStatus'; import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata'; @@ -49,6 +50,7 @@ import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch'; import { truncatePathMiddle } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { buildCommandPaletteFileSearchKey, scoreCommandPaletteFiles } from './commandPaletteFilesState'; @@ -58,6 +60,9 @@ type CommandEntry = { icon: React.ReactNode; shortcutId?: string; searchText: string; + /** Search-only command: reachable by typing, hidden from the initial list + so the first screen stays scroll-free. */ + secondary?: boolean; onSelect: () => void; }; @@ -89,9 +94,14 @@ export const CommandPalette: React.FC = () => { const openContextSurface = useUIStore((s) => s.openContextSurface); const openContextFile = useUIStore((s) => s.openContextFile); const shortcutOverrides = useUIStore((s) => s.shortcutOverrides); + const openMultiRunLauncher = useUIStore((s) => s.openMultiRunLauncher); + const setArchivePageOpen = useUIStore((s) => s.setArchivePageOpen); + const setProjectContextTab = useUIStore((s) => s.setProjectContextTab); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const togglePinnedSession = useSessionPinnedStore((s) => s.toggle); const activeSessions = useGlobalSessionsStore(React.useCallback( (state) => isCommandPaletteOpen ? state.activeSessions : EMPTY_SESSIONS, @@ -230,6 +240,27 @@ export const CommandPalette: React.FC = () => { if (currentDirectory) openContextOverview(currentDirectory); }), }, + { + id: 'cycle-theme', + secondary: true, + title: t('commandPalette.item.cycleTheme'), + icon: , + shortcutId: 'cycle_theme', + searchText: t('commandPalette.item.cycleTheme'), + onSelect: run(() => { + shortcutRegistry.invoke('cycle_theme'); + }), + }, + { + id: 'open-status', + secondary: true, + title: t('commandPalette.item.showOpenCodeStatus'), + icon: , + searchText: t('commandPalette.item.showOpenCodeStatus'), + onSelect: run(() => { + void showOpenCodeStatus(); + }), + }, { id: 'open-settings', title: t('commandPalette.item.openSettings'), @@ -239,6 +270,97 @@ export const CommandPalette: React.FC = () => { onSelect: run(() => setSettingsDialogOpen(true)), }, ]; + list.push( + { + id: 'pin-session', + secondary: true, + title: t('commandPalette.item.pinSession'), + icon: , + searchText: t('commandPalette.item.pinSession'), + onSelect: run(() => { + if (currentSessionId && currentDirectory) { + togglePinnedSession({ directory: currentDirectory, sessionId: currentSessionId }); + } + }), + }, + { + id: 'copy-session-id', + secondary: true, + title: t('commandPalette.item.copySessionId'), + icon: , + searchText: t('commandPalette.item.copySessionId'), + onSelect: run(() => { + if (!currentSessionId) return; + void copyTextToClipboard(currentSessionId) + .then((result) => { + if (result.ok) { + toast.success(t('sessions.sidebar.session.copyId.success')); + return; + } + toast.error(t('sessions.sidebar.session.copyId.error')); + }) + .catch(() => toast.error(t('sessions.sidebar.session.copyId.error'))); + }), + }, + { + id: 'open-multi-run', + secondary: true, + title: t('commandPalette.item.openMultiRun'), + icon: , + searchText: t('commandPalette.item.openMultiRun'), + onSelect: run(() => { + setSessionSwitcherOpen(false); + openMultiRunLauncher(); + }), + }, + { + id: 'open-archive', + secondary: true, + title: t('commandPalette.item.openArchive'), + icon: , + searchText: t('commandPalette.item.openArchive'), + onSelect: run(() => { + setSessionSwitcherOpen(false); + setArchivePageOpen(true); + }), + }, + { + id: 'open-notes', + secondary: true, + title: t('commandPalette.item.openNotes'), + icon: , + searchText: t('commandPalette.item.openNotes'), + onSelect: run(() => { + if (currentDirectory) { + setProjectContextTab('notes'); + openContextSurface(currentDirectory, 'notes'); + } + }), + }, + { + id: 'open-todos', + secondary: true, + title: t('commandPalette.item.openTodos'), + icon: , + searchText: t('commandPalette.item.openTodos'), + onSelect: run(() => { + if (currentDirectory) { + setProjectContextTab('todos'); + openContextSurface(currentDirectory, 'notes'); + } + }), + }, + ); + list.push({ + id: 'toggle-memory-debug', + secondary: true, + title: t('commandPalette.item.toggleMemoryDebug'), + icon: , + searchText: t('commandPalette.item.toggleMemoryDebug'), + onSelect: run(() => { + window.dispatchEvent(new CustomEvent('openchamber:memory-debug-toggle')); + }), + }); if (canUseElectronDesktopIPC()) { list.splice(1, 0, { id: 'new-mini-chat', @@ -270,6 +392,11 @@ export const CommandPalette: React.FC = () => { setSettingsDialogOpen, activeProject?.id, activeProject?.path, + currentSessionId, + togglePinnedSession, + openMultiRunLauncher, + setArchivePageOpen, + setProjectContextTab, ]); // --------------------------------------------------------------------------- @@ -378,7 +505,9 @@ export const CommandPalette: React.FC = () => { const hasQuery = liveTrimmed.length > 0; const scoredCommands = React.useMemo(() => { - if (!hasQuery) return commands.map((item) => ({ item, score: 0 })); + if (!hasQuery) { + return commands.filter((item) => !item.secondary).map((item) => ({ item, score: 0 })); + } return scoreByFuzzyQuery(commands, liveTrimmed, (c) => c.searchText, { limit: 7, noFuzzy: true, diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 6ed250ea..f5f776b5 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -10,18 +10,19 @@ import { Icon } from "@/components/icon/Icon"; import { useUIStore } from "@/stores/useUIStore"; import { getEffectiveShortcutCombo, + getEffectiveShortcutPrefix, getShortcutAction, - getModifierLabel, formatShortcutForDisplay, + type ShortcutActionId, } from "@/lib/shortcuts"; import { useI18n, type I18nKey } from "@/lib/i18n"; import { isVSCodeRuntime } from "@/lib/desktop"; import type { IconName } from "@/components/icon/icons"; type ShortcutItem = { - id?: string; + id?: ShortcutActionId; keys: string | string[]; - descriptionKey: I18nKey; + descriptionKey?: I18nKey; icon: IconName | null; }; @@ -30,9 +31,12 @@ type ShortcutSection = { items: ShortcutItem[]; }; -const renderShortcut = (id: string, fallbackCombo: string, overrides: Record) => { - const action = getShortcutAction(id); - return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides)) : fallbackCombo; +const renderShortcut = ( + id: ShortcutActionId, + overrides: Record, + unassignedLabel: string, +) => { + return formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides), unassignedLabel); }; export const HelpDialog: React.FC = () => { @@ -40,7 +44,6 @@ export const HelpDialog: React.FC = () => { const isHelpDialogOpen = useUIStore((state) => state.isHelpDialogOpen); const setHelpDialogOpen = useUIStore((state) => state.setHelpDialogOpen); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const mod = getModifierLabel(); const isVSCode = isVSCodeRuntime(); const shortcuts: ShortcutSection[] = [ @@ -100,7 +103,7 @@ export const HelpDialog: React.FC = () => { keys: '', }, { - keys: [`Shift + Alt + ${mod} + N`], + keys: [formatShortcutForDisplay('mod+shift+alt+n')], descriptionKey: "helpDialog.item.newWindow", icon: "window", }, @@ -121,6 +124,21 @@ export const HelpDialog: React.FC = () => { icon: "git-branch", keys: '', }, + { + id: 'open_draft_project_picker', + icon: 'folder', + keys: '', + }, + { + id: 'open_draft_worktree_picker', + icon: 'git-branch', + keys: '', + }, + { + id: 'open_session_list', + icon: 'list-unordered', + keys: '', + }, { id: 'focus_input', descriptionKey: "helpDialog.item.focusChatInput", icon: "text", keys: '' }, { id: 'toggle_prompt_navigator', @@ -139,24 +157,6 @@ export const HelpDialog: React.FC = () => { { categoryKey: "helpDialog.section.panels", items: [ - { - id: 'toggle_right_sidebar', - descriptionKey: 'helpDialog.item.toggleRightSidebar', - icon: "layout-right", - keys: '', - }, - { - id: 'open_right_sidebar_git', - descriptionKey: 'helpDialog.item.openRightSidebarGitTab', - icon: "git-branch", - keys: '', - }, - { - id: 'open_right_sidebar_files', - descriptionKey: 'helpDialog.item.openRightSidebarFilesTab', - icon: "layout-right", - keys: '', - }, { id: 'toggle_terminal', descriptionKey: 'helpDialog.item.toggleTerminalDock', @@ -170,14 +170,13 @@ export const HelpDialog: React.FC = () => { keys: '', }, { - id: 'toggle_context_plan', - descriptionKey: 'helpDialog.item.togglePlanContextPanel', - icon: "time", - keys: '', + keys: [`${formatShortcutForDisplay(getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides))} + 1...0`], + descriptionKey: "helpDialog.item.switchContextSurface", + icon: "layout-right", }, { - keys: [`${mod} + 1...0`], - descriptionKey: "helpDialog.item.switchContextSurface", + keys: [`${formatShortcutForDisplay(getEffectiveShortcutPrefix('switch_session_tab', shortcutOverrides))} + 1...9`], + descriptionKey: "helpDialog.item.switchSessionTab", icon: "layout-right", }, ], @@ -197,12 +196,6 @@ export const HelpDialog: React.FC = () => { icon: "stack", keys: '', }, - { - id: 'cycle_services_tab', - descriptionKey: 'helpDialog.item.cycleServicesTab', - icon: "stack", - keys: '', - }, { id: 'open_settings', descriptionKey: "helpDialog.item.openSettings", @@ -214,7 +207,7 @@ export const HelpDialog: React.FC = () => { ]; return ( - + @@ -237,40 +230,54 @@ export const HelpDialog: React.FC = () => { {section.items .filter((shortcut) => !(isVSCode && shortcut.id === 'toggle_prompt_navigator')) .map((shortcut) => { - const displayKeys = shortcut.id - ? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides) - : (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / ")); + const action = shortcut.id ? getShortcutAction(shortcut.id) : undefined; + const descriptionKey = shortcut.descriptionKey + ?? (action?.customizable ? action.settingsLabelKey : undefined); + if (!descriptionKey) return null; + // This dialog lists what the keyboard can do right now; + // an action without a binding belongs to the command + // palette and Settings, not here. + if (shortcut.id && !getEffectiveShortcutCombo(shortcut.id, shortcutOverrides)) { + return null; + } + const displayKeys = shortcut.id + ? renderShortcut( + shortcut.id, + shortcutOverrides, + t('settings.openchamber.keyboardShortcuts.unassigned'), + ) + : (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / ")); - return ( -
-
- {shortcut.icon && ( - - )} - - {t(shortcut.descriptionKey)} - + return ( +
+
+ {shortcut.icon && ( + + )} + + {t(descriptionKey)} + +
+
+ {(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => ( + + {i > 0 && ( + + {t('helpDialog.keyCombiner.or')} + + )} + + {keyCombo} + + + ))} +
-
- {(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => ( - - {i > 0 && ( - - {t('helpDialog.keyCombiner.or')} - - )} - - {keyCombo} - - - ))} -
-
- ); - })} + ); + })}
))} @@ -284,14 +291,18 @@ export const HelpDialog: React.FC = () => {
  • • {t('helpDialog.proTips.commandPalette', { - shortcut: renderShortcut('open_command_palette', `${mod} P`, shortcutOverrides), + shortcut: renderShortcut( + 'open_command_palette', + shortcutOverrides, + t('settings.openchamber.keyboardShortcuts.unassigned'), + ), })}
  • • {t('helpDialog.proTips.recentSessions')}
  • - • {t('helpDialog.proTips.themeCycling')} + • {t('helpDialog.proTips.leaderSequences')}
diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index e8fd327f..aec5df61 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -3,6 +3,8 @@ import { Menu as BaseMenu } from "@base-ui/react/menu" import { cn } from "@/lib/utils" import { Icon } from "@/components/icon/Icon"; +import { shortcutRegistry } from "@/lib/shortcuts"; +import { handleDropdownNavigationKey } from "./dropdown-navigation"; import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles"; type AsChildProps = { asChild?: boolean }; @@ -34,11 +36,21 @@ function renderFromAsChild(asChild: boolean | undefined, children: React.ReactNo return { children }; } +type DropdownMenuProps = React.ComponentProps & { + disableGlobalShortcuts?: boolean; +}; + function DropdownMenu({ + disableGlobalShortcuts = false, + open, + defaultOpen, + onOpenChange, ...props -}: React.ComponentProps) { +}: DropdownMenuProps) { const [portalContainer, setPortalContainer] = React.useState(null); const [collisionBoundary, setCollisionBoundary] = React.useState(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false); + const isOpen = open ?? uncontrolledOpen; const portalContextValue = React.useMemo(() => ({ portalContainer, collisionBoundary, @@ -46,9 +58,24 @@ function DropdownMenu({ setCollisionBoundary, }), [collisionBoundary, portalContainer]); + React.useLayoutEffect(() => { + if (!disableGlobalShortcuts || !isOpen) return; + return shortcutRegistry.suspend(); + }, [disableGlobalShortcuts, isOpen]); + + const handleOpenChange: NonNullable['onOpenChange']> = (nextOpen, eventDetails) => { + if (open === undefined) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen, eventDetails); + }; + return ( - + ) } @@ -116,11 +143,23 @@ function DropdownMenuContent({ style, children, onCloseAutoFocus, + onKeyDown, ...props }: ContentProps) { const portalContext = React.useContext(DropdownPortalContext); void onCloseAutoFocus + const handleKeyDown: NonNullable['onKeyDown']> = (event) => { + onKeyDown?.(event); + handleDropdownNavigationKey(event, (navigationKey) => { + event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { + key: navigationKey, + bubbles: true, + cancelable: true, + })); + }); + }; + return ( {children} diff --git a/packages/ui/src/components/ui/dropdown-navigation.ts b/packages/ui/src/components/ui/dropdown-navigation.ts new file mode 100644 index 00000000..185e2fc9 --- /dev/null +++ b/packages/ui/src/components/ui/dropdown-navigation.ts @@ -0,0 +1,47 @@ +import type React from 'react'; + +import { isIMECompositionEvent } from '@/lib/ime'; + +function getDropdownNavigationKey(event: Pick): 'ArrowDown' | 'ArrowUp' | null { + if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null; + // `code` covers non-Latin layouts, where `key` is the layout's own letter. + if (event.key.toLowerCase() === 'n' || event.code === 'KeyN') return 'ArrowDown'; + if (event.key.toLowerCase() === 'p' || event.code === 'KeyP') return 'ArrowUp'; + return null; +} + +type DropdownNavigationEvent = Pick< + React.KeyboardEvent, + | 'altKey' + | 'code' + | 'ctrlKey' + | 'defaultPrevented' + | 'isPropagationStopped' + | 'key' + | 'metaKey' + | 'preventDefault' + | 'shiftKey' + | 'stopPropagation' +>; + +export function handleDropdownNavigationKey( + event: DropdownNavigationEvent, + navigate: (key: 'ArrowDown' | 'ArrowUp') => void, +): boolean { + if (event.defaultPrevented || event.isPropagationStopped()) return false; + const navigationKey = getDropdownNavigationKey(event); + if (!navigationKey) return false; + + // Do not add an IME guard: exact Ctrl+N/P remain intentional commands, while + // every other composing key falls through without being handled. + navigate(navigationKey); + event.preventDefault(); + event.stopPropagation(); + return true; +} + +export function shouldDismissDropdown( + event: KeyboardEvent | React.KeyboardEvent, +): boolean { + return event.key === 'Escape' && !isIMECompositionEvent(event); +} diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index 0fb0ea0e..14a0daa2 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -8,6 +8,8 @@ import { cn } from "@/lib/utils" import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger" import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay"; import { Icon } from "@/components/icon/Icon"; +import { shortcutRegistry } from "@/lib/shortcuts"; +import { handleDropdownNavigationKey } from "./dropdown-navigation"; type AsChildProps = { asChild?: boolean }; type AsChildRenderProps = { @@ -38,15 +40,22 @@ type SelectRootProps = Omit< value?: Value; defaultValue?: Value; onValueChange?: (value: Value, eventDetails: SelectRootChangeEventDetails) => void; + disableGlobalShortcuts?: boolean; }; function Select({ onValueChange, modal = false, + disableGlobalShortcuts = false, + open, + defaultOpen, + onOpenChange, ...props }: SelectRootProps) { const [portalContainer, setPortalContainer] = React.useState(null); const [collisionBoundary, setCollisionBoundary] = React.useState(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false); + const isOpen = open ?? uncontrolledOpen; const portalContextValue = React.useMemo(() => ({ portalContainer, collisionBoundary, @@ -63,9 +72,26 @@ function Select({ [onValueChange] ); + React.useLayoutEffect(() => { + if (!disableGlobalShortcuts || !isOpen) return; + return shortcutRegistry.suspend(); + }, [disableGlobalShortcuts, isOpen]); + + const handleOpenChange: NonNullable['onOpenChange']> = (nextOpen, eventDetails) => { + if (open === undefined) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen, eventDetails); + }; + return ( - + ) } @@ -184,12 +210,24 @@ function SelectContent({ align, collisionAvoidance, constrainToMain = false, + onKeyDown, ...props }: React.ComponentProps & SelectContentExtra) { const portalContext = React.useContext(SelectPortalContext); const alignItemWithTrigger = position === "item-aligned"; const portalContainer = portalContext?.portalContainer ?? null; + const handleKeyDown: NonNullable['onKeyDown']> = (event) => { + onKeyDown?.(event); + handleDropdownNavigationKey(event, (navigationKey) => { + event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { + key: navigationKey, + bubbles: true, + cancelable: true, + })); + }); + }; + return ( ) { +}: React.ComponentProps & { + showSelectedBackground?: boolean; +}) { return ( = ({ scrollToFile(value); }, [cancelPendingScrollAlignment, expandStackedFile, scrollToFile]); + // Step review to the adjacent changed file (alt+arrow): selects, expands + // a collapsed section, and scrolls to it. Window-level because the diff + // surface has no persistent focus target; guarded off editable fields. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return; + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + const target = event.target; + if (target instanceof HTMLElement && ( + target.isContentEditable + || target.tagName === 'INPUT' + || target.tagName === 'TEXTAREA' + || target.closest('[role="dialog"]') + )) { + return; + } + if (changedFiles.length === 0) return; + const delta = event.key === 'ArrowDown' ? 1 : -1; + const index = displayFile ? changedFiles.findIndex((file) => file.path === displayFile) : -1; + const nextIndex = index === -1 + ? (delta > 0 ? 0 : changedFiles.length - 1) + : index + delta; + const next = changedFiles[nextIndex]; + if (!next) return; + event.preventDefault(); + handleSelectFileAndScroll(next.path); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [changedFiles, displayFile, handleSelectFileAndScroll]); + const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => { const nextLayout: 'inline' | 'side-by-side' = mode === 'side-by-side' ? 'side-by-side' : 'inline'; diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 907de790..73219d05 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -45,7 +45,7 @@ import { import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useDeviceInfo } from '@/lib/device'; -import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils'; +import { cn, getRevealLabelKey } from '@/lib/utils'; import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers'; import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave'; import { getRuntimeUrlResolver } from '@/lib/runtime-url'; @@ -75,7 +75,8 @@ import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; -import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { useKeybind, useKeybinds } from '@/hooks/useKeybind'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; import { syncScheduledTaskLoops } from '@/lib/scheduledTasksApi'; @@ -1032,7 +1033,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation); const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath); - const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap); const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); @@ -1759,35 +1759,28 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { setAutoSaveStatus('idle'); }, [selectedFile?.path]); - React.useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!hasModifier(e)) { - return; - } + useKeybinds({ + save_file: (event) => { + if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false; - if (e.key.toLowerCase() === 's') { - e.preventDefault(); - // Cancel pending auto-save; user wants immediate save - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current); - autoSaveTimerRef.current = null; - } - if (!isSaving) { - void saveDraft().then((saved) => { - if (!saved) return; - setAutoSaveStatus('saved'); - setTimeout(() => setAutoSaveStatus('idle'), 2000); - }); - } - } else if (e.key.toLowerCase() === 'f') { - e.preventDefault(); - setIsSearchOpen(true); + // Cancel pending auto-save because the explicit save should run immediately. + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isSaving, saveDraft]); + if (!isSaving) { + void saveDraft().then((saved) => { + if (!saved) return; + setAutoSaveStatus('saved'); + setTimeout(() => setAutoSaveStatus('idle'), 2000); + }); + } + }, + find_in_file: (event) => { + if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false; + setIsSearchOpen(true); + }, + }); const loadSelectedFile = React.useCallback(async (node: FileNode) => { const loadId = activeFileLoadIdRef.current + 1; @@ -2906,42 +2899,21 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }; }, [isMobile, nudgeEditorSelectionAboveKeyboard]); - React.useEffect(() => { + useKeybind('open_go_to_line', (event) => { if (!canEdit || textViewMode !== 'edit' || isMobile) { - return; + return false; } - const goToLineCombo = getEffectiveShortcutCombo('open_go_to_line', shortcutOverrides); + const target = event.target as Element | null; + if (target?.closest('[role="dialog"]')) return false; + if (!(target instanceof Node) || !editorWrapperRef.current?.contains(target)) return false; - const handleKeyDown = (event: KeyboardEvent) => { - const target = event.target as Element | null; - if (target?.closest('[role="dialog"]')) { - return; - } + const isEditorTarget = Boolean(target?.closest('.cm-editor')); + const isTypingTarget = Boolean(target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]')); + if (isTypingTarget && !isEditorTarget) return false; - const isEditorTarget = Boolean(target?.closest('.cm-editor')); - const isTypingTarget = Boolean( - target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]') - ); - if (isTypingTarget && !isEditorTarget) { - return; - } - - const activeElement = document.activeElement as Element | null; - const editorHasFocus = Boolean(activeElement?.closest('.cm-editor')); - if (!editorHasFocus) { - return; - } - - if (eventMatchesShortcut(event, goToLineCombo)) { - event.preventDefault(); - setIsGoToLineOpen(true); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [canEdit, isMobile, shortcutOverrides, textViewMode]); + setIsGoToLineOpen(true); + }); const editorFontSize = useUIStore((state) => state.editorFontSize); @@ -3196,6 +3168,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } const docked = layout === 'docked'; + const saveShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('save_file')); const wrapperCls = docked ? 'pointer-events-auto flex flex-wrap items-center gap-1' : 'pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-1 shadow-sm'; @@ -3225,14 +3198,14 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { {t('filesView.editor.saved')} - ) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` }), + ) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut }), diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index dbec130d..3be50394 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -1,5 +1,9 @@ import React from 'react'; -import { cn, getModifierLabel } from '@/lib/utils'; +import { cn } from '@/lib/utils'; +import { + formatShortcutForDisplay, + getEffectiveShortcutCombo, +} from '@/lib/shortcuts'; import { useUIStore } from '@/stores/useUIStore'; import { useSettingsDirectory } from '@/hooks/useSettingsDirectory'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -187,6 +191,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const settingsPageRaw = useUIStore((state) => state.settingsPage); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const openSettingsShortcutOverride = useUIStore((state) => state.shortcutOverrides.open_settings); const settingsSlug = resolveSettingsSlug(settingsPageRaw); const [mobileStage, setMobileStage] = React.useState(initialMobileStage); @@ -728,7 +733,15 @@ export const SettingsView: React.FC = ({ onClose, forceMobile : showBackButton ? t('settings.view.actions.backToSettings') : t('settings.view.actions.closeSettings'); - const shortcutKey = getModifierLabel(); + const openSettingsCombo = getEffectiveShortcutCombo( + 'open_settings', + openSettingsShortcutOverride === undefined ? undefined : { open_settings: openSettingsShortcutOverride }, + ); + const closeSettingsTitle = openSettingsCombo + ? t('settings.view.actions.closeSettingsWithShortcut', { + shortcut: formatShortcutForDisplay(openSettingsCombo), + }) + : t('settings.view.actions.closeSettings'); const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => { if (typeof window === 'undefined' || runtimeCtx.isVSCode) { @@ -1077,7 +1090,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile type="button" onClick={onClose} aria-label={t('settings.view.actions.closeSettings')} - title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })} + title={closeSettingsTitle} className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" > @@ -1105,7 +1118,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile type="button" onClick={onClose} aria-label={t('settings.view.actions.closeSettings')} - title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })} + title={closeSettingsTitle} className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" > diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 21d45de0..8e3e83f7 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -21,6 +21,7 @@ import { useI18n } from '@/lib/i18n'; import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; type TerminalViewProps = { visible?: boolean; @@ -968,7 +969,7 @@ export const TerminalView: React.FC = ({ visible }) => { onClick={() => handleModifierToggle('ctrl')} disabled={quickKeysDisabled} > - {t('terminalView.quickKeys.controlLabel')} + {formatShortcutForDisplay('ctrl')} {t('terminalView.quickKeys.controlModifierAria')}