diff --git a/CHANGELOG.md b/CHANGELOG.md index 6edb2e5b..350a8175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Walkthrough:** a new guided walkthrough reorders a diff into a sequence of stops — the model groups related changes, explains what each one does, and orders them so each builds on the last. Start one from the Changes and pull-request views for uncommitted work, a branch against its base, or a pull request; nothing runs on its own. Walkthroughs are written in your interface language by default, and the panel can generate one in any other supported language. +- **Mobile/Tablet:** reworked the tablet and foldable layout around the phone's navigation — a persistent resizable sessions sidebar on the left, the workspace (Changes, Files, Terminal, Notes, MCP) as a resizable right sidebar, and app pages like settings and instances shown as centered dialogs. An open diff, edited file, or attached terminal now survives rotation. +- Performance: fixed Bun dependency chunking so the web app no longer downloads a single 18.5 MB vendor bundle at startup; heavy syntax highlighting, screenshot, diagram, editor, and image-conversion libraries now load only when needed (thanks to @makeittech). +- Mobile/Android: pairing QR codes can now be scanned on devices without Google Play Services; the camera closes as soon as a code is recognized, followed by a connection-in-progress screen. +- Mobile/Android: left and right drawer swipes can now start farther from the screen edge, outside Android's system Back gesture area. +- Sessions: launching OpenChamber from a directory other than your project (for example your home folder) no longer produces repeated "not a git repository" errors that could stop sessions and projects from loading (thanks to @makeittech). +- Sidebar: a worktree shared by more than one project no longer appears twice (thanks to @makeittech). +- Sidebar: session titles no longer clip at the ends of their rows. +- Git/Diff: opening a changed file now jumps its header directly to the top, and live updates refresh only files that actually changed while preserving the current review position. Saves from the built-in file editor update the diff too. +- Sessions: archiving and unarchiving now stays scoped to the current instance and workspace (thanks to @alexandrereyes). +- Chat: assistant messages no longer render active HTML. +- VSCode: clicking an apply_patch tool result now opens each changed file at its correct path instead of always opening the first file (thanks to @nabsiddiqui). + ## [1.17.2] - 2026-08-01 - **Mobile:** rebuilt the app navigation around two swipe drawers — a sessions drawer (left) with a cross-project tree, swipe actions to rename, archive, or delete sessions, and a workspace drawer (right) with Changes, Files, Terminal, Notes, and MCP tabs. Tapping the session title in the header switches recents from a compact overlay with live status indicators. Cold launches reopen the last active session and land on an explicit connect screen on failure instead of flashing an empty draft. @@ -42,7 +55,7 @@ All notable changes to this project will be documented in this file. ## [1.17.0] - 2026-07-28 - **Context panel:** a new surface rail brings Changes, pull requests, files, terminal, notes, plans, previews, and side chats into one resizable panel. The pull-request surface now shows live checks and comments, and can attach failed checks or comments to a chat draft. -- **Desktop/Linux:** official AppImage releases for x64 and arm64, with in-app updates, frameless window controls, system tray minimize, launch at login, multi-window support, and “Open in” for discovered installed apps. Missing update manifests are treated as “no update” instead of a hard failure, and updater errors surface in About/sidebar (thanks to @jibanez-staticduo, @makeittech). +- **Desktop/Linux:** official AppImage releases for x64 and arm64, with in-app updates, frameless window controls, system tray minimize, launch at login, multi-window support, and “Open in” for discovered installed apps. Missing update manifests are treated as “no update” instead of a hard failure, and updater errors surface in About/sidebar (thanks to @BestSithInEU, @jibanez-staticduo, @makeittech). - **Sidebar:** sessions are organized into Recent and project zones with worktree-grouped or flat views. Scheduled tasks, archived sessions, multi-run, and worktree management now open as full-page views from the sidebar. - **Agents/CLI:** agents on managed local instances can now create, send to, fork, inspect, and wait for sessions; create isolated worktrees; and manage scheduled tasks through the OpenChamber tool. The CLI adds matching `session`, `schedule`, `projects`, and `models` commands, and a new Schedule a Task starter guides task setup from chat. - Chat composer: prompts now render Markdown emphasis, attention lines, file and agent mentions, slash commands, snippets, attachment citations, and `~path` references directly while you type. File mentions can be edited in place, and the mobile composer grows with its content instead of using a separate fullscreen gesture. diff --git a/README.md b/README.md index 1964313b..7241e03a 100644 --- a/README.md +++ b/README.md @@ -1,453 +1,160 @@ # OpenChamber -[![GitHub stars](https://img.shields.io/github/stars/openchamber/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0yMjkuMDYsMTA4Ljc5bC00OC43LDQyLDE0Ljg4LDYyLjc5YTguNCw4LjQsMCwwLDEtMTIuNTIsOS4xN0wxMjgsMTg5LjA5LDczLjI4LDIyMi43NGE4LjQsOC40LDAsMCwxLTEyLjUyLTkuMTdsMTQuODgtNjIuNzktNDguNy00MkE4LjQ2LDguNDYsMCwwLDEsMzEuNzMsOTRMOTUuNjQsODguOGwyNC42Mi01OS42YTguMzYsOC4zNiwwLDAsMSwxNS40OCwwbDI0LjYyLDU5LjZMMjI0LjI3LDk0QTguNDYsOC40NiwwLDAsMSwyMjkuMDYsMTA4Ljc5WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIzOS4xOCw5Ny4yNkExNi4zOCwxNi4zOCwwLDAsMCwyMjQuOTIsODZsLTU5LTQuNzZMMTQzLjE0LDI2LjE1YTE2LjM2LDE2LjM2LDAsMCwwLTMwLjI3LDBMOTAuMTEsODEuMjMsMzEuMDgsODZhMTYuNDYsMTYuNDYsMCwwLDAtOS4zNywyOC44Nmw0NSwzOC44M0w1MywyMTEuNzVhMTYuMzgsMTYuMzgsMCwwLDAsMjQuNSwxNy44MkwxMjgsMTk4LjQ5bDUwLjUzLDMxLjA4QTE2LjQsMTYuNCwwLDAsMCwyMDMsMjExLjc1bC0xMy43Ni01OC4wNyw0NS0zOC44M0ExNi40MywxNi40MywwLDAsMCwyMzkuMTgsOTcuMjZabS0xNS4zNCw1LjQ3LTQ4LjcsNDJhOCw4LDAsMCwwLTIuNTYsNy45MWwxNC44OCw2Mi44YS4zNy4zNywwLDAsMS0uMTcuNDhjLS4xOC4xNC0uMjMuMTEtLjM4LDBsLTU0LjcyLTMzLjY1YTgsOCwwLDAsMC04LjM4LDBMNjkuMDksMjE1Ljk0Yy0uMTUuMDktLjE5LjEyLS4zOCwwYS4zNy4zNywwLDAsMS0uMTctLjQ4bDE0Ljg4LTYyLjhhOCw4LDAsMCwwLTIuNTYtNy45MWwtNDguNy00MmMtLjEyLS4xLS4yMy0uMTktLjEzLS41cy4xOC0uMjcuMzMtLjI5bDYzLjkyLTUuMTZBOCw4LDAsMCwwLDEwMyw5MS44NmwyNC42Mi01OS42MWMuMDgtLjE3LjExLS4yNS4zNS0uMjVzLjI3LjA4LjM1LjI1TDE1Myw5MS44NmE4LDgsMCwwLDAsNi43NSw0LjkybDYzLjkyLDUuMTZjLjE1LDAsLjI0LDAsLjMzLjI5UzIyNCwxMDIuNjMsMjIzLjg0LDEwMi43M1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=66800B)](https://github.com/openchamber/openchamber/stargazers) -[![GitHub release](https://img.shields.io/github/v/release/openchamber/openchamber?style=flat&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0iI2YxZWNlYyIgdmlld0JveD0iMCAwIDI1NiAyNTYiPjxwYXRoIGQ9Ik0xMjgsMTI5LjA5VjIzMmE4LDgsMCwwLDEtMy44NC0xbC04OC00OC4xOGE4LDgsMCwwLDEtNC4xNi03VjgwLjE4YTgsOCwwLDAsMSwuNy0zLjI1WiIgb3BhY2l0eT0iMC4yIj48L3BhdGg%2BPHBhdGggZD0iTTIyMy42OCw2Ni4xNSwxMzUuNjgsMThhMTUuODgsMTUuODgsMCwwLDAtMTUuMzYsMGwtODgsNDguMTdhMTYsMTYsMCwwLDAtOC4zMiwxNHY5NS42NGExNiwxNiwwLDAsMCw4LjMyLDE0bDg4LDQ4LjE3YTE1Ljg4LDE1Ljg4LDAsMCwwLDE1LjM2LDBsODgtNDguMTdhMTYsMTYsMCwwLDAsOC4zMi0xNFY4MC4xOEExNiwxNiwwLDAsMCwyMjMuNjgsNjYuMTVaTTEyOCwzMmw4MC4zNCw0NC0yOS43NywxNi4zLTgwLjM1LTQ0Wk0xMjgsMTIwLDQ3LjY2LDc2bDMzLjktMTguNTYsODAuMzQsNDRaTTQwLDkwbDgwLDQzLjc4djg1Ljc5TDQwLDE3NS44MlptMTc2LDg1Ljc4aDBsLTgwLDQzLjc5VjEzMy44MmwzMi0xNy41MVYxNTJhOCw4LDAsMCwwLDE2LDBWMTA3LjU1TDIxNiw5MHY4NS43N1oiPjwvcGF0aD48L3N2Zz4%3D&logoColor=FFFCF0&labelColor=100F0F&color=205EA6)](https://github.com/openchamber/openchamber/releases/latest) -[![Created with OpenCode](docs/references/badges/created-with-opencode.svg)](https://opencode.ai) +[![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) -## **OpenCode, everywhere.** Desktop. Browser. Phone. +## Run agent work. Keep control. Ship from anywhere. -### A rich interface for [OpenCode](https://opencode.ai). Review diffs, manage agents, run dev servers, and keep the big picture while your AI codes. +**OpenChamber is an open-source workspace for running, supervising, and reviewing AI coding work across desktop, browser, editor, and mobile.** + +OpenChamber gives you one place to direct agent work, understand the changes, and move them toward release. Your projects stay available when you switch devices or step away. ![OpenChamber Chat](docs/references/chat_example.png)
More screenshots -![Tool Output](docs/references/tool_output_example.png) -![Settings](docs/references/settings_example.png) -![Diff View](docs/references/diff_example.png) ![VS Code Extension](packages/vscode/extension.jpg)

-PWA Chat -PWA Diff +OpenChamber PWA chat +OpenChamber PWA diff review

-## Why use OpenChamber? +## What you can do with OpenChamber -- **Cross-device continuity**: Start in TUI, continue on tablet/phone, return to terminal - same session -- **Remote access**: Use OpenCode from anywhere via browser -- **Familiarity**: A visual alternative for developers who prefer GUI workflows +### Goals that continue on their own -## Features +Give a session a finish line with **Session Goals**. OpenChamber checks the result after every turn and keeps the agent working until the goal is complete, blocked, or reaches the limit you set — even after you close the app. -### Core (all app versions) +### Compare and combine runs -- Branchable chat timeline with `/undo`, `/redo`, and one-click forks from earlier turns -- Smart tool UIs for diffs, file operations, permissions, and long-running task progress -- Voice mode with speech input and read-aloud responses for hands-free workflows -- Multi-agent runs from one prompt with isolated worktrees for safe side-by-side comparisons -- Git workflows in-app: identities, commits, PR creation, checks, and merge actions -- GitHub-native workflows: start sessions from issues and pull requests with context already attached -- Plan/Build mode with a dedicated plan view for drafting and iterating implementation steps -- Inline comment drafts on diffs, files, and plans that can be sent back to the agent -- Context visibility tools (token/cost breakdowns, raw message inspection, and activity summaries) -- Integrated terminal with per-directory sessions and stable performance on heavy output -- Built-in skills catalog and local skill management for reusable automation workflows +Use **Multi-run** to give the same task to up to five models, each in its own session and optionally its own worktree. See what each one actually built, choose the best result, or use **Fusion** to combine the strongest parts into a new session. -### Web / PWA +### Guided changes walkthroughs -- Provider-aware tunnel access model with Cloudflare `quick`, `managed-remote`, and `managed-local` modes -- One-scan onboarding with tunnel QR + password URL helpers -- Mobile-first experience: optimized chat controls, keyboard-safe layouts, and attachment-friendly UI -- Background notifications plus reliable cross-tab session activity tracking -- Built-in self-update + restart flow that keeps your server settings intact +**Changes Walkthrough** turns a large diff into an AI-guided tour of the change. It groups related edits into steps, puts them in the order the change makes sense, and explains how the pieces fit together. -### Desktop (macOS + Windows + Linux) +### Inspect a running app -- Floating Mini Chat: keep a small always-on-top assistant beside your editor, browser, or terminal -- Multiple native windows for separate projects or sessions -- Native notifications for task alerts while OpenChamber is hidden -- One-click open in VS Code, Cursor, Terminal, Finder, Explorer, and more -- Desktop host switcher for local and remote OpenChamber instances -- Convenient tunnel management without manual setup -- Deep-link connections for joining remote OpenChamber from a link -- SSH remote access with host import, connection management, and port forwarding +Open your app beside the conversation with **Preview**. Point at an element and send the agent its screenshot, styles, position, and browser errors — all the context behind “this thing here.” Desktop brings the same workflow to any web page through its built-in browser. -### VS Code Extension +### GitHub context from issue to pull request -- Editor-native workflow: open files directly from tool output and keep sessions beside your code -- Agent Manager for parallel multi-model runs from a single prompt -- Right-click actions to add context, explain selections, and improve code in-place -- In-extension settings, responsive layout, and theme mapping that matches your editor -- Hardened runtime lifecycle and health checks for faster startup and fewer stuck reconnect states +Start a session from a GitHub issue or pull request with its context attached. Send failed checks or review comments back to the agent, then update or merge the pull request from OpenChamber. -### Custom Themes +### Continue on another device -- **Use it from anywhere** - Cloudflare tunnel with QR code onboarding. Scan, connect, code from your couch. -- **Branchable chat timeline** - Undo, redo, fork from any turn. Explore different approaches without losing your place. -- **GitHub-native workflows** - Start sessions from issues and PRs with context already attached. Review checks, merge - all in-app. -- **Project Actions** - Run dev servers, configure SSH port forwarding, open remote URLs locally. Your project commands, one click away. -- **Connect to remote machines** - Desktop app connects to remote OpenChamber instances over SSH, with dedicated lifecycle and UX flows. +Open the same projects and sessions from Desktop, Web/PWA, VS Code, iOS, or Android. Check progress, answer questions, review changes, and reattach to a running terminal. -## Quick Start +### Private remote access -> **Prerequisite:** Desktop bundles the matching OpenCode CLI. CLI/Web and VS Code use your installed [OpenCode CLI](https://opencode.ai). +Pair a device with a one-time QR code and connect through **Private Relay** without opening ports or exposing a public server. The connection is end-to-end encrypted and can be revoked at any time. Direct connections, LAN/VPN access, Cloudflare/Ngrok tunnels, and SSH are also supported. -### **Desktop (macOS + Windows + Linux)** +### Track work across projects -Download the latest Desktop release from [GitHub Releases](https://github.com/openchamber/openchamber/releases). +See which sessions are working, waiting, finished, or failed, along with approvals, scheduled tasks, provider limits, token use, and costs. Organize sessions into folders and keep notes, todos, and reusable project actions nearby. -On Linux, choose the AppImage for your system: +### Schedule recurring work -- `linux-x86_64.AppImage` for 64-bit Intel or AMD systems -- `linux-arm64.AppImage` for ARM64/aarch64 systems +Run a prompt once, daily, weekly, or on a cron schedule. Scheduled tasks can use Session Goals, so they continue toward an outcome instead of stopping after one response. -Make the AppImage executable before launching it, for example with `chmod +x `. Keep the AppImage in a location your user can write to so OpenChamber can download and apply in-app updates. +## Use it where you work -Linux AppImages need FUSE (`libfuse.so.2`). On Ubuntu/Debian install `libfuse2` (or `fuse` / `libfuse2t64` on newer releases). If FUSE is unavailable, run with extraction instead: +| Surface | Role | +| --- | --- | +| **Desktop** | The complete workspace for macOS, Windows, and Linux, with multiple windows, Mini Chat, remote machines, SSH, and native notifications | +| **Web / PWA** | Open your workspace in a browser, install it as an app, and stay up to date through background notifications | +| **VS Code** | Keep sessions beside your code, send selections to the agent, open results in the editor, and compare parallel runs | +| **iOS / Android** | Review and steer work away from your desk, receive completion alerts, and use the terminal with touch controls | +| **CLI / Server** | Run OpenChamber on a workstation or server, schedule work, manage remote access, and keep it available after login | + +## Quick start + +### Desktop — macOS, Windows, and Linux + +Download the latest release from [GitHub Releases](https://github.com/openchamber/openchamber/releases/latest). Desktop bundles the matching OpenCode CLI, so no separate OpenCode installation is required. + +Linux releases are available as x86_64 and ARM64 AppImages. Make the downloaded AppImage executable and keep it in a writable location for in-app updates: ```bash -APPIMAGE_EXTRACT_AND_RUN=1 ./OpenChamber-*-linux-*.AppImage +chmod +x OpenChamber-*.AppImage +./OpenChamber-*.AppImage ``` -Linux Desktop ships as AppImage with in-app window controls and auto-update when running from a writable AppImage. +Linux AppImages require FUSE (`libfuse.so.2`). Without FUSE, run with `APPIMAGE_EXTRACT_AND_RUN=1`. -### **VS Code** -Install from [Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber) or search "OpenChamber" in Extensions. +### VS Code -### **CLI (Web + PWA)** -_requires Node.js 22+_ +Install [OpenChamber from the Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber), or search for “OpenChamber” in Extensions. + +### CLI — Web and PWA + +Requires Node.js 22+. CLI/Web and VS Code use your installed [OpenCode CLI](https://opencode.ai). ```bash curl -fsSL https://raw.githubusercontent.com/openchamber/openchamber/main/scripts/install.sh | bash openchamber --ui-password be-creative-here ``` -
-Advanced CLI options +Common operations: ```bash -openchamber --port 8080 # Custom port -openchamber --lan --port 3000 # Listen on LAN (0.0.0.0) -openchamber --ui-password secret # Password-protect UI -openchamber startup enable # Start at login as a native service -OPENCHAMBER_UI_PASSWORD=secret openchamber startup enable # Save service password env -openchamber startup status # Show startup service status -openchamber startup disable # Remove startup service -openchamber tunnel help # Tunnel lifecycle commands -openchamber tunnel providers # Show provider capabilities -openchamber tunnel profile add --provider cloudflare --mode managed-remote --name prod-main --hostname app.example.com --token -openchamber tunnel start --profile prod-main +openchamber status +openchamber connect-url --qr openchamber tunnel start --provider cloudflare --mode quick --qr -openchamber tunnel start --provider cloudflare --mode managed-local --config ~/.cloudflared/config.yml -openchamber tunnel status --all # Show tunnel state across instances -openchamber tunnel stop --port 3000 # Stop tunnel only (server stays running) -openchamber connect-url --port 3000 # Add this server to OpenChamber Desktop -openchamber connect-url --server http://host:3000 --qr -openchamber connect-url --port 3000 --qr -openchamber logs # Follow latest instance logs -OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber # Connect to external OpenCode server -OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber # Connect via custom host/HTTPS -openchamber stop # Stop server -openchamber update # Update to latest +openchamber startup enable +openchamber logs +openchamber stop +openchamber update ``` -`startup enable` snapshots your current environment into the native service so startup behaves like you launched `openchamber` from the same shell. This preserves provider tokens, PATH, SSH agent settings, and other CLI auth/config env vars. Use `--no-env-snapshot` if you want a minimal service env. +OpenChamber binds to localhost by default. Use `--lan` only on a trusted network and protect browser access with `--ui-password`. -Connect to an existing OpenCode server: -```bash -OPENCODE_PORT=4096 OPENCODE_SKIP_START=true openchamber -OPENCODE_HOST=https://myhost:4096 OPENCODE_SKIP_START=true openchamber -``` +## Guides -Bind managed OpenCode server to all interfaces (use only on trusted networks): -```bash -OPENCHAMBER_OPENCODE_HOSTNAME=0.0.0.0 openchamber --port 3000 -``` +Go deeper with the OpenChamber guides: -Expose OpenChamber itself on your LAN: -```bash -openchamber --lan --port 3000 --ui-password secret -``` +- [Quick start](packages/docs/content/docs/quickstart.mdx) +- [Installation](packages/docs/content/docs/install.mdx) +- [Connect devices](packages/docs/content/docs/connect-devices.mdx) +- [Private Relay](packages/docs/content/docs/private-relay.mdx) +- [Multi-run](packages/docs/content/docs/multi-run.mdx) +- [Session Goals](packages/docs/content/docs/session-goals.mdx) +- [Changes Walkthrough](packages/docs/content/docs/walkthrough.mdx) +- [Preview and dev servers](packages/docs/content/docs/preview.mdx) +- [GitHub workflows](packages/docs/content/docs/github.mdx) +- [Mobile](packages/docs/content/docs/mobile.mdx) +- [Security](packages/docs/content/docs/security.mdx) +- [Troubleshooting](packages/docs/content/docs/troubleshooting.mdx) -Add this server to OpenChamber Desktop or another OpenChamber app: -```bash -openchamber connect-url --port 3000 --qr -``` +For self-hosting details, see the [reverse proxy guide](docs/REVERSE_PROXY.md). For custom theme authoring, see the [custom themes guide](docs/CUSTOM_THEMES.md). -If no OpenChamber server is running on that port, `connect-url` starts one before generating the link. +## Why OpenCode? -Headless/API-only setup for a remote machine: -```bash -openchamber connect-url --port 3000 --api-only --lan --server http://your-host-or-ip:3000 --qr --ui-password secret -``` +OpenChamber uses [OpenCode](https://opencode.ai) to power its coding agents. We chose it because we believe it provides the best open-source agentic coding experience today: capable, extensible, and open by design. -This runs OpenChamber as an API-only server without the desktop app or browser UI assets on that machine, then creates a link for Desktop to import. `--lan` makes the server reachable from other machines. `--server` is the address Desktop should use. +Around that foundation, OpenChamber brings together the work that happens before, during, and after an agent run — deciding what to try, keeping it on track, reviewing the result, connecting from anywhere, and getting the change shipped. -When OpenChamber was started with `--lan` or `--host 0.0.0.0`, `connect-url` automatically uses a detected LAN IP instead of `127.0.0.1`. Use `--server http://host:3000` to override the advertised address, and include `--lan` when `connect-url` needs to start the server for LAN access. - -Paste the printed `openchamber://connect?...` link in Desktop under Settings -> Remote Instances -> Direct Instances -> Import Link. The link contains the server URL and a client token. It does not enable browser UI password protection; use `--ui-password` when exposing a server beyond localhost. - -
- -
-systemd service (VPN / LAN access) - -Run OpenChamber and OpenCode as separate persistent services — useful when you want to access your -dev machine over a VPN (e.g. Tailscale) or LAN without a Cloudflare tunnel. - -**How it works:** -- OpenCode runs as its own service, binding only to `localhost`. -- OpenChamber connects to it via `OPENCODE_HOST` and `--lan` makes it reachable on your VPN IP. -- `--foreground` keeps the CLI process alive so systemd can track and restart it. - -**`~/.config/systemd/user/opencode.service`** -```ini -[Unit] -Description=OpenCode Server - -[Service] -Type=simple -ExecStart=opencode serve --port 4095 -Environment="PATH=/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/YOU/.local/bin:/home/YOU/.npm-global/bin:/usr/local/bin:/usr/bin:/bin" -Environment=SSH_AUTH_SOCK=%t/ssh-agent.socket -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=default.target -``` - -> **Why set `PATH` and `SSH_AUTH_SOCK`?** -> systemd user services start with a minimal environment — no shell profile is sourced. -> Without an explicit `PATH`, OpenCode won't find tools installed via Homebrew, npm, or `~/.local/bin`. -> Without `SSH_AUTH_SOCK`, git operations over SSH (push, pull, clone) will fail because the agent socket isn't inherited. -> Adjust the `PATH` to match your own tool installation paths. -> `%t` expands to `$XDG_RUNTIME_DIR` (e.g. `/run/user/1000`), where most SSH agents write their socket. - -**`~/.config/systemd/user/openchamber.service`** -```ini -[Unit] -Description=OpenChamber Web Server -After=opencode.service - -[Service] -Type=simple -ExecStart=openchamber serve --port 3000 --host 0.0.0.0 --ui-password your-password --foreground -Environment="OPENCODE_HOST=http://localhost:4095" -Environment="OPENCODE_SKIP_START=true" -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=default.target -``` - -```bash -systemctl --user daemon-reload -systemctl --user enable --now opencode openchamber -``` - -OpenChamber will be reachable at `http://:3000` from any device on your VPN. - -> **Note:** `--host 0.0.0.0` is required to listen on all interfaces. The default -> bind address is `127.0.0.1` (localhost only). Use `--host ` or -> `OPENCHAMBER_HOST=` to bind to a specific interface instead. - -
- -
-Docker - -```bash -docker compose up -d -``` - -Available at `http://localhost:3000`. - -**UI Password:** -```yaml -environment: - UI_PASSWORD: your_secure_password -``` - -**Cloudflare Tunnel (optional):** -```yaml -environment: - OPENCHAMBER_TUNNEL_MODE: quick # quick | managed-remote | managed-local - OPENCHAMBER_TUNNEL_PROVIDER: cloudflare -``` - -For `managed-remote` mode, provide: - -```yaml -environment: - OPENCHAMBER_TUNNEL_MODE: managed-remote - OPENCHAMBER_TUNNEL_HOSTNAME: app.example.com - OPENCHAMBER_TUNNEL_TOKEN: -``` - -For `managed-local` mode, optionally provide: - -```yaml -environment: - OPENCHAMBER_TUNNEL_MODE: managed-local - OPENCHAMBER_TUNNEL_CONFIG: /home/openchamber/.cloudflared/config.yml -``` - -Managed-local path note: `OPENCHAMBER_TUNNEL_CONFIG` must point to a path inside the container user home (`/home/openchamber/...`). If your Cloudflare config references a credentials JSON file, that file path must also be accessible inside the container (mount with `volumes`). - -### Reverse proxy notes - -- For a complete reverse proxy setup guide, see [`docs/REVERSE_PROXY.md`](./docs/REVERSE_PROXY.md). -- Website docs source lives at `packages/docs/content/docs/reverse-proxy.mdx`. - -### Tunnel behavior notes - -- OpenChamber supports one active tunnel per running instance (port). -- Starting a tunnel with a different mode/provider on the same instance replaces the current tunnel. -- Replacing or stopping a tunnel revokes existing connect links and invalidates remote tunnel sessions for that instance. -- Connect links are one-time tokens; generating a new link revokes the previous unused link. - -**Data Directory Permission Note:** The `data/` directory is mounted into the container for persistent storage (config, sessions, SSH keys, workspaces). Before running, ensure the directory exists and has proper permissions: - -```bash -mkdir -p data/openchamber data/opencode/share data/opencode/config data/ssh -chown -R 1000:1000 data/ -``` - -**SSH/Git:** If git push/pull fails, run `ssh -T git@github.com` in terminal. - -
- - -## Features - -
-Chat & Interaction - -- Branchable chat timeline with `/undo`, `/redo`, and one-click forks from any turn -- Multi-agent runs from one prompt with isolated worktrees for safe side-by-side comparisons -- Voice mode with speech input and read-aloud responses for hands-free workflows -- Plan/Build mode with a dedicated plan view for drafting and iterating steps -- Inline comment drafts on diffs, files, and plans - send feedback back to the agent -- Shell mode via leading `!` with inline output -- Share messages as images -- Mermaid diagrams render inline with copy/download actions -- Smart tool UIs for diffs, file operations, permissions, and task progress - -
- -
-Git & GitHub - -- Full Git sidebar with staging, commits, push/pull, branch management, and rebase/merge flows -- PR creation with AI-generated descriptions, status checks, and merge actions -- Start sessions from GitHub issues and pull requests with context baked in -- Multi-remote push and fork-aware PR creation -- Worktree integration: isolated sessions per branch, merge back with conflict handling -- Git identities, gitmoji support, and multi-account GitHub auth - -
- -
-Files, Diff & Terminal - -- Workspace file browser with inline editing, syntax highlighting, Vim mode, and markdown preview -- Beautiful diff viewer with stacked/inline modes, lazy loading for large changesets -- Integrated terminal with per-directory sessions, tabbed interface, and stable heavy-output performance -- Clickable file paths in messages - jump to exact line locations -- File-type icons across all views for faster visual scanning - -
- -
-Web / PWA - -- Cloudflare tunnel with quick, managed-remote, and managed-local modes, secure one-time connect links, and QR onboarding -- Mobile-first: optimized chat controls, keyboard-safe layouts, drag-to-reorder projects -- Background notifications and cross-tab session tracking -- Self-update + restart flow that keeps your server settings intact -- Installable as PWA with project-aware naming - -
- -
-Desktop (macOS + Windows + Linux) - -- Floating Mini Chat: keep a small always-on-top assistant beside your editor, browser, or terminal -- Multiple native windows for separate projects or sessions -- Native notifications for task alerts while OpenChamber is hidden -- One-click open in VS Code, Cursor, Terminal, Finder, Explorer, and more -- Desktop host switcher for local and remote OpenChamber instances -- Convenient tunnel management without manual setup -- Deep-link connections for joining remote OpenChamber from a link -- SSH remote access with host import, connection management, and port forwarding - -
- -
-VS Code Extension - -- Editor-native: open files from tool output, keep sessions beside your code -- Agent Manager for parallel multi-model runs from a single prompt -- Right-click actions: add context, explain selections, improve code in-place -- Session editor panel, responsive layout, and theme mapping to your editor -- Edit-style tool results open directly in focused diff views - -
- -
-Customization - -- 18+ built-in themes with light/dark variants -- Custom themes via JSON files in `~/.config/openchamber/themes/` - hot reload, no restart -- Configurable keyboard shortcuts for chat, panels, and services -- Font size, spacing, corner radius, and layout controls -- Customizable project icons with upload and automatic favicon discovery -- Skills catalog and local skill management for reusable automation - -[Read the Guide: Custom Themes](docs/CUSTOM_THEMES.md) - -
- -
-Context & Productivity - -- Token usage, cost breakdowns, and raw message inspection panel -- Usage quota tracking across multiple providers with pace/prediction indicators -- Favorite model cycling via keyboard shortcuts -- Session folders and subfolders with drag-to-reorder -- Persistent project notes and todos per project -- Draft persistence per session with expanded focus mode for longer prompts - -
- -## Roadmap - -Active development. Here's what's being worked on or planned: - -- Mobile app with remote instance and laptop connectivity -- More built-in tunneling options -- Kanban board for multi-agent management - keeping the human in the loop and in control -- Custom OpenCode plugins/tools built-in catalog -- Linear integration -- Built-in browser for running dev apps with agent integration - -## Acknowledgments - -Independent project, not affiliated with the OpenCode team. - -**Special thanks to:** - -- [OpenCode](https://opencode.ai) - For the excellent API and extensible architecture. -- [Flexoki](https://github.com/kepano/flexoki) - Beautiful color scheme by [Steph Ango](https://stephango.com/flexoki). -- [Pierre](https://pierrejs-docs.vercel.app/) - Fast, beautiful diff viewer with syntax highlighting. -- [Ghostty-web](https://github.com/coder/ghostty-web) - Great implementation of a Ghostty web renderer. -- [David Hill](https://x.com/iamdavidhill) - Who inspired me to release this without [overthinking](https://x.com/iamdavidhill/status/1993648326450020746). -- [My wife](https://github.com/yulia-ivashko), who - with zero AI background - sat down with the app for the first time and built the firework celebration that plays on every successful push. -- Every contributor who shaped this project with their PRs, ideas, and attention to detail. +OpenChamber is an independent project and is not affiliated with the OpenCode team. ## Contributing -See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and guidelines. +See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and contribution guidelines. Documentation authoring guidance lives in [`packages/docs`](packages/docs/README.md). -Docs source lives in [`packages/docs`](packages/docs/README.md). +## Acknowledgments + +Special thanks to: + +- [OpenCode](https://opencode.ai) for its excellent API and extensible open-source architecture +- [Pierre](https://pierrejs-docs.vercel.app/) for its fast diff viewer and syntax highlighting +- [Ghostty-web](https://github.com/coder/ghostty-web) for its Ghostty web renderer +- [Yulia Ivashko](https://github.com/yulia-ivashko), who built the firework celebration that plays on every successful push +- Every contributor who shaped OpenChamber with code, ideas, and attention to detail ## License diff --git a/bun.lock b/bun.lock index 070da26b..15b89095 100644 --- a/bun.lock +++ b/bun.lock @@ -40,7 +40,6 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", - "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.8", "bun-pty": "^0.4.5", @@ -55,9 +54,8 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.6", "remark-gfm": "^4.0.1", - "simple-git": "^3.28.0", + "simple-git": "^3.36.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "yaml": "^2.8.1", @@ -202,7 +200,7 @@ "remark-math": "^6.0.0", "remend": "^1.2.1", "shiki": "^3.23.0", - "simple-git": "^3.28.0", + "simple-git": "^3.36.0", "sonner": "^2.0.7", "strip-json-comments": "^5.0.3", "tailwind-merge": "^3.3.1", @@ -286,7 +284,7 @@ "qrcode-terminal": "^0.12.0", "reflect-metadata": "^0.2.2", "sherpa-onnx-node": "1.12.28", - "simple-git": "^3.28.0", + "simple-git": "^3.36.0", "web-push": "^3.6.7", "ws": "^8.18.3", "yaml": "^2.8.1", @@ -1260,6 +1258,10 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@simple-git/args-pathspec": ["@simple-git/args-pathspec@1.0.3", "", {}, "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA=="], + + "@simple-git/argv-parser": ["@simple-git/argv-parser@1.1.1", "", { "dependencies": { "@simple-git/args-pathspec": "^1.0.3" } }, "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw=="], + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="], "@simplewebauthn/server": ["@simplewebauthn/server@13.3.1", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-GV/oM/qeycWn8p42JZIMJBsXWQcNFg+nJFzeQTnMA4gN8mXg0+HZFWJerHg8ZN/zlveMS3iV1wzuFpOVWS/46w=="], @@ -1384,8 +1386,6 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], - "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], @@ -1654,13 +1654,13 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "character-entities": ["character-entities@1.2.4", "", {}, "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], - "character-entities-legacy": ["character-entities-legacy@1.1.4", "", {}, "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA=="], + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "character-reference-invalid": ["character-reference-invalid@1.1.4", "", {}, "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg=="], + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], @@ -1996,8 +1996,6 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -2034,8 +2032,6 @@ "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], - "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], - "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], @@ -2136,7 +2132,7 @@ "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], - "hast-util-parse-selector": ["hast-util-parse-selector@2.2.5", "", {}, "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ=="], + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], @@ -2146,14 +2142,10 @@ "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], - "hastscript": ["hastscript@6.0.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^1.0.0", "hast-util-parse-selector": "^2.0.0", "property-information": "^5.0.0", "space-separated-tokens": "^1.0.0" } }, "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w=="], + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "heic2any": ["heic2any@0.0.4", "", {}, "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA=="], - "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], - - "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], - "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], "html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="], @@ -2222,9 +2214,9 @@ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "is-alphabetical": ["is-alphabetical@1.0.4", "", {}, "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - "is-alphanumerical": ["is-alphanumerical@1.0.4", "", { "dependencies": { "is-alphabetical": "^1.0.0", "is-decimal": "^1.0.0" } }, "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A=="], + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], @@ -2246,7 +2238,7 @@ "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], - "is-decimal": ["is-decimal@1.0.4", "", {}, "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="], + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], @@ -2260,7 +2252,7 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "is-hexadecimal": ["is-hexadecimal@1.0.4", "", {}, "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], @@ -2446,8 +2438,6 @@ "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], - "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], @@ -2714,7 +2704,7 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-entities": ["parse-entities@2.0.0", "", { "dependencies": { "character-entities": "^1.0.0", "character-entities-legacy": "^1.0.0", "character-reference-invalid": "^1.0.0", "is-alphanumerical": "^1.0.0", "is-decimal": "^1.0.0", "is-hexadecimal": "^1.0.0" } }, "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse-json": ["parse-json@8.3.0", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "index-to-position": "^1.1.0", "type-fest": "^4.39.1" } }, "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ=="], @@ -2776,8 +2766,6 @@ "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], - "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], - "proc-log": ["proc-log@2.0.1", "", {}, "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -2846,8 +2834,6 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - "react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="], - "read": ["read@1.0.7", "", { "dependencies": { "mute-stream": "~0.0.4" } }, "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ=="], "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], @@ -2862,8 +2848,6 @@ "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - "refractor": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="], - "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], @@ -3020,7 +3004,7 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], - "simple-git": ["simple-git@3.32.3", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-56a5oxFdWlsGygOXHWrG+xjj5w9ZIt2uQbzqiIGdR/6i5iococ7WQ/bNPzWxCJdEUGUCmyMH0t9zMpRJTaKxmw=="], + "simple-git": ["simple-git@3.36.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "@simple-git/args-pathspec": "^1.0.3", "@simple-git/argv-parser": "^1.1.0", "debug": "^4.4.0" } }, "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q=="], "simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="], @@ -3408,8 +3392,6 @@ "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], - "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -3606,8 +3588,6 @@ "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - "dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -3642,18 +3622,6 @@ "globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], - "hast-util-from-dom/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - - "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - - "hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="], - - "hastscript/comma-separated-tokens": ["comma-separated-tokens@1.0.8", "", {}, "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw=="], - - "hastscript/property-information": ["property-information@5.6.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA=="], - - "hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="], - "iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="], "iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="], @@ -3676,8 +3644,6 @@ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - "micromark-extension-math/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -3714,6 +3680,8 @@ "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse-json/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "parse-semver/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], @@ -3736,16 +3704,12 @@ "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], - "react-syntax-highlighter/@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], - "read-pkg/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "read-pkg/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "refractor/prismjs": ["prismjs@1.27.0", "", {}, "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA=="], - "rehype-katex/katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], "rimraf/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], @@ -3768,8 +3732,6 @@ "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "superagent/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], "supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -3886,30 +3848,12 @@ "glob/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], - "hast-util-from-dom/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], - - "hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], - - "hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], "make-fetch-happen/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "mdast-util-mdx-jsx/parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - - "mdast-util-mdx-jsx/parse-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - - "mdast-util-mdx-jsx/parse-entities/character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - - "mdast-util-mdx-jsx/parse-entities/is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], - - "mdast-util-mdx-jsx/parse-entities/is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], - - "mdast-util-mdx-jsx/parse-entities/is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], - "micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "node-gyp/make-fetch-happen/cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="], @@ -4090,8 +4034,6 @@ "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - "mdast-util-mdx-jsx/parse-entities/is-alphanumerical/is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - "node-gyp/make-fetch-happen/cacache/@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="], "node-gyp/make-fetch-happen/cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], diff --git a/docs/references/diff_example.png b/docs/references/diff_example.png deleted file mode 100644 index 97b51f1f..00000000 Binary files a/docs/references/diff_example.png and /dev/null differ diff --git a/docs/references/settings_example.png b/docs/references/settings_example.png deleted file mode 100644 index 9e7fb55f..00000000 Binary files a/docs/references/settings_example.png and /dev/null differ diff --git a/docs/references/tool_output_example.png b/docs/references/tool_output_example.png deleted file mode 100644 index ca17726f..00000000 Binary files a/docs/references/tool_output_example.png and /dev/null differ diff --git a/knip.json b/knip.json index beb1972a..f2e4c823 100644 --- a/knip.json +++ b/knip.json @@ -1,8 +1,13 @@ { "$schema": "https://unpkg.com/knip@latest/schema.json", + "ignore": [ + "packages/mobile/android/app/src/main/assets/public/**", + "packages/mobile/ios/App/App/public/**" + ], "workspaces": { ".": { "entry": [ + "postcss.config.js", "scripts/**/*.{js,cjs,mjs,ts}" ], "project": [ @@ -12,6 +17,8 @@ }, "packages/ui": { "entry": [ + "src/apps/renderVSCodeApp.tsx", + "src/**/*.bench.{ts,tsx}", "src/**/*.{test,spec}.{js,cjs,mjs,jsx,ts,tsx}", "src/**/__tests__/**/*.{js,cjs,mjs,jsx,ts,tsx}" ], @@ -23,7 +30,9 @@ "entry": [ "src/mobile-main.tsx", "src/mini-chat-main.tsx", + "src/main.tsx", "src/sw.ts", + "bin/**/*.{test,spec}.{js,cjs,mjs}", "server/**/*.{test,spec}.{js,cjs,mjs}", "src/**/*.{test,spec}.{ts,tsx}" ], @@ -50,6 +59,7 @@ }, "packages/vscode": { "entry": [ + "webview/main.tsx", "src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}", "webview/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}" ], @@ -57,6 +67,15 @@ "src/**/*.{ts,tsx}", "webview/**/*.{ts,tsx}" ] + }, + "packages/mobile": { + "entry": [ + "scripts/**/*.{js,cjs,mjs,ts}" + ], + "project": [ + "*.{js,cjs,mjs,ts}", + "scripts/**/*.{js,cjs,mjs,ts}" + ] } } } diff --git a/package.json b/package.json index 31f9c141..3561555d 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,6 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", - "@types/react-syntax-highlighter": "^15.5.13", "@xenova/transformers": "^2.17.2", "@zumer/snapdom": "^2.12.8", "bun-pty": "^0.4.5", @@ -134,9 +133,8 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^15.6.6", "remark-gfm": "^4.0.1", - "simple-git": "^3.28.0", + "simple-git": "^3.36.0", "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "yaml": "^2.8.1", diff --git a/packages/docs/CONTRIBUTING.md b/packages/docs/CONTRIBUTING.md index 76d6efbf..133cc359 100644 --- a/packages/docs/CONTRIBUTING.md +++ b/packages/docs/CONTRIBUTING.md @@ -203,13 +203,14 @@ other language mirrors the English files under a locale folder. | Korean | `ko/` | `ko` | | Polish | `pl/` | `pl` | | French | `fr/` | `fr` | +| German | `de/` | `de` | | Japanese | `ja/` | `ja` | > [!IMPORTANT] > The **content folder** uses the lowercase locale key (`zh-cn`, `pt-br`); the > **sidebar `translations`** key uses the BCP-47 language tag (`zh-CN`, `pt-BR`). > They look similar but are not interchangeable — Starlight resolves them with -> different rules. Everything else (`uk`, `es`, `ko`, `pl`, `fr`, `ja`, `en`) is identical +> different rules. Everything else (`uk`, `es`, `ko`, `pl`, `fr`, `de`, `ja`, `en`) is identical > in both columns. This locale set is mirrored in the website at @@ -269,6 +270,7 @@ to each section and item in `sidebar.config.json`: "ko": "여기서 시작", "pl": "Zacznij tutaj", "fr": "Commencer ici", + "de": "Hier starten", "ja": "ここから開始" }, "items": [ @@ -283,6 +285,7 @@ to each section and item in `sidebar.config.json`: "ko": "설치", "pl": "Instalacja", "fr": "Installation", + "de": "Installation", "ja": "インストール" } } diff --git a/packages/docs/content/docs/de/agent-control-tool.mdx b/packages/docs/content/docs/de/agent-control-tool.mdx new file mode 100644 index 00000000..c3b8dec6 --- /dev/null +++ b/packages/docs/content/docs/de/agent-control-tool.mdx @@ -0,0 +1,39 @@ +--- +title: Agent-Steuerungswerkzeug +description: Lass einen Agenten OpenChamber-Sitzungen, Worktrees und geplante Aufgaben direkt aus dem Chat verwalten. +--- + +# Agent-Steuerungswerkzeug + +Verwende das Agentenwerkzeug `openchamber`, um Arbeit in der App direkt aus dem Chat zu verwalten. Es ist standardmäßig aktiviert, wenn OpenChamber seinen eigenen lokalen OpenCode-Server ausführt; du musst kein separates Werkzeug installieren und keinen Shell-Befehl ausführen. + +## Was du anfragen kannst + +Sprich einfach in natürlicher Sprache mit dem Agenten. Zum Beispiel: + +- "Erstelle in diesem Projekt eine neue OpenChamber-Sitzung, verwende das Modell `openai/gpt-5.6-sol` und sende ihr diese Aufforderung: überprüfe den Authentifizierungsfluss." +- "Erstelle für diese Aufgabe eine neue OpenChamber-Sitzung in einem separaten Worktree und bitte sie, Tests für den Login-Fluss hinzuzufügen." +- "Verwende OpenChamber, um meine 10 neuesten Sitzungen aufzulisten und ihren aktuellen Status anzuzeigen." +- "Erstelle eine geplante OpenChamber-Aufgabe mit dem Namen Weekday review, die diese Aufforderung werktags um 09:00 sendet: review changes since the last run." +- "Führe die geplante OpenChamber-Aufgabe mit dem Namen Weekday review jetzt aus." +- "Prüfe die OpenChamber-Sitzung mit dem Namen Authentication review und zeige mir die neueste Antwort des Assistenten." + +Das Werkzeug kann Projekte und Modelleinstellungen auflisten, Sitzungen erstellen und weiterführen, eine Sitzung forken, isolierte Worktree-Sitzungen erstellen und geplante Aufgaben verwalten. Auf diese Weise gestartete Sitzungen erscheinen in OpenChamber wie jede andere Sitzung auch, sodass du sie öffnen und die Arbeit selbst fortsetzen kannst. + +## Beachten + +- Neue Sitzungsaufforderungen kehren standardmäßig sofort zurück. Verfolge die Sitzung in OpenChamber oder bitte den Agenten, sie später noch einmal zu prüfen. +- Ein separater Worktree wird nur erstellt, wenn du ausdrücklich danach fragst. Nicht-committete Änderungen aus deinem aktuellen Worktree werden nicht dorthin kopiert. +- Das Werkzeug kann keine Sitzungen oder Worktrees löschen, keine Projektpfade registrieren, keine beliebigen Shell-Befehle ausführen und keine beliebigen URLs aufrufen. + +## Werkzeug ein- oder ausschalten + +Öffne **Einstellungen → Allgemein → OpenCode CLI**, ändere **Agent control tool** und wähle dann **Save + Reload**. Die Einstellung gilt, nachdem der verwaltete OpenCode-Server neu gestartet wurde. + +Das Werkzeug ist nicht verfügbar, wenn OpenChamber über `OPENCODE_HOST` oder skip-start mit einem externen OpenCode-Server verbunden ist oder innerhalb der VS-Code-Erweiterung läuft. Desktop- und Web-Installationen, die den verwalteten OpenCode-Server von OpenChamber verwenden, unterstützen es automatisch. + +## Verwandt + +- [Geplante Aufgaben](/scheduled-tasks/) +- [Worktree-Sitzungen](/worktrees/) +- [Sitzungsziele](/session-goals/) diff --git a/packages/docs/content/docs/de/commands-snippets.mdx b/packages/docs/content/docs/de/commands-snippets.mdx new file mode 100644 index 00000000..e4a7c68f --- /dev/null +++ b/packages/docs/content/docs/de/commands-snippets.mdx @@ -0,0 +1,39 @@ +--- +title: Befehle & Snippets +description: Wiederverwendbare Slash-Befehle und Text-Snippets für den Chat erstellen. +--- + +# Befehle & Snippets + +Sowohl Befehle als auch Snippets ersparen dir das wiederholte Tippen derselben Dinge. Befehle sind ganze Prompts, die du mit `/` aufrufst; Snippets sind Textbausteine, die du mit `#` in eine Nachricht einfügst. + +## Befehle + +Ein Befehl ist ein gespeicherter Prompt, den du mit einem Slash aufrufst, etwa `/review`. Du verwaltest ihn unter **Einstellungen → Befehle**. + +1. Öffne **Einstellungen → Befehle** und erstelle einen Befehl. +2. Gib ihm einen Namen, eine Beschreibung und den Prompt-Text, den er senden soll. +3. Optional kannst du ihn an einen bestimmten Agenten oder ein Modell binden. +4. Wähle den Geltungsbereich: persönlich oder projektbezogen. + +Gib im Chat als **erstes** Zeichen der Nachricht `/` ein, um Befehle anzuzeigen, und wähle dann einen aus. Dein Text kann Platzhalter verwenden: + +- `$ARGUMENTS` — alles, was du nach dem Befehl eingibst +- `@filename` — fügt den Inhalt einer Datei ein +- `` !`command` `` — fügt die Ausgabe eines Shell-Befehls ein + +Die eingebauten Befehle `init` und `review` können zurückgesetzt, aber nicht gelöscht werden. + +## Snippets + +Ein Snippet ist wiederverwendbarer Text, auf den du inline mit einer Raute verweist, zum Beispiel `#signoff`. Du verwaltest ihn unter **Einstellungen → Snippets**. + +1. Öffne **Einstellungen → Snippets** und erstelle ein Snippet. +2. Gib ihm einen Namen und den Text, für den es steht. Füge Aliase hinzu, wenn du mehr als einen Auslöser möchtest. +3. Wähle den persönlichen oder projektbezogenen Geltungsbereich. + +Gib im Chat `#` ein und wähle ein Snippet aus. OpenChamber ersetzt es vor dem Senden durch den vollständigen Text. + +## Weiterführend + +- [Skills](/skills/) — größere Anweisungssätze bei Bedarf laden diff --git a/packages/docs/content/docs/de/connect-devices.mdx b/packages/docs/content/docs/de/connect-devices.mdx new file mode 100644 index 00000000..e3d19233 --- /dev/null +++ b/packages/docs/content/docs/de/connect-devices.mdx @@ -0,0 +1,68 @@ +--- +title: Ein Gerät verbinden +description: Kopple dein Telefon, deinen Desktop oder einen anderen Browser mit deinem OpenChamber-Server über einen einmaligen QR-Code. +--- + +# Ein Gerät verbinden + +Kopple ein weiteres Gerät — die Mobile App, die Desktop-App oder einen Browser auf einem anderen Rechner — mit deinem OpenChamber-Server, indem du einen einmaligen QR-Code scannst. Das ist der empfohlene Weg, Geräte zu verbinden; du musst keine Ports öffnen und keine Adressen eintippen. + +## Gerät koppeln + +1. Öffne auf dem Rechner mit OpenChamber **Settings → Remote Instances → Connect to this server** und drücke **Add a device**. +2. Gib dem Gerät einen Namen (z. B. *Mein iPhone*), damit du es später wiedererkennst. +3. Wähle, wo du das Gerät nutzen wirst: + - **This computer only** — für Apps, die auf genau diesem Rechner laufen + - **Home network only** — verbindet sich direkt über dein WLAN; funktioniert außerhalb dieses Netzwerks nicht + - **Anywhere** — funktioniert zu Hause und unterwegs; Daten außerhalb deines Netzwerks laufen über das [Private Relay](/private-relay/), ein Ende-zu-Ende-verschlüsselter Tunnel ohne zusätzliche Einrichtung +4. Drücke **Create QR code**. +5. Scanne den Code auf dem anderen Gerät: + - **mobile app** — tippe auf dem Verbindungsbildschirm (oder in der Instanzliste) auf **Scan QR code** + - **desktop app** — kopiere stattdessen den Verbindungslink und füge ihn unter **Settings → Remote Instances → Other OpenChamber servers → Import Link** ein + +Der Dialog schließt sich von selbst, sobald das Gerät verbunden ist, und das Gerät erscheint mit Live-Status in der Liste. Das war’s — die Kopplung ist abgeschlossen. + +## So bleibt die Kopplung sicher + +- **Der QR-Code ist nur einmal verwendbar.** Er funktioniert in dem Moment nicht mehr, in dem ein Gerät ihn einlöst, und läuft von selbst ab, wenn er nie benutzt wird. +- **Jedes Gerät erhält sein eigenes Token.** Beim Scannen wird niemals dein UI-Passwort offengelegt, und das Token eines Geräts kann nicht verwendet werden, um sich als ein anderes auszugeben. +- **Du behältst die Kontrolle.** Jedes gekoppelte Gerät wird mit Name, Plattform und Verbindungsstatus angezeigt — du kannst jedes davon jederzeit widerrufen. +- **Verkehr von unterwegs ist Ende-zu-Ende verschlüsselt.** Bei **Anywhere** läuft Verkehr außerhalb deines Netzwerks über das [Private Relay](/private-relay/), das nicht lesen kann, was hindurchläuft. + +## Gekoppelte Geräte verwalten + +**Settings → Remote Instances → Connect to this server** listet jedes Gerät, das diesen Server erreichen kann, mit einem grünen Punkt, wenn es online ist, und zeigt an, ob es über das lokale Netzwerk oder das Relay verbunden ist. + +- **Revoke** trennt ein Gerät sofort. Kopple es mit einem neuen QR-Code erneut, wenn du es dir anders überlegst. +- **Clear revoked** räumt die Liste auf. + +Dasselbe physische Gerät bleibt nur in einem Eintrag, auch wenn es sich später erneut anmeldet — du sammelst keine Duplikate an. + +## Über die Kommandozeile verbinden + +Wenn der Server ohne UI läuft (also headless), erstelle einen Verbindungslink in einem Terminal auf diesem Rechner. + +Für ein Gerät im selben Netzwerk: + +```bash +openchamber connect-url --port 3000 --qr +``` + +Für ein Gerät, das sich von **überall** verbinden soll — das entspricht der Wahl von **Anywhere** im Dialog: + +```bash +openchamber connect-url --relay --qr +``` + +Ein `--relay`-Link enthält beide Routen, genau wie der Dialog: Das Gerät verbindet sich direkt über dein lokales Netzwerk, wenn es den Server erreichen kann, und fällt auf das [Private Relay](/private-relay/) zurück, wenn es unterwegs ist. Das Relay startet von selbst: Eine laufende Instanz übernimmt den Link innerhalb einer Minute, eine gestoppte Instanz beim nächsten Start. + +> Die direkte Route funktioniert nur, wenn der Server tatsächlich im Netzwerk lauscht. Standardmäßig lauscht OpenChamber nur auf dem Rechner selbst — starte es mit `--lan`, damit es über WLAN erreichbar ist. Der Befehl warnt dich (`[LAN_UNREACHABLE]`), wenn die direkte Route des Links von anderen Geräten nicht nutzbar ist; ein `--relay`-Link funktioniert dann trotzdem, nur immer über das Relay. + +Der ausgegebene Link und QR-Code funktionieren genau wie die aus dem Einstellungsdialog — nur einmal verwendbar, mit Ablaufzeit und widerrufbar. + +## Verwandt + +- [Private Relay](/private-relay/) — wie "Anywhere"-Verbindungen funktionieren +- [Mobile Apps](/mobile/) — die iOS- oder Android-App installieren +- [Remote Instances](/remote-instances/) — die Desktop-App per SSH oder Link mit Servern verbinden +- [Remote access](/troubleshooting/remote-access/) — wenn sich ein Gerät nicht verbinden kann diff --git a/packages/docs/content/docs/de/context.mdx b/packages/docs/content/docs/de/context.mdx new file mode 100644 index 00000000..9973d1d4 --- /dev/null +++ b/packages/docs/content/docs/de/context.mdx @@ -0,0 +1,38 @@ +--- +title: Kontext +description: Sieh, wie viel des Modellgedächtnisses eine Session verwendet. +--- + +# Kontext + +Jedes Modell kann immer nur so viel von einer Unterhaltung gleichzeitig halten — seinen Kontext. OpenChamber zeigt dir, wie voll er ist, damit du erkennst, wenn eine Session an die Grenze kommt und eine Antwort vielleicht ältere Details fallen lässt. + +## Die schnelle Anzeige + +Während du chattest, zeigt eine kleine Anzeige den prozentualen Kontextverbrauch. Die Farbe ändert sich, während er voller wird: + +- grün — noch reichlich Platz +- gelb — wird voll (etwa drei Viertel) +- rot — fast voll + +Bewege den Mauszeiger darüber (oder tippe sie auf dem Handy an), um die genauen Token-Zahlen zu sehen. + +## Das vollständige Kontext-Panel + +Öffne den Tab **Kontext** in der rechten Seitenleiste für ein vollständigeres Bild der aktuellen Session: + +- das verwendete Modell und wann die Session gestartet wurde +- Gesamtzahl der Tokens im Verhältnis zum Limit des Modells +- Gesamtzahlen für Nachrichten und Kosten +- eine Aufschlüsselung der Token der letzten Antwort +- eine grobe Aufteilung dessen, was den Kontext belegt (deine Nachrichten, die des Agenten, Tool-Ausgaben) + +Die Aufschlüsselung ist eine Schätzung, keine exakte Zahl — nutze sie, um zu erkennen, was das Fenster füllt, nicht für die Abrechnung. + +## Was tun, wenn es voll ist + +Starte für eine neue Aufgabe eine frische Session, statt eine Session endlos wachsen zu lassen. Ein kürzerer Kontext ist schneller und hält das Modell fokussiert. + +## Verwandtes + +- [Projekte](/projects/) — Sessions werden pro Projekt gruppiert diff --git a/packages/docs/content/docs/de/desktop-browser.mdx b/packages/docs/content/docs/de/desktop-browser.mdx new file mode 100644 index 00000000..8860ee8f --- /dev/null +++ b/packages/docs/content/docs/de/desktop-browser.mdx @@ -0,0 +1,22 @@ +--- +title: Desktop-Browser +description: Durchsuche jede Seite in der Desktop-App mit Inspektion und Konsolenaufzeichnung. +--- + +# Desktop-Browser + +Die Desktop-App hat einen eingebauten Browser, damit du jede Seite direkt neben deinem Chat öffnen, auf Elemente zeigen und danach fragen sowie die Konsole der Seite aufzeichnen kannst. Öffne ihn über die Globus-Schaltfläche im App-Kopfbereich. + +> Der Desktop-Browser ist eine Funktion **nur für den Desktop**. Im Web bietet das [Preview](/preview/)-Panel dieselben Inspektions- und Konsolentools für deinen lokalen Dev-Server. + +## Inspizieren und annotieren + +Aktiviere **inspect** und klicke auf ein beliebiges Element auf der Seite. OpenChamber erstellt dazu eine Notiz — was es ist, welche Stile es hat, wo es sich befindet und einen Screenshot — und hängt sie an deine Chatnachricht an. Das ist der schnellste Weg, dem Agenten zu sagen: „dieses Element, genau hier“. + +## Konsolenaufzeichnung + +Der Browser sammelt die Konsolenausgabe der Seite — Fehler, Warnungen und Logs — damit du sie filtern und lesen kannst, ohne die Entwicklertools zu öffnen. + +## Verwandt + +- [Preview & Dev Servers](/preview/) — dieselben Tools für deinen lokalen Dev-Server diff --git a/packages/docs/content/docs/de/desktop-tunnels.mdx b/packages/docs/content/docs/de/desktop-tunnels.mdx new file mode 100644 index 00000000..6b3d80ad --- /dev/null +++ b/packages/docs/content/docs/de/desktop-tunnels.mdx @@ -0,0 +1,41 @@ +--- +title: Desktop-Tunnel +description: Erstelle Cloudflare- oder Ngrok-Tunnel aus der Desktop-App. +--- + +# Desktop-Tunnel + +Die Desktop-App kann unter **Settings → OpenChamber → Tunnel** einen öffentlichen Tunnel erstellen. Für diesen Weg musst du OpenChamber nicht per CLI starten. + +## Anbieter installieren + +OpenChamber startet das Provider-CLI auf deinem Rechner. Installiere zuerst den Anbieter, den du verwenden möchtest: + +```bash +brew install cloudflared +brew install ngrok +``` + +Cloudflare verwendet `cloudflared`. Ngrok benötigt ein ngrok-Konto und ein Authtoken aus dem ngrok-Dashboard: + +```bash +ngrok config add-authtoken +``` + +## In der App starten + +1. Öffne **Settings → OpenChamber → Tunnel**. +2. Wähle **Cloudflare** oder **Ngrok**. +3. Starte einen Quick Tunnel. +4. Scanne den erzeugten QR-Code mit deinem Telefon. + +Ngrok unterstützt derzeit Quick Tunnels. Cloudflare unterstützt Quick Tunnels und verwaltete Cloudflare-Modi. + +## Zugriffsschutz + +Auch wenn die Anbieter-URL selbst öffentlich ist, schützt OpenChamber den Zugriff weiterhin mit seinem eigenen Verbindungs-Token. Der erzeugte Verbindungslink enthält ein Einmal-Token, hat eine TTL, und alte unbenutzte Links werden widerrufen, wenn du einen neuen erzeugst oder den Tunnel stoppst bzw. neu startest. + +## Verwandt + +- [Tunnels](/tunnels/) — Tunnel-Nutzung per CLI und verwaltete Cloudflare-Modi +- [PWA & Mobile](/mobile/) — OpenChamber vom Telefon aus erreichen diff --git a/packages/docs/content/docs/de/environment.mdx b/packages/docs/content/docs/de/environment.mdx new file mode 100644 index 00000000..8e48b703 --- /dev/null +++ b/packages/docs/content/docs/de/environment.mdx @@ -0,0 +1,138 @@ +--- +title: Umgebungsvariablen +description: Konfiguriere die OpenChamber- und OpenCode-Integration mit Umgebungsvariablen. +--- + +# Umgebungsvariablen + +OpenChamber liest diese Umgebungsvariablen beim Start. Für Startdienste speichert `openchamber startup enable` die aktuelle Umgebung standardmäßig als Schnappschuss, also führe den Befehl nach Änderungen an den Variablen, die der Dienst verwenden soll, erneut aus. + +## OpenChamber-Server + +### `OPENCHAMBER_HOST` + +Bind-Adresse für den OpenChamber-Webserver. Verwende `0.0.0.0`, um Zugriff von anderen Maschinen zu erlauben. + +### `OPENCHAMBER_UI_PASSWORD` + +Passwort für die Browser-Oberfläche. Verwende es, wenn du außerhalb von localhost bindest, Tunnels nutzt oder hinter einem Reverse Proxy läufst. + +### `OPENCHAMBER_API_ONLY` + +Startet OpenChamber im Headless-Modus, wenn auf `true` oder `1` gesetzt. API-Routen bleiben für Desktop- und Mobile-Clients verfügbar, aber die Browser-Oberfläche wird nicht ausgeliefert. + +### `OPENCHAMBER_DATA_DIR` + +Überschreibt das OpenChamber-Datenverzeichnis. Standard ist `~/.config/openchamber`. + +### `OPENCHAMBER_COMPRESS_API` + +Steuert die Komprimierung von API-Antworten. Verwende `true` oder `1`, um sie zu erzwingen, und `false` oder `0`, um sie zu deaktivieren. + +### `OPENCHAMBER_SKIP_API_COMPRESSION` + +Deaktiviert die Komprimierung von API-Antworten, wenn auf `true` oder `1` gesetzt. Das hat Vorrang vor `OPENCHAMBER_COMPRESS_API`. + +### `OPENCHAMBER_VERBOSE_REQUEST_LOGS` + +Aktiviert ausführliche HTTP-Request-Logs, wenn auf `true` oder `1` gesetzt. + +### `OPENCHAMBER_UPDATE_API_URL` + +Überschreibt den API-Endpunkt für Update-Prüfungen. Die meisten Nutzer sollten das nicht setzen. + +### `OPENCHAMBER_PACKAGE_MANAGER` + +Erzwingt den Paketmanager, den Update-Operationen verwenden, wenn die automatische Erkennung falsch liegt. + +## OpenCode-Server + +### `OPENCODE_HOST` + +Verbindet OpenChamber mit einem vorhandenen OpenCode-Server. Der Wert muss ein `http`- oder `https`-Origin mit explizitem Port und ohne Pfad, Query oder Hash sein. `OPENCODE_HOST` hat Vorrang vor `OPENCODE_PORT`. + +### `OPENCODE_PORT` + +Legt den Port des OpenCode-Servers fest. Bei verwaltetem OpenCode fordert dies den verwalteten Port an; mit `OPENCODE_SKIP_START=true` verbindet es sich mit einem externen Server auf diesem Port. + +### `OPENCODE_SKIP_START` + +Verhindert, dass OpenChamber einen eigenen OpenCode-Server startet, wenn auf `true` gesetzt. + +### `OPENCHAMBER_OPENCODE_HOSTNAME` + +Bind-Hostname für den von OpenChamber verwalteten OpenCode-Server. Standard ist `127.0.0.1`. + +### `OPENCODE_BINARY` + +Pfad zur ausführbaren `opencode`-Datei, die OpenChamber ausführen soll. + +### `OPENCODE_CONFIG` + +Pfad zu einer bestimmten OpenCode-Konfigurationsdatei. + +### `OPENCODE_CONFIG_DIR` + +Pfad zu einem bestimmten OpenCode-Konfigurationsverzeichnis für Agents, Skills, Snippets und Konfigurationssuche. + +### `OPENCODE_DATA_DIR` + +Benutzerdefiniertes Datenverzeichnis für den verwalteten OpenCode-Server. + +### `OPENCODE_WSL_DISTRO` + +Wählt die WSL-Distribution für die OpenCode-Integration unter Windows aus. + +### `OPENCHAMBER_OPENCODE_WSL_DISTRO` + +OpenChamber-spezifischer Alias zur Auswahl der WSL-Distribution. `OPENCODE_WSL_DISTRO` hat Vorrang, wenn beide gesetzt sind. + +### `OPENCODE_JWT_SECRET` + +Geheimnis zum Signieren von UI-Authentifizierungstokens. Verwende für dauerhafte Diensteinrichtungen einen langen, zufälligen Wert. + +## Terminal und Git + +### `OPENCHAMBER_TERMINAL_SHELL` + +Shell-Executable, die OpenChamber für Terminal-Sessions verwendet. + +### `OPENCHAMBER_GIT_BINARY` + +Git-Executable, die OpenChamber-Git-Funktionen verwenden. + +### `GIT_BINARY` + +Alternative Git-Executable-Überschreibung. Bevorzuge `OPENCHAMBER_GIT_BINARY` für OpenChamber-spezifische Konfiguration. + +### `OPENCHAMBER_GIT_READ_CACHE_TTL_MS` + +Lebensdauer in Millisekunden für zwischengespeicherte Git-gestützte Dateilesevorgänge. Setze `0`, um diesen Cache beim Debuggen zu deaktivieren. + +## Voice und Tunnels + +### `OPENAI_API_KEY` + +API-Schlüssel, den OpenChamber-Voice-Funktionen für OpenAI-kompatible Dienste verwenden. + +### `OPENCHAMBER_ALLOW_REMOTE_OPENAI_COMPAT_URLS` + +Erlaubt entfernte OpenAI-kompatible Basis-URLs für Voice-Funktionen, wenn auf `true` oder `1` gesetzt. + +### `NGROK_AUTHTOKEN` + +ngrok-Auth-Token, den OpenChamber-Tunnel-Befehle verwenden. Du kannst ngrok auch mit `ngrok config add-authtoken ` konfigurieren. + +## Laufzeit-Helfer + +### `BUN_BINARY` + +Bun-Executable, die OpenChamber beim Starten von Daemon-Prozessen verwenden soll. + +### `BUN_INSTALL` + +Installationswurzel von Bun. OpenChamber verwendet sie, um `bin/bun` für Daemon-Start und Updates zu finden. + +### `VITE_OPENCODE_URL` + +Build-Zeit-API-Basis-URL für die mit Vite gebaute Web-App. Die meisten Nutzer sollten sie für die normale CLI- oder Desktop-Nutzung nicht setzen. diff --git a/packages/docs/content/docs/de/git-identities.mdx b/packages/docs/content/docs/de/git-identities.mdx new file mode 100644 index 00000000..7c02f83b --- /dev/null +++ b/packages/docs/content/docs/de/git-identities.mdx @@ -0,0 +1,30 @@ +--- +title: Git-Identitäten +description: Committe mit dem richtigen Namen und der richtigen E-Mail für jedes Repository. +--- + +# Git-Identitäten + +Eine Git-Identität ist der Name und die E-Mail-Adresse, mit denen deine Commits signiert werden. Wenn du zwischen privaten und Arbeits-Repos wechselst, kannst du Identitäten speichern und pro Repository die richtige anwenden, statt dich auf eine einzige globale Einstellung zu verlassen. Verwalte sie unter **Einstellungen → Git**. + +## Eine Identität hinzufügen + +1. Öffne **Einstellungen → Git** und wähle **New**. +2. Gib den **name** und die **email** ein, mit denen committen werden soll. +3. Wähle aus, wie sie sich beim Remote authentifiziert: + - **SSH** — verweise auf einen SSH-Schlüssel + - **token** — verwende eine gespeicherte Anmeldedaten für einen Host +4. Gib ihr optional eine Farbe und ein Symbol, damit sie leicht zu erkennen ist. + +Auch deine globale System-Identität wird angezeigt, allerdings nur lesbar. + +## Eine Identität auf ein Repo anwenden + +Das Anwenden einer Identität schreibt sie in die **lokale** Git-Konfiguration dieses Repositories — sie betrifft nur dieses Repo, nicht deine globale Einstellung. SSH-Identitäten setzen außerdem den zu verwendenden SSH-Befehl auf deinen Schlüssel; Token-Identitäten richten die Anmeldedatenspeicherung für den Host ein. + +Du kannst Identitäten importieren, die OpenChamber aus deinen vorhandenen Git-Anmeldedaten erkennt, und sie als Token-Identitäten speichern. + +## Verwandt + +- [Git- & GitHub-Workflows](/git/) — mit der von dir festgelegten Identität committen +- [GitHub Issues & PRs](/github/) — ein GitHub-Konto für PRs verbinden diff --git a/packages/docs/content/docs/de/git.mdx b/packages/docs/content/docs/de/git.mdx new file mode 100644 index 00000000..fc2d4b54 --- /dev/null +++ b/packages/docs/content/docs/de/git.mdx @@ -0,0 +1,41 @@ +--- +title: Git- & GitHub-Workflows +description: Änderungen vormerken, committen und Branches verwalten, ohne OpenChamber zu verlassen. +--- + +# Git- & GitHub-Workflows + +OpenChamber hat eine integrierte Git-Ansicht, damit du Änderungen prüfen, committen und Branches verwalten kannst, ohne in ein Terminal zu wechseln. Öffne sie über den **Git**-Tab in der rechten Seitenleiste. + +## Prüfen und committen + +Die Git-Ansicht teilt deine Änderungen in **staged** und **unstaged** auf: + +- klicke auf das **+** einer Datei, um sie zu stage'n, oder auf **−**, um sie wieder unstaged zu machen +- stage oder unstage ganze Gruppen auf einmal +- klicke auf eine Datei, um ihren Diff zu sehen + +Schreibe dann eine Commit-Nachricht und committe. Du kannst OpenChamber eine **Commit-Nachricht generieren** lassen, basierend auf deinen gestagten Änderungen — dabei wird das Modell der aktuellen Sitzung verwendet, also brauchst du eine geöffnete Sitzung. + +## Branches und Verlauf + +Die Git-Ansicht deckt auch den üblichen Rest von Git ab: + +- Branches erstellen, wechseln, umbenennen und löschen +- pushen, pullen und fetchen +- Verlauf und Diffs pro Commit durchsuchen +- Änderungen stashen und wiederherstellen + +## Pull Requests + +Verbinde GitHub (siehe [GitHub Issues & PRs](/github/)) und der **PR**-Tab lässt dich einen Pull Request öffnen, aktualisieren, als bereit markieren oder mergen — und seinen Titel und seine Beschreibung auf dieselbe Weise wie Commit-Nachrichten generieren. + +## Konflikte übernehmen + +Wenn ein Merge, Rebase oder Integrate auf einen Konflikt stößt, zeigt OpenChamber, was festhängt, und lässt dich es lösen — einschließlich der Übergabe an den Agenten. + +## Verwandt + +- [GitHub Issues & PRs](/github/) — GitHub verbinden und mit Issues mit der Arbeit beginnen +- [Worktree-Sitzungen](/worktrees/) — einen Branch in seinem eigenen Ordner isolieren +- [Git-Identitäten](/git-identities/) — als die richtige Person pro Repo committen diff --git a/packages/docs/content/docs/de/github.mdx b/packages/docs/content/docs/de/github.mdx new file mode 100644 index 00000000..e2de6f5a --- /dev/null +++ b/packages/docs/content/docs/de/github.mdx @@ -0,0 +1,34 @@ +--- +title: GitHub Issues & PRs +description: Verbinde GitHub und starte Sitzungen aus Issues und Pull Requests. +--- + +# GitHub Issues & PRs + +Verbinde dein GitHub-Konto und OpenChamber kann Issues und Pull Requests einlesen, direkt daraus eine Sitzung starten und PRs für dich öffnen oder aktualisieren. + +## GitHub verbinden + +1. Öffne **Einstellungen → Git**. +2. Wähle unter GitHub **Connect**. OpenChamber zeigt einen Link und einen kurzen Code an. +3. Öffne den Link, gib den Code ein und bestätige. + +Wenn die Verbindung steht, erscheint dein Konto im GitHub-Bereich. Du kannst mehr als ein Konto verbinden und zwischen ihnen wechseln oder dich jederzeit trennen. + +## Arbeit aus einem Issue oder PR starten + +Wenn du mit verbundenem GitHub eine [Worktree-Sitzung](/worktrees/) erstellst, kannst du **Start from GitHub issue/PR** wählen: + +- wähle ein **issue** und OpenChamber benennt den Branch danach und öffnet die Sitzung mit dem Issue und seinen Kommentaren als erster Nachricht +- wähle einen **pull request** und OpenChamber checkt den Branch des PR aus; du kannst den Diff des PR einbeziehen, damit der Agent die komplette Änderung hat + +So landest du direkt in einer Sitzung, in der der Kontext bereits geladen ist. + +## Pull Requests öffnen und verwalten + +Im **PR**-Tab der [Git-Ansicht](/git/) kannst du einen Pull Request erstellen, aktualisieren, einen Entwurf als bereit markieren oder mergen. OpenChamber kann den PR-Titel und die Beschreibung aus deinen Änderungen generieren. + +## Verwandt + +- [Git- & GitHub-Workflows](/git/) — committen und Branches verwalten +- [Worktree-Sitzungen](/worktrees/) — dort starten Issue- und PR-Sitzungen diff --git a/packages/docs/content/docs/de/index.mdx b/packages/docs/content/docs/de/index.mdx new file mode 100644 index 00000000..617013ad --- /dev/null +++ b/packages/docs/content/docs/de/index.mdx @@ -0,0 +1,32 @@ +--- +title: OpenChamber-Dokumentation +description: Ein Einrichtungs- und Bedienhandbuch für OpenChamber für Web, Desktop und VS Code. +--- + +# OpenChamber-Dokumentation + +OpenChamber ist der visuelle Arbeitsbereich rund um OpenCode (den KI-Coding-Agenten, der in deinem Terminal läuft). Er gibt dir einen Bildschirm, auf dem du diese Arbeit beobachten und steuern kannst, statt nur auf der Befehlszeile zu arbeiten. + +Nutze diese Doku, um: + +- die richtige App für deine Arbeitsweise zu installieren +- OpenChamber für den sicheren Remote-Einsatz zu öffnen +- das Aussehen anzupassen und häufige Probleme zu beheben + +## Lies zuerst das hier + +- [Install](/install/) +- [Quickstart](/quickstart/) +- [Tunnels](/tunnels/) +- [Troubleshooting](/troubleshooting/) + +## Entdecken + +- [Projects](/projects/) und [Worktree Sessions](/worktrees/) — deine Arbeit organisieren und isolieren +- [Providers, Models & Agents](/providers/) — OpenCode verbinden und ein Modell wählen +- [Git & GitHub Workflows](/git/) — commits erstellen, prüfen und PRs öffnen +- [Security](/security/) und [Tunnels](/tunnels/) — deine Instanz schützen und erreichen + +## Wofür OpenChamber gedacht ist + +OpenChamber ist für die Teile des KI-Codings gedacht, die von einer Leitstelle profitieren: verzweigende Sessions, Diffs prüfen, Terminals verwalten, den Fortschritt von Tools beobachten, Projektaktionen ausführen und das gesamte Board sichtbar halten, während der Agent arbeitet. diff --git a/packages/docs/content/docs/de/install.mdx b/packages/docs/content/docs/de/install.mdx new file mode 100644 index 00000000..097ce2a0 --- /dev/null +++ b/packages/docs/content/docs/de/install.mdx @@ -0,0 +1,33 @@ +--- +title: Installieren +description: Installiere OpenChamber für Desktop, Web oder VS Code. +--- + +# Installieren + +Es gibt drei Möglichkeiten, OpenChamber auszuführen: + +- Desktop-App für macOS +- Web-App, die vom CLI gehostet wird und die du wie eine Handy-App installieren kannst (eine PWA) +- VS-Code-Erweiterung + +## Voraussetzung + +Installiere zuerst [OpenCode](https://opencode.ai) — OpenChamber läuft darauf auf. + +## Web + PWA + +```bash +curl -fsSL https://raw.githubusercontent.com/openchamber/openchamber/main/scripts/install.sh | bash +openchamber --ui-password be-creative-here +``` + +Öffne die URL, die das CLI ausgibt (normalerweise `http://localhost:3000`). Du solltest die OpenChamber-Session-Liste sehen. Damit du sie griffbereit hast, kannst du im Adressfeld deines Browsers über die Option „Installieren“ daraus eine App machen. + +## Desktop + +Lade den aktuellen Desktop-Build von der GitHub-Releases-Seite oder der OpenChamber-Downloadseite herunter. Öffne ihn und melde dich in deinem gewohnten OpenCode-Workflow an. + +## VS Code + +Installiere sie aus dem VS Code Marketplace und melde dich in deinem gewohnten OpenCode-Workflow an. Die OpenChamber-Ansicht öffnet sich dann in der Seitenleiste. diff --git a/packages/docs/content/docs/de/magic-prompts.mdx b/packages/docs/content/docs/de/magic-prompts.mdx new file mode 100644 index 00000000..d6dd2c68 --- /dev/null +++ b/packages/docs/content/docs/de/magic-prompts.mdx @@ -0,0 +1,26 @@ +--- +title: Magische Prompts +description: Passe die eingebauten Prompts an, die hinter OpenChambers automatisierten Abläufen stehen. +--- + +# Magische Prompts + +OpenChamber verwendet im Hintergrund eingebaute Prompts, wenn etwas automatisch geschieht — eine Commit-Nachricht schreiben, einen PR entwerfen, ein Issue prüfen, einen Konflikt lösen oder eine Sitzung zusammenfassen. Hier kannst du diese Prompts ansehen und umschreiben. Öffne die Seite unter **Einstellungen → Magische Prompts**. + +Für die normale Nutzung brauchst du diese Seite nicht. Nutze sie, wenn ein Ablauf sich anders verhalten soll — etwa bei Commit-Nachrichten in einem bestimmten Stil. + +## Einen Prompt bearbeiten + +1. Öffne **Einstellungen → Magische Prompts**. +2. Wähle einen Prompt aus den Gruppen in der Seitenleiste — Git, GitHub, Planung und Sitzung. +3. Bearbeite den Text und speichere ihn. + +Einige Prompts haben einen sichtbaren Teil (die Nachricht, die du sehen würdest) und einen Anweisungsteil (versteckte Hinweise für den Agenten). Prompts können `{{placeholders}}` enthalten, die OpenChamber einsetzt, zum Beispiel den Diff oder den Issue-Titel — lass diese unverändert. + +## Zurücksetzen + +Anders entschieden? Jeder Prompt hat **Auf Standard zurücksetzen**, und es gibt **Alle zurücksetzen**, wenn du überall neu anfangen möchtest. + +## Weiterführend + +- [Git- & GitHub-Workflows](/git/) — viele dieser Prompts treiben die Git-Abläufe an diff --git a/packages/docs/content/docs/de/mcp.mdx b/packages/docs/content/docs/de/mcp.mdx new file mode 100644 index 00000000..24841978 --- /dev/null +++ b/packages/docs/content/docs/de/mcp.mdx @@ -0,0 +1,30 @@ +--- +title: MCP-Server +description: Füge MCP-Server hinzu, damit Agents zusätzliche Werkzeuge erhalten. +--- + +# MCP-Server + +Ein MCP-Server gibt deinen Agents zusätzliche Werkzeuge — etwa um eine Datenbank zu durchsuchen, eine API aufzurufen oder einen genutzten Dienst auszulesen. Du fügst sie unter **Einstellungen → MCP** hinzu. + +## Einen Server hinzufügen + +1. Öffne **Einstellungen → MCP**. +2. Füge einen Server hinzu und wähle seinen Typ: + - **lokal** — OpenChamber führt einen Befehl auf deinem Rechner aus. Du gibst den auszuführenden Befehl an und, falls nötig, Umgebungsvariablen. + - **entfernt** — OpenChamber verbindet sich mit einer URL, die jemand anderes hostet. Du gibst die URL und alle nötigen Header an (zum Beispiel ein Auth-Token). +3. Speichere. Der Server ist standardmäßig eingeschaltet; du kannst ihn deaktivieren, ohne ihn zu löschen. + +## Wo es gilt + +Wähle den Geltungsbereich, wenn du einen Server hinzufügst: + +- **persönlich** — in jedem Projekt verfügbar +- **projektbezogen** — nur im aktuellen Projekt verfügbar und zusammen mit den übrigen Projekteinstellungen gespeichert + +Servernamen verwenden Kleinbuchstaben, Ziffern, Bindestriche und Unterstriche. + +## Weiterführend + +- [Provider, Modelle & Agents](/providers/) — verbinde zuerst ein Modell +- [Skills](/skills/) — eine weitere Möglichkeit, die Fähigkeiten von Agents zu erweitern diff --git a/packages/docs/content/docs/de/mobile.mdx b/packages/docs/content/docs/de/mobile.mdx new file mode 100644 index 00000000..b0f9ed2e --- /dev/null +++ b/packages/docs/content/docs/de/mobile.mdx @@ -0,0 +1,43 @@ +--- +title: Mobile Apps & PWA +description: Installiere die OpenChamber-App auf iOS oder Android und verbinde sie mit deinem Server. +--- + +# Mobile Apps & PWA + +OpenChamber hat native Apps für iPhone und Android, damit du Sitzungen verfolgen, auf Agenten antworten und Arbeiten von deinem Telefon aus verwalten kannst — zu Hause über WLAN oder von überall über das [Private Relay](/private-relay/). + +## App installieren + +- **iPhone/iPad** — tritt der [TestFlight-Beta](https://testflight.apple.com/join/5ek6GU1E) bei +- **Android** — lade das APK aus dem [latest release](https://github.com/openchamber/openchamber/releases/latest) herunter + +## Mit deinem Server verbinden + +1. Öffne auf dem Computer mit OpenChamber **Settings → Remote Instances → Connect to this server** und drücke **Add a device**. +2. Wähle **Anywhere** (oder **Home network only**, wenn du das Telefon nur zu Hause nutzt) und drücke **Create QR code**. +3. Tippe in der Mobile App auf **Scan QR code** und halte die Kamera darauf. + +Die App verbindet sich und merkt sich den Server. Der QR-Code ist nur einmal verwendbar, und jedes Gerät erhält sein eigenes, widerrufbares Token — siehe [Connect a Device](/connect-devices/) für die sichere Kopplung. + +Du kannst die App mit mehreren Servern koppeln und im Instanzenbereich zwischen ihnen wechseln; die App zeigt bei jedem an, ob er erreichbar ist und ob du über das lokale Netzwerk oder das Relay verbunden bist. + +## PWA (Browser-Installation) + +Du willst gar keinen App-Store? Die Web-App lässt sich direkt aus dem Browser installieren: + +- **desktop browser** — nutze die Option **Install** in der Adressleiste +- **iPhone/iPad (Safari)** — Teilen → **Add to Home Screen** +- **Android (Chrome)** — Menü → **Install app** / **Add to Home Screen** + +Um die PWA von außerhalb deines Netzwerks zu erreichen, brauchst du einen [Tunnel](/tunnels/) und ein starkes [UI password](/security/) — die nativen Apps übernehmen das für dich über das Relay. + +## Mobile Einstellungen + +Unter **Settings → OpenChamber** passen ein paar Optionen die mobile und installierte Nutzung an — der Installationsname der App, die Bildschirmausrichtung und das Verhalten der Bildschirmtastatur. + +## Verwandt + +- [Connect a Device](/connect-devices/) — Kopplung, einmalige QR-Codes und Geräteverwaltung +- [Private Relay](/private-relay/) — wie "Anywhere"-Zugriff funktioniert +- [Security](/security/) — die UI schützen, bevor du sie freigibst diff --git a/packages/docs/content/docs/de/multi-run.mdx b/packages/docs/content/docs/de/multi-run.mdx new file mode 100644 index 00000000..197a1210 --- /dev/null +++ b/packages/docs/content/docs/de/multi-run.mdx @@ -0,0 +1,34 @@ +--- +title: Multi-run +description: Führe dieselbe Aufforderung gleichzeitig über mehrere Modelle oder Sitzungen aus. +--- + +# Multi-run + +Multi-run startet mehrere Sitzungen aus einem einzigen Formular — praktisch, um dieselbe Aufgabe mit verschiedenen Modellen zu testen und die Ergebnisse zu vergleichen. Öffne es über die Schaltfläche oben in der Sitzungs-Seitenleiste. + +## Einen Multi-run starten + +1. Öffne den Multi-run-Startbildschirm. +2. Wähle das Projekt und benenne die Run-Gruppe. +3. Schreibe die Aufforderung und wähle die Modelle aus, mit denen sie ausgeführt werden soll (bis zu fünf pro Gruppe). +4. Entscheide, ob du **Runs isolieren** möchtest. +5. Starte. + +Jedes Modell erhält seine eigene Sitzung, und alle beginnen mit deiner Aufforderung. + +## Isolierte Runs + +Aktiviere **Runs isolieren**, damit jeder Run seinen eigenen [Worktree](/worktrees/) und Branch bekommt und sie sich nie dieselben Dateien teilen. Dafür brauchst du ein Git-Repo — für Ordner, die keines sind, wird es automatisch deaktiviert. Wähle den Branch, von dem die Runs starten. + +Ohne Isolation ist jeder Run einfach eine normale Sitzung im Projektordner. + +## Ergebnisse vergleichen + +Jeder Run ist eine normale Sitzung, die du öffnen, lesen und behalten oder verwerfen kannst. Wenn du Runs gestartet hast, um Ansätze zu vergleichen, prüfe sie nebeneinander und nimm den besten mit. + +Wenn ein einzelner Run nicht startet, werden die anderen trotzdem ausgeführt — du siehst dann nur weniger Sitzungen, als du angefordert hast. + +## Verwandt + +- [Worktree-Sitzungen](/worktrees/) — wie Isolation unter der Haube funktioniert diff --git a/packages/docs/content/docs/de/notes-todos-plans.mdx b/packages/docs/content/docs/de/notes-todos-plans.mdx new file mode 100644 index 00000000..3a196aee --- /dev/null +++ b/packages/docs/content/docs/de/notes-todos-plans.mdx @@ -0,0 +1,37 @@ +--- +title: Projektnotizen, Todos & Pläne +description: Halte Notizen, eine Todo-Liste und gespeicherte Pläne für jedes Projekt fest. +--- + +# Projektnotizen, Todos & Pläne + +Jedes Projekt hat seinen eigenen Arbeitsbereich für Notizen, eine Todo-Liste und gespeicherte Pläne. Sie gehören zum Projekt und nicht zu einer einzelnen Session, also bleiben sie erhalten, wenn du zwischen Sessions wechselst. Du findest sie im Tab **Kontext** der rechten Seitenleiste (auf Mobilgeräten ein eigener Tab). + +## Notizen + +Ein freies Notizfeld für alles, woran du dich zum Projekt erinnern möchtest. Es speichert automatisch, während du tippst. + +## Todos + +Eine einfache Checkliste. Füge Einträge hinzu, hake sie ab, ordne sie per Drag-and-drop um und lösche die erledigten. + +Jedes Todo hat ein Menü **Senden**, damit du es an den Agenten übergeben kannst: + +- an die aktuelle Session senden +- mit ihm eine neue Session starten +- mit ihm eine neue [Worktree-Session](/worktrees/) starten (nur wenn das Projekt ein Git-Repo ist) + +## Pläne + +Ein Ort, um längere Pläne als gespeicherte Dateien aufzubewahren. Du kannst: + +- einen Plan aus einer Markdown- oder Textdatei importieren +- einen Plan öffnen, um ihn im Seitenpanel zu lesen +- Pläne löschen, die du nicht mehr brauchst + +Du solltest danach wieder im Kontext-Tab landen — mit gespeicherter Notiz, abgehakter Todo oder gelistetem Plan; so erkennst du, dass es geklappt hat. + +## Verwandtes + +- [Worktree-Sessions](/worktrees/) — eine Todo in einem eigenen Branch ausführen +- [Projekte](/projects/) — diese gehören zum aktiven Projekt diff --git a/packages/docs/content/docs/de/notifications.mdx b/packages/docs/content/docs/de/notifications.mdx new file mode 100644 index 00000000..268779ce --- /dev/null +++ b/packages/docs/content/docs/de/notifications.mdx @@ -0,0 +1,34 @@ +--- +title: Benachrichtigungen +description: Lass dich benachrichtigen, wenn eine Sitzung deine Aufmerksamkeit braucht oder fertig ist. +--- + +# Benachrichtigungen + +Benachrichtigungen sagen dir, wenn etwas deine Aufmerksamkeit braucht, damit du nicht ständig auf den Bildschirm schauen musst — eine Sitzung ist fertig, hat einen Fehler, stellt eine Frage oder braucht eine Berechtigung für etwas. Du richtest sie unter **Einstellungen → OpenChamber → Benachrichtigungen** ein. + +## Aktivieren + +1. Öffne **Einstellungen → OpenChamber → Benachrichtigungen**. +2. Erlaube Benachrichtigungen, wenn dein Browser oder System dich danach fragt. +3. Wähle aus, worüber du informiert werden möchtest: + - eine Sitzung **endet** + - eine Sitzung **hat einen Fehler** + - eine Sitzung **stellt eine Frage** + - eine Sitzung braucht **eine Berechtigung** + - **Unteraufgaben** sind abgeschlossen + +## Wie sie bei dir ankommen + +- auf dem **Desktop** bekommst du native Systembenachrichtigungen +- in einem **Browser oder einer installierten App** bekommst du Web-Push-Benachrichtigungen, damit sie auch ankommen, wenn der Tab im Hintergrund ist + +Sitzungen, die auf automatisches Annehmen gesetzt sind, belästigen dich nicht mit Berechtigungsbenachrichtigungen. + +## Die Formulierungen anpassen + +Jede Art von Benachrichtigung hat einen Titel und eine Nachrichtenvorlage, die du bearbeiten kannst, mit Feldern wie Agentenname und Modell. Außerdem gibt es eine Begrenzung, wie viel der letzten Nachricht eingefügt wird, damit Benachrichtigungen kurz bleiben. + +## Weiterführend + +- [Sprachmodus](/voice/) — lass dir Antworten stattdessen vorlesen diff --git a/packages/docs/content/docs/de/opencode-server.mdx b/packages/docs/content/docs/de/opencode-server.mdx new file mode 100644 index 00000000..8ee05494 --- /dev/null +++ b/packages/docs/content/docs/de/opencode-server.mdx @@ -0,0 +1,97 @@ +--- +title: OpenCode-Server +description: Verbinde OpenChamber mit einem lokalen oder entfernten OpenCode-Server. +--- + +# OpenCode-Server + +OpenChamber läuft auf einem OpenCode-Server auf. Standardmäßig startet es einen für dich, sodass du nichts tun musst. Diese Seite brauchst du nur, wenn du OpenChamber auf einen Server zeigen lassen willst, den du bereits betreibst, oder den Server verwalten willst, den es startet. + +## Wie OpenChamber einen Server findet + +Wenn OpenChamber startet, sucht es in dieser Reihenfolge nach einem Server: + +1. einen Server wiederverwenden, den es bereits gestartet hat +2. zu einem externen verbinden, wenn du es so angegeben hast (siehe unten) +3. einen Server am Standard-Port automatisch erkennen (`4096`) +4. andernfalls den eigenen starten und verwalten + +Wenn nichts konfiguriert ist, geschieht Schritt 4 automatisch und du bist startklar. + +## Mit einem Server verbinden, den du bereits betreibst + +Setze diese Variablen, bevor du OpenChamber startest: + +```bash +OPENCODE_HOST=http://localhost:4096 OPENCODE_SKIP_START=true openchamber +``` + +- `OPENCODE_HOST` — die vollständige Adresse deines OpenCode-Servers einschließlich des Ports (ein Wert wie `http://localhost:4096`). Sie darf am Ende keinen Pfad haben. +- `OPENCODE_SKIP_START=true` — weist OpenChamber an, keinen eigenen Server zu starten. + +Wenn du nur den Port ändern musst, setze `OPENCODE_PORT` statt `OPENCODE_HOST`. + +Wenn `OPENCODE_HOST` keinen Port hat oder einen Pfad enthält, ignoriert OpenChamber ihn und fällt darauf zurück, den eigenen Server zu starten. Achte beim Start auf eine `[config]`-Warnung in den Logs, wenn eine erwartete Verbindung nicht zustande kam. + +## Den Server über das CLI verwalten + +```bash +openchamber status +openchamber logs +openchamber restart +openchamber stop +``` + +`openchamber` allein startet den Server im Hintergrund. Füge `--foreground` hinzu, damit er an dein Terminal gebunden bleibt. + +## OpenChamber beim Anmelden starten + +Verwende `startup enable`, um einen nativen Benutzerdienst zu installieren. OpenChamber nutzt `launchd` auf macOS, `systemd --user` auf Linux und den Taskplaner auf Windows. + +```bash +openchamber startup enable +openchamber startup status +openchamber startup disable +``` + +Um die UI zu schützen, setze das Passwort beim Aktivieren des Dienstes: + +```bash +OPENCHAMBER_UI_PASSWORD='secret' openchamber startup enable +``` + +Für einen headless Server, der sich beim Anmelden startet und für Desktop- oder Mobile-Clients gedacht ist, füge `--api-only` und einen erreichbaren Host hinzu: + +```bash +openchamber startup enable --port 3000 --api-only --host 0.0.0.0 --ui-password secret +``` + +`startup enable` speichert deine aktuelle Umgebung als Schnappschuss im Dienst, damit er sich eher so verhält, als würdest du `openchamber` aus derselben Shell starten. So bleiben Provider-Tokens, `PATH`, SSH-Agent-Einstellungen und andere CLI-Auth-/Konfigurationsvariablen verfügbar. Verwende `--no-env-snapshot`, wenn du eine minimale Dienstenumgebung willst. + +Der Startup-Dienst merkt sich `--port`, `--host`, `--ui-password` und `--api-only`. CLI-Neustarts und Update-Neustarts verwenden diese gespeicherten Einstellungen wieder. + +Um einen Verbindungslink für eine andere OpenChamber-App zu erstellen, verwende: + +```bash +openchamber connect-url --port 3000 --server http://your-host:3000 --qr +``` + +Führe `openchamber connect-url --help` aus, um alle Link-Optionen zu sehen, einschließlich `--name`, `--lan`, `--server`, `--api-only`, `--ui-password` und `--qr`. + +Du kannst Tunnel für diesen laufenden Dienst weiterhin unabhängig verwalten: + +```bash +openchamber tunnel start --port 3000 +openchamber tunnel stop --port 3000 +``` + +Das Stoppen des Tunnels startet weder den Dienst noch die App neu. + +## „OpenCode wird neu gestartet“ + +Während der Server startet oder neu startet, zeigt OpenChamber den Status „OpenCode wird neu gestartet“ an und pausiert Anfragen, bis er bereit ist. Das ist direkt nach dem Start oder einem Neustart normal. Wenn er nie verschwindet, siehe [OpenCode-Verbindung](/troubleshooting/opencode-connection/). + +## Verwandtes + +- [Providers, Models & Agents](/providers/) — festlegen, womit der Server spricht +- [OpenCode-Verbindung](/troubleshooting/opencode-connection/) — falls keine Verbindung möglich ist diff --git a/packages/docs/content/docs/de/preview.mdx b/packages/docs/content/docs/de/preview.mdx new file mode 100644 index 00000000..555f5023 --- /dev/null +++ b/packages/docs/content/docs/de/preview.mdx @@ -0,0 +1,32 @@ +--- +title: Vorschau & Entwicklungsserver +description: Öffne einen laufenden Entwicklungsserver direkt in OpenChamber. +--- + +# Vorschau & Entwicklungsserver + +Wenn du einen Entwicklungsserver startest, kann OpenChamber ihn direkt in der App öffnen statt in einem separaten Browser-Tab — so kannst du deine Seite neben dem Chat sehen, ihre Konsole aufzeichnen und Elemente anstupsen, um Fragen dazu zu stellen. + +## Eine Vorschau öffnen + +OpenChamber überwacht die Terminalausgabe auf eine lokale Adresse (die `Local:`-Zeile, die Werkzeuge wie Vite, Next.js oder Astro ausgeben). Sobald es eine findet: + +- erscheint im Terminal eine Schaltfläche **Open preview** +- öffnet eine [Projektaktion](/project-actions/) mit aktiviertem Auto-Open die Vorschau für dich +- kann auch ein lokaler Link in einer Chatnachricht sie öffnen + +Die Seite lädt im Seitenbereich. Nur lokale Adressen (auf deinem eigenen Rechner) können als Vorschau geöffnet werden. + +## Konsole und Inspektion + +Im Vorschau-Bereich kannst du: + +- die **Konsole** der Seite beobachten — Fehler, Warnungen und Protokolle, so gefiltert, wie du möchtest +- **inspect** einschalten, auf ein beliebiges Element klicken und eine Notiz dazu senden — Selektor, Stile, Position und ein Screenshot — direkt in den Chat + +Das ist der schnellste Weg, dem Agenten "diese Schaltfläche hier" zu sagen, ohne sie beschreiben zu müssen. + +## Verwandt + +- [Projektaktionen](/project-actions/) — einen Server automatisch öffnen, wenn du ihn startest +- [Desktop-Browser](/desktop-browser/) — dieselben Werkzeuge für jede Seite, auf dem Desktop diff --git a/packages/docs/content/docs/de/private-relay.mdx b/packages/docs/content/docs/de/private-relay.mdx new file mode 100644 index 00000000..e14ad627 --- /dev/null +++ b/packages/docs/content/docs/de/private-relay.mdx @@ -0,0 +1,44 @@ +--- +title: Privates Relay +description: Erreiche deinen OpenChamber-Server von überall über ein Ende-zu-Ende-verschlüsseltes Relay — ohne Ports, ohne Tunnel, ohne Einrichtung. +--- + +# Privates Relay + +Das OpenChamber Private Relay ermöglicht es deinen gekoppelten Geräten, deinen Server von überall aus zu erreichen — im Mobilfunknetz, im Café, in einer anderen Stadt — ohne Ports zu öffnen, einen Tunnel einzurichten oder deinen Rechner direkt mit dem Internet zu verbinden. Die Einrichtung ist selbstständig: Ein Gerät mit **Anywhere** unter [Connect a Device](/connect-devices/) zu koppeln reicht aus. + +## So funktioniert es + +Dein Server baut eine ausgehende Verbindung zur Relay-Infrastruktur von OpenChamber auf und hält sie offen. Wenn eines deiner Geräte außerhalb deines Netzwerks ist, verbindet es sich ebenfalls mit dem Relay, und das Relay leitet die verschlüsselten Daten zwischen beiden weiter. Auf deinem Rechner lauscht nichts auf eingehende Verbindungen aus dem Internet. + +Wenn eine direkte Verbindung verfügbar ist — du bist wieder zu Hause im selben WLAN — bevorzugen deine Geräte sie und umgehen das Relay vollständig. + +## Was das Relay sehen kann und was nicht + +Das Relay ist ein blinder Kurier, kein Vermittler: + +- **Ende-zu-Ende verschlüsselt.** Dein Gerät und dein Server handeln Verschlüsselungsschlüssel direkt miteinander aus. Das Relay leitet nur versiegelte Daten weiter, für die es keine Schlüssel hat — es kann weder deinen Code noch deine Prompts oder Passwörter lesen. +- **Nur deine Geräte können sich verbinden.** Ein Gerät muss ein Token besitzen, das über [one-time pairing](/connect-devices/) von *deinem* Server ausgestellt wurde. Niemand kann deinen Server über das Relay entdecken oder ohne ein von dir erstelltes Token darauf zugreifen — und du kannst jedes Token jederzeit widerrufen. +- **Pairing-Links sind nur einmal verwendbar.** Ein QR-Code zum Koppeln funktioniert genau einmal und läuft ab, wenn er nicht verwendet wird; ein geleakter alter Link ist also wertlos. +- **Nichts wird geteilt, bevor du es erlaubst.** Das Relay bleibt aus, bis du es aktivierst oder ein Gerät darüber koppelst, und du kannst es jederzeit deaktivieren — Geräte, die darüber verbunden sind, werden sofort getrennt. + +## Wann es läuft + +Das Relay verwaltet seinen Lebenszyklus selbst — es gibt keinen Schalter, den du dir merken musst: + +- **Es startet bei Bedarf.** Das Erstellen einer **Anywhere**-Kopplung schaltet das Relay ein, und es kommt nach einem Neustart zurück, solange noch ein gekoppeltes Gerät davon abhängt. +- **Es stoppt von selbst.** Sobald kein Gerät und keine ausstehende Kopplung das Relay mehr nutzt — zum Beispiel nachdem du das letzte Relay-gekoppelte Gerät widerrufen hast — fährt es automatisch herunter. + +**Settings → Remote Instances → OpenChamber Relay** zeigt den Live-Status (Connected, Reconnecting, …) und wie viele Geräte gerade darüber verbunden sind. Dort kannst du auch **Disable** drücken, um den Relay-Zugriff sofort zu beenden; Geräte im lokalen Netzwerk sind davon nicht betroffen. + +## Relay oder ein Tunnel? + +- Nutze das **Relay**, um von deinen eigenen gekoppelten Geräten aus deinen eigenen Server zu erreichen. Es kommt ohne Einrichtung aus, und nichts ist öffentlich exponiert. +- Nutze einen [Tunnel](/tunnels/), wenn du eine einfache **öffentliche URL** brauchst — zum Beispiel um OpenChamber in einem normalen Browser auf einem Rechner zu öffnen, den du nicht koppeln kannst, oder um Zugriff hinter einem [UI password](/security/) zu teilen. + +## Verwandt + +- [Connect a Device](/connect-devices/) — ein Gerät mit einem einmaligen QR-Code koppeln +- [Mobile Apps](/mobile/) — die iOS- oder Android-App installieren +- [Security](/security/) — Passwörter, Passkeys und Grundlagen der Freigabe +- [Remote access](/troubleshooting/remote-access/) — wenn eine Verbindung nicht zustande kommt diff --git a/packages/docs/content/docs/de/project-actions.mdx b/packages/docs/content/docs/de/project-actions.mdx new file mode 100644 index 00000000..a0ef9b78 --- /dev/null +++ b/packages/docs/content/docs/de/project-actions.mdx @@ -0,0 +1,28 @@ +--- +title: Projektaktionen +description: Speichere Befehle, die du oft ausführst, und starte sie mit einem Klick. +--- + +# Projektaktionen + +Eine Projektaktion ist ein Shell-Befehl, den du einmal speicherst und dann mit einem Klick ausführst — dein Entwicklungsserver, ein Build, ein Testlauf. Jedes Projekt hat seine eigene Liste. Richte sie unter **Einstellungen → Projekte → Projektaktionen** ein. + +## Eine Aktion hinzufügen + +1. Öffne **Einstellungen → Projekte** und suche den Bereich **Projektaktionen**. +2. Füge eine Aktion hinzu, gib ihr einen Namen, wähle ein Symbol und gib den auszuführenden Befehl ein. +3. Speichern. + +Du kannst eine Aktion auf bestimmte Betriebssysteme beschränken, wenn ein Befehl nur auf einem davon sinnvoll ist. + +## Eine Aktion ausführen + +Aktionen liegen in einem Menü im App-Header. Klicke auf eine und OpenChamber führt sie in einem Terminal in deinem Projektordner aus und wechselt dich in die Terminalansicht, damit du die Ausgabe verfolgen kannst. Stoppe sie über dasselbe Menü. + +## Einen Entwicklungsserver automatisch öffnen + +Aktiviere **auto-open URL** für eine Aktion, die einen Server startet. OpenChamber überwacht die Ausgabe auf eine lokale Adresse und bietet an, sie zu öffnen — siehe [Vorschau & Entwicklungsserver](/preview/). Auf dem Desktop kannst du sie außerdem über einen SSH-Port-Forward leiten. + +## Verwandt + +- [Vorschau & Entwicklungsserver](/preview/) — einen laufenden Entwicklungsserver in OpenChamber öffnen diff --git a/packages/docs/content/docs/de/project-icons.mdx b/packages/docs/content/docs/de/project-icons.mdx new file mode 100644 index 00000000..3e0775ec --- /dev/null +++ b/packages/docs/content/docs/de/project-icons.mdx @@ -0,0 +1,20 @@ +--- +title: Projektsymbole +description: Geben Sie jedem Projekt ein wiedererkennbares Symbol. +--- + +# Projektsymbole + +Ein Projektsymbol macht es leicht, Ihre Projekte auf einen Blick zu unterscheiden. OpenChamber versucht, eines für Sie zu finden, und Sie können jederzeit Ihr eigenes setzen. Verwalten Sie es unter **Einstellungen → Projekte**. + +## Automatische Erkennung + +Wenn Sie ein Projekt hinzufügen, sucht OpenChamber darin nach einer `favicon`-Datei und verwendet sie als Symbol des Projekts. Wenn Ihr Repo bereits ein Favicon ausliefert, erscheint das Symbol meist einfach — Sie müssen nichts tun. + +## Eigenes festlegen + +Öffnen Sie **Einstellungen → Projekte** und laden Sie ein Bild hoch (PNG, JPEG oder SVG, bis zu 5 MB). Ein benutzerdefiniertes Bild hat Vorrang vor dem automatisch erkannten. Sie können stattdessen auch eine Farbe wählen oder das Bild entfernen, um zur Erkennung zurückzukehren. + +## Verwandt + +- [Projekte](/projects/) — Ihre Projekte benennen, einfärben und organisieren diff --git a/packages/docs/content/docs/de/projects.mdx b/packages/docs/content/docs/de/projects.mdx new file mode 100644 index 00000000..8baf4c22 --- /dev/null +++ b/packages/docs/content/docs/de/projects.mdx @@ -0,0 +1,34 @@ +--- +title: Projekte +description: Organisiere deine Arbeit in Projekten und wechsle zwischen ihnen. +--- + +# Projekte + +Ein Projekt ist ein Ordner auf deinem Computer, den OpenChamber im Blick behält — normalerweise eine Codebasis. Wenn du das Projekt wechselst, wechselt der Ordner, in dem der Agent arbeitet, zusammen mit den Sessions und Einstellungen dieses Projekts. + +## Ein Projekt hinzufügen + +Du kannst ein Projekt an ein paar Stellen hinzufügen: + +- der Eintrag **Projekt hinzufügen** in der Befehlspalette +- die Schaltfläche **+** oben in der Session-Seitenleiste +- der Ordnerbrowser, wenn du ein Verzeichnis auswählst + +Zeige auf einen Ordner, und OpenChamber merkt ihn sich. Der Name kommt vom Ordner; du kannst ihn später ändern. + +## Projekte wechseln + +Wähle ein Projekt in der Seitenleiste aus, um es aktiv zu machen. Alles — Sessions, Git, Notizen — folgt dem Projekt, das du geöffnet hast. + +## Ein Projekt wiedererkennbar machen + +Öffne **Einstellungen → Projekte**, um einem Projekt einen eigenen Namen, eine Farbe oder ein Symbol zu geben. OpenChamber versucht, automatisch ein Symbol zu finden — siehe [Projekt-Symbole](/project-icons/). + +> In VS Code verwendet OpenChamber immer den Ordner, den du geöffnet hast, als einziges Projekt. Es gibt dort also nichts hinzuzufügen oder umzuschalten. Die Seite mit den Projekteinstellungen ist dort ausgeblendet. + +## Verwandtes + +- [Projektnotizen, Todos & Pläne](/notes-todos-plans/) — Arbeitsnotizen pro Projekt behalten +- [Projektaktionen](/project-actions/) — Befehle speichern, die du oft ausführst +- [Kontext](/context/) — sehen, wie viel des Modellgedächtnisses eine Session verbraucht diff --git a/packages/docs/content/docs/de/providers.mdx b/packages/docs/content/docs/de/providers.mdx new file mode 100644 index 00000000..4d5738f5 --- /dev/null +++ b/packages/docs/content/docs/de/providers.mdx @@ -0,0 +1,49 @@ +--- +title: Provider, Modelle & Agents +description: Verbinde KI-Provider, wähle Modelle aus und richte Agents ein. +--- + +# Provider, Modelle & Agents + +Bevor OpenChamber etwas tun kann, braucht es mindestens einen verbundenen KI-Provider. Diese Seite erklärt, wie du einen Provider verbindest, ein Modell auswählst und Agents anpasst. + +## Einen Provider verbinden + +1. Öffne **Einstellungen → Provider**. +2. Öffne das Menü **Provider hinzufügen** und wähle einen Provider aus, der noch nicht verbunden ist. +3. Melde dich auf eine von zwei Arten an, je nach Provider: + - **API-Schlüssel** — füge deinen Schlüssel ein und speichere. + - **Anmeldung (Gerätefluss)** — OpenChamber zeigt einen Link und einen kurzen Code an. Öffne den Link, gib den Code ein und bestätige. OpenChamber schließt die Verbindung dann selbstständig ab. + +Wenn ein Provider als verbunden angezeigt wird, sind seine Modelle im Chat verfügbar. + +Zum Trennen öffnest du den Provider und entfernst seine Anmeldung. + +## Ein Modell auswählen + +Du wählst das Modell dort aus, wo du arbeitest: + +- im Chat verwendest du den Modellwähler in der Nachrichtenleiste, um Provider und Modell für diese Sitzung festzulegen +- pro Agent legst du unten ein Standardmodell fest + +## Agents einrichten + +Ein Agent ist eine benannte Konfiguration — ein Modell, eine Persönlichkeit und das, was er tun darf. + +1. Öffne **Einstellungen → Agents**. +2. Wähle einen Agenten aus oder erstelle einen neuen. +3. Bearbeite beliebige dieser Felder: + - **Beschreibung** — wofür der Agent gedacht ist + - **Modell** — sein Standardmodell + - **Temperatur** — wie kreativ seine Antworten sind + - **Prompt** — dauerhafte Anweisungen, denen er immer folgt + - **Tool-Regeln** — welche Werkzeuge er verwenden darf + +## Wo deine Anmeldungen gespeichert werden + +Provider-Anmeldungen werden von OpenCode gespeichert, nicht von OpenChamber, daher sind sie mit der OpenCode-CLI geteilt. Wenn du denselben Provider an mehr als einem Ort einrichtest, gewinnt die spezifischste Einstellung: Eine projektbezogene Einstellung überschreibt deine persönliche. + +## Weiterführend + +- [MCP-Server](/mcp/) — füge Agents zusätzliche Werkzeuge hinzu +- [Nutzung & Kontingente](/usage/) — verfolge, wie viel du verbraucht hast diff --git a/packages/docs/content/docs/de/quickstart.mdx b/packages/docs/content/docs/de/quickstart.mdx new file mode 100644 index 00000000..5411db01 --- /dev/null +++ b/packages/docs/content/docs/de/quickstart.mdx @@ -0,0 +1,26 @@ +--- +title: Schnellstart +description: Starte OpenChamber schnell und wähle die richtige App für die Aufgabe. +--- + +# Schnellstart + +## Schnellster Weg + +1. Installiere [OpenCode](https://opencode.ai). +2. Installiere das OpenChamber-CLI (siehe [Installieren](/install/) für den Einzeilenbefehl). +3. Führe `openchamber --ui-password be-creative-here` aus. +4. Öffne die URL, die das CLI ausgibt (normalerweise `http://localhost:3000`). +5. Um es auf deinem Handy zu nutzen, starte einen [Tunnel](/tunnels/) und scanne den QR-Code. + +Du solltest die OpenChamber-Session-Liste in deinem Browser sehen. Wenn sie geladen wird, bist du startklar. + +Verwende ein starkes UI-Passwort, besonders wenn du die Instanz ins Internet öffnen willst. + +Wenn die Seite nicht lädt, sieh unter [Troubleshooting](/troubleshooting/) nach. + +## Welche App soll ich verwenden? + +- verwende **Desktop** für die tägliche Arbeit auf macOS +- verwende **Web** für den Remote-Zugriff und zum Prüfen vom Handy aus +- verwende **VS Code** für Sessions direkt neben deinem Code diff --git a/packages/docs/content/docs/de/remote-instances.mdx b/packages/docs/content/docs/de/remote-instances.mdx new file mode 100644 index 00000000..91bb8216 --- /dev/null +++ b/packages/docs/content/docs/de/remote-instances.mdx @@ -0,0 +1,48 @@ +--- +title: Remote-Instanzen +description: Verbinde die Desktop-App per SSH mit OpenChamber auf einem anderen Rechner. +--- + +# Remote-Instanzen + +Die Desktop-App kann per SSH mit OpenChamber auf einem anderen Rechner verbunden werden — einem Arbeitsserver, einer Cloud-Box, einem Homelab — und dessen UI so auf deinen Bildschirm bringen, als wäre sie lokal. Richte das unter **Settings → Remote Instances** ein. + +> Remote-Instanzen sind eine Funktion **nur für den Desktop**. Im Web oder in VS Code verbindest du dich stattdessen über die Umgebungsvariablen in [OpenCode Server](/opencode-server/) mit einem Remote-Server. + +## Remote-Instanz hinzufügen + +1. Öffne **Settings → Remote Instances** und füge eine hinzu. +2. Gib den SSH-Befehl an, den du normalerweise für den Zugriff auf den Rechner verwendest, plus einen Spitznamen. +3. Wähle, wie OpenChamber dort läuft: + - **managed** — OpenChamber installiert und startet sich auf dem entfernten Rechner selbst + - **external** — verbinde dich mit einer Instanz, die bereits läuft +4. Verbinde dich. + +OpenChamber geht die Schritte durch — Verbindung prüfen, Remote einrichten, Server starten und den Port weiterleiten — und zeigt bei jedem Schritt, wo es gerade ist. Wenn **ready** erreicht ist, wird die entfernte UI lokal geladen. + +## Anmeldedaten + +Du entscheidest, ob SSH- und UI-Passwörter gespeichert werden oder ob du sie jedes Mal eingibst. Wenn die Verbindung abbricht, meldet OpenChamber, welcher Schritt fehlgeschlagen ist, damit du ihn beheben kannst — siehe [Remote access](/troubleshooting/remote-access/). + +## Verbindungslinks + +Wenn auf einem entfernten Rechner bereits OpenChamber läuft, ist ein Kopplungslink der einfachste Weg, die Desktop-App zu verbinden. Öffne auf der UI des entfernten Servers **Settings → Remote Instances → Connect to this server → Add a device**, erstelle einen Link und importiere ihn auf deinem Desktop unter **Settings → Remote Instances → Other OpenChamber servers → Import Link**. Den vollständigen Ablauf findest du unter [Connect a Device](/connect-devices/). + +Ein mit **Anywhere** erstellter Link enthält sowohl eine direkte Adresse als auch eine Route über das [Private Relay](/private-relay/): Die Desktop-App verbindet sich direkt, wenn sie den Server erreichen kann (gleiches Netzwerk), und fällt auf das Ende-zu-Ende-verschlüsselte Relay zurück, wenn du unterwegs bist. Der Status neben jedem gespeicherten Server zeigt, welche Route gerade verwendet wird. + +Du kannst einen Link auch in einem Terminal auf dem entfernten Rechner erstellen: + +```bash +openchamber connect-url --port 3000 --server http://your-host:3000 --qr +``` + +`connect-url` startet den Server zuerst, wenn auf diesem Port nichts läuft. Füge `--api-only` für einen headless Server hinzu, `--lan`, um beim Starten an das LAN zu binden, `--ui-password`, um den Browserzugriff zu schützen, und `--name`, um die gespeicherte Verbindung zu benennen. Füge `--relay` für einen Link hinzu, der auch außerhalb des lokalen Netzwerks funktioniert: Das Gerät bevorzugt die direkte Verbindung, wenn sie erreichbar ist, und fällt auf das [Private Relay](/private-relay/) zurück — die Instanz startet das Relay selbstständig. + +Der erzeugte Link enthält ein Einmal-Geheimnis zum Koppeln. Nach dem Import besitzt das Gerät sein eigenes Client-Token — getrennt vom UI password des Browsers —, das Server-Neustarts überdauert, bis du es auf dem ausstellenden Server widerrufst. + +## Verwandt + +- [Connect a Device](/connect-devices/) — Kopplungslinks, QR-Codes und Geräteverwaltung +- [Private Relay](/private-relay/) — wie "Anywhere"-Verbindungen funktionieren +- [OpenCode Server](/opencode-server/) — eine Verbindung zu einem entfernten Server in Web oder VS Code herstellen +- [Remote access](/troubleshooting/remote-access/) — wenn eine Verbindung nicht zustande kommt diff --git a/packages/docs/content/docs/de/reverse-proxy.mdx b/packages/docs/content/docs/de/reverse-proxy.mdx new file mode 100644 index 00000000..40e4221b --- /dev/null +++ b/packages/docs/content/docs/de/reverse-proxy.mdx @@ -0,0 +1,347 @@ +--- +title: Reverse Proxy +description: Konfigurieren Sie OpenChamber korrekt hinter Nginx, Nginx Proxy Manager oder einem anderen Reverse Proxy. +--- + +# Reverse Proxy + +Nutzen Sie diese Seite, wenn OpenChamber hinter Nginx, Nginx Proxy Manager, Caddy, Cloudflare oder einem anderen Reverse Proxy läuft. + +## Bevor Sie es proxyen + +1. Bestätigen Sie zuerst, dass OpenChamber direkt funktioniert. +2. Öffnen Sie `http://:3000` oder Ihren benutzerdefinierten Port aus demselben Netzwerk. +3. Fügen Sie den Reverse Proxy erst hinzu, wenn die Direktverbindung funktioniert. + +## Was der Proxy unterstützen muss + +- WebSockets für den Live-Nachrichtenverkehr: + - `/api/event/ws` + - `/api/global/event/ws` + - `/api/terminal/ws` +- SSE ohne Buffering: + - `/api/event` + - `/api/global/event` + - `/api/notifications/stream` + - `/api/openchamber/events` + - `/api/terminal/:sessionId/stream` +- Große Request-Bodies für Anhänge und Dateioperationen +- Lange Read-Timeouts für Live-Streams und Terminal-Sitzungen + +## Wichtige Regeln + +- Aktivieren Sie das Proxying für WebSockets. +- Deaktivieren Sie Buffering auf SSE-Routen. +- Deaktivieren Sie gzip auf dem Proxy, wenn OpenChamber Antworten bereits komprimiert. +- Halten Sie Komprimierung nur in einer Ebene aktiv. +- Leiten Sie normale Proxy-Header wie `Host`, `X-Forwarded-For` und `X-Forwarded-Proto` weiter. +- Erhöhen Sie die Body-Größenlimits, wenn Nutzer Dateien hochladen. + +## Kurze Checkliste + +- OpenChamber direkt im LAN erreichbar +- WebSockets im Proxy aktiviert +- SSE-Routen ohne Buffering +- `gzip off` auf dem Proxy-Host oder Komprimierung anderweitig deaktiviert +- `client_max_body_size` groß genug für Anhänge +- `proxy_read_timeout` lang genug für Streams + +## Beispiel: Nginx + +
+Beispielkonfiguration anzeigen + +```nginx +client_max_body_size 50M; +client_body_buffer_size 50M; +proxy_request_buffering off; + +proxy_http_version 1.1; +proxy_set_header Connection ""; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; + +gzip off; + +location = /api/terminal/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location = /api/global/event/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location = /api/event/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location ~ ^/api/(event|global/event|notifications/stream|openchamber/events)$ { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location ~ ^/api/terminal/.+/stream$ { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location /api { + proxy_pass http://127.0.0.1:3000; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; +} + +location / { + proxy_pass http://127.0.0.1:3000; +} +``` + +
+ +## Beispiel: Nginx Proxy Manager + +
+Beispiel für den Tab „Advanced“ anzeigen + +```nginx +client_max_body_size 50M; +client_body_buffer_size 50M; +proxy_request_buffering off; + +proxy_http_version 1.1; +proxy_set_header Connection ""; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; + +gzip off; + +location = /api/terminal/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/global/event/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/event/ws { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/event { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/global/event { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/notifications/stream { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location = /api/openchamber/events { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location ~ ^/api/terminal/.+/stream$ { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Accept "text/event-stream"; + proxy_set_header Cache-Control "no-cache"; + proxy_buffering off; + proxy_cache off; + gzip off; + add_header X-Accel-Buffering "no" always; + add_header Cache-Control "no-cache, no-transform" always; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location /api { + proxy_pass http://127.0.0.1:3000; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_connect_timeout 30s; +} + +location / { + proxy_pass http://127.0.0.1:3000; +} +``` + +
+ +Aktivieren Sie für diesen Host außerdem `Websockets Support` in Nginx Proxy Manager. + +## Häufige Fehlersymptome + +### Seite lädt, aber das Senden von Nachrichten schlägt fehl + +- WebSockets sind im Proxy nicht aktiviert +- `/api/event/ws` oder `/api/global/event/ws` wird nicht korrekt weitergeleitet + +### Benachrichtigungen oder Live-Status werden nicht aktualisiert + +- eine der SSE-Routen wird gepuffert oder gecacht +- `X-Accel-Buffering "no"` fehlt + +### Datei-Uploads schlagen fehl + +- `client_max_body_size` ist zu klein + +### Alles funktioniert lokal, bricht aber nur hinter dem Proxy + +- der Proxy komprimiert und puffert Live-Verkehr +- dem Proxy fehlt WebSocket-Unterstützung + +## Beispiel: Caddy + +
+Beispielkonfiguration anzeigen + +```caddy +reverse_proxy 127.0.0.1:3000 { + # WebSocket-Unterstützung ist in Caddy automatisch vorhanden + + # SSE-Antworten sofort flushen + flush_interval -1 + + # Host- und Proxy-Header weiterreichen + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + + # Timeouts für langlebige Streams erhöhen + transport http { + read_timeout 3600s + write_timeout 3600s + } +} +``` + +
+ +Caddy behandelt WebSocket-Upgrades automatisch — keine zusätzliche Konfiguration nötig. Die Direktive `flush_interval -1` sorgt dafür, dass SSE-Teilstücke ohne Buffering sofort weitergeleitet werden. + +## Warnung zu CDN und doppelter Komprimierung + +Wenn Sie ein CDN (wie Cloudflare) vor Ihren Reverse Proxy setzen, achten Sie auf doppelte Komprimierung: + +- OpenChamber komprimiert HTTP-Antworten mit gzip (Schwelle 1 KB). +- Cloudflare und andere CDNs komprimieren Antworten standardmäßig ebenfalls. +- Das kann zu doppelt komprimierten Antworten oder falschen `Content-Encoding`-Headern führen. + +Um das zu vermeiden, deaktivieren Sie die Komprimierung auf **einer** Ebene: + +- **Cloudflare:** Rules → Compression → deaktivieren (oder den Modus „Passthrough“ verwenden). +- **Nginx:** `gzip off` (oben in den Beispielen bereits gezeigt). +- **Caddy:** Caddy komprimiert standardmäßig nicht erneut, wenn der Upstream bereits komprimierte Inhalte sendet. + +SSE-Streaming-Routen sind von OpenChamber von der Komprimierung ausgeschlossen, das CDN kann sie aber trotzdem puffern. Prüfen Sie in der CDN-Dokumentation, wie sich Buffering auf SSE-Pfaden deaktivieren lässt. + +## Verwandt + +- [Tunnels](/tunnels/) +- [Fehlerbehebung](/troubleshooting/) diff --git a/packages/docs/content/docs/de/scheduled-tasks.mdx b/packages/docs/content/docs/de/scheduled-tasks.mdx new file mode 100644 index 00000000..ac80c229 --- /dev/null +++ b/packages/docs/content/docs/de/scheduled-tasks.mdx @@ -0,0 +1,35 @@ +--- +title: Geplante Aufgaben +description: Führe einen Prompt automatisch nach Zeitplan aus. +--- + +# Geplante Aufgaben + +Eine geplante Aufgabe führt für dich nach Zeitplan einen Prompt aus — zum Beispiel täglich „die Änderungen von gestern zusammenfassen“ oder wöchentlich eine Bereinigung. Wenn sie läuft, startet OpenChamber eine neue Session und sendet den Prompt selbst. Öffne den Scheduler über die Schaltfläche oben in der Session-Seitenleiste. + +## Eine Aufgabe erstellen + +1. Öffne den Dialog für geplante Aufgaben in der Session-Seitenleiste. +2. Füge eine Aufgabe hinzu und gib ihr einen Namen. +3. Wähle, wann sie läuft: + - **daily** — zu einer oder mehreren Zeiten pro Tag + - **weekly** — an ausgewählten Wochentagen und Uhrzeiten + - **once** — ein einzelnes Datum und eine einzelne Uhrzeit +4. Lege fest, was sie tut: den zu sendenden Prompt sowie Provider, Modell und Agent, die verwendet werden sollen. Der Prompt kann ein Slash-Befehl sein, zum Beispiel `/review`. +5. Speichern und sicherstellen, dass die Aufgabe aktiviert ist. + +Du kannst jede Aufgabe sofort mit **run now** ausführen, um zu prüfen, ob sie das tut, was du erwartest. + +Aktiviere **Run as goal**, damit der Lauf seinen Prompt bis zum Abschluss verfolgt, statt nach einer Antwort zu stoppen — siehe [Session Goals](/session-goals/). + +## Woran du Erfolg erkennst + +Nach einem Lauf zeigt die Aufgabe, wann sie zuletzt gelaufen ist, ob sie erfolgreich war und einen Link zur Session, die sie erstellt hat. Wenn ein Lauf fehlschlägt, wird dort auch der Fehler angezeigt. + +## Wichtig zu wissen + +Aufgaben werden nur ausgelöst, während der OpenChamber-Server läuft. Wenn du ihn schließt, sind geplante Läufe pausiert, bis er wieder online ist. + +## Verwandtes + +- [Commands & Snippets](/commands-snippets/) — einen Slash-Befehl als Prompt wiederverwenden diff --git a/packages/docs/content/docs/de/security.mdx b/packages/docs/content/docs/de/security.mdx new file mode 100644 index 00000000..e00bb7cd --- /dev/null +++ b/packages/docs/content/docs/de/security.mdx @@ -0,0 +1,44 @@ +--- +title: Sicherheit +description: Schützen Sie die UI mit einem Passwort und Passkeys, bevor Sie sie freigeben. +--- + +# Sicherheit + +OpenChamber gewährt Zugriff auf Ihren Rechner und Ihren Code. Sperren Sie es also ab, bevor jemand außer Ihnen darauf zugreifen kann. Diese Seite behandelt das UI-Passwort, Passkeys und was Sie wissen sollten, bevor Sie OpenChamber in ein Netzwerk exponieren. + +## UI-Passwort setzen + +Starten Sie OpenChamber mit einem Passwort, und die Browser-UI fragt danach: + +```bash +openchamber --ui-password be-creative-here +``` + +Sie können es auch über die Umgebungsvariable `OPENCHAMBER_UI_PASSWORD` setzen, statt es in die Befehlszeile zu schreiben. Nach der Anmeldung merkt sich OpenChamber das Gerät für eine Weile, damit Sie nicht jedes Mal gefragt werden. + +Setzen Sie immer ein Passwort, wenn die Instanz für andere erreichbar ist — besonders über einen [Tunnel](/tunnels/) oder das öffentliche Internet. + +## Passkeys + +Sobald ein Passwort gesetzt ist, können Sie Passkeys (Face ID, Touch ID, ein Sicherheitsschlüssel) für eine schnellere Anmeldung hinzufügen. Fügen Sie sie unter **Einstellungen → OpenChamber → Passkeys** hinzu. + +Passkeys sind an das aktuelle Passwort gebunden. Wenn Sie das Passwort ändern oder entfernen, werden gespeicherte Passkeys gelöscht und Sie fügen sie erneut hinzu. + +## Geräte-Tokens + +Geräte, die über [Ein Gerät verbinden](/connect-devices/) gekoppelt werden, authentifizieren sich mit eigenen gerätespezifischen Tokens, nicht mit dem UI-Passwort. Pairing-Links sind nur einmal nutzbar und laufen ab, wenn sie nicht verwendet werden; jedes gekoppelte Gerät wird unter **Einstellungen → Remote-Instanzen → Mit diesem Server verbinden** aufgeführt, wo Sie es jederzeit widerrufen können. Verbindungen von außerhalb laufen über das [Private Relay](/private-relay/), das Ende-zu-Ende verschlüsselt ist und Ihren Traffic nicht lesen kann. + +## Bevor Sie es freigeben + +- Standardmäßig lauscht OpenChamber nur auf Ihrem eigenen Rechner (`127.0.0.1`). Es braucht eine bewusste Änderung, um breiter zu lauschen, und Sie sollten vorher ein Passwort setzen. +- Für Ihre eigenen Geräte bevorzugen Sie [Pairing](/connect-devices/) mit dem [Private Relay](/private-relay/) — dabei wird überhaupt nichts öffentlich freigegeben. +- Wenn Sie eine öffentliche URL brauchen, verwenden Sie lieber einen [Tunnel](/tunnels/) oder ein privates Netzwerk (z. B. VPN), statt einen Port ins Internet zu öffnen. +- Wenn Sie OpenChamber hinter Ihrem eigenen HTTPS-Server betreiben, sehen Sie sich [Reverse Proxy](/reverse-proxy/) an. + +## Verwandt + +- [Ein Gerät verbinden](/connect-devices/) — einmaliges Pairing und gerätespezifische Tokens +- [Private Relay](/private-relay/) — Ende-zu-Ende-verschlüsselter Zugriff von überall +- [Tunnel](/tunnels/) — eine öffentliche URL freigeben, wenn Sie eine brauchen +- [Reverse Proxy](/reverse-proxy/) — OpenChamber hinter Ihrem eigenen Server betreiben diff --git a/packages/docs/content/docs/de/session-goals.mdx b/packages/docs/content/docs/de/session-goals.mdx new file mode 100644 index 00000000..3b7a2bf1 --- /dev/null +++ b/packages/docs/content/docs/de/session-goals.mdx @@ -0,0 +1,73 @@ +--- +title: Sitzungsziele +description: Verwandelt eine Aufforderung in ein Ziel, auf das der Agent automatisch hinarbeitet. +--- + +# Sitzungsziele + +Ein Ziel macht aus einer Aufforderung eine Ziellinie. Statt den Agenten nach jeder Antwort mit "continue" anzustupsen, legst du das Ziel einmal fest — und OpenChamber hält die Sitzung automatisch darauf ausgerichtet und prüft nach jedem Zug mit einem unabhängigen Prüfer den Fortschritt. Es läuft weiter, auch wenn du nicht da bist. + +## Ein Ziel starten + +1. Drücke in der Composer-Ansicht die Zielschaltfläche. Sie leuchtet auf — der Zielmodus ist aktiviert. +2. Gib deine Aufforderung ein und sende sie ab. Diese Nachricht wird zum Ziel des Vorhabens. + +Das funktioniert in einer bestehenden Sitzung ebenso wie in einem neuen Sitzungsentwurf: Zielmodus aktivieren, die erste Nachricht schreiben, senden — die neue Sitzung startet mit dem bereits aktiven Ziel. + +### Weitere Wege, ein Ziel zu starten + +- **Aus einer Antwort des Agenten**: Im Dialog "Start new session from this answer" aktiviere **Run as goal** — die Antwort wird als Aufgabe übergeben, die die neue Sitzung bis zum Abschluss ausführt (kombiniere das mit **Create worktree** für einen isolierten Lauf). +- **Aus einem Plan**: Wenn du einen gespeicherten Plan in einer neuen Sitzung oder einem Worktree umsetzt, aktiviere im Dialog **Run as goal**. Das Ziel übernimmt den Planinhalt als seine Aufgabe, sodass der Prüfer den Fortschritt am tatsächlichen Plan misst. +- **Nach Zeitplan**: Aktiviere bei einer [geplanten Aufgabe](/scheduled-tasks/) **Run as goal**, damit wiederkehrende Läufe ihre Aufforderung bis zum Abschluss verfolgen. + +## Eine eigenständige Zielbeschreibung schreiben + +Der Fortschrittsprüfer sieht nur deine Zielbeschreibung und die neueste Antwort des Agenten — nicht den Chatverlauf. Formuliere die Zielnachricht also so, dass auch jemand ohne Gesprächskontext versteht, wie der fertige Zustand aussieht. + +- Gut: "Füge Tests für das Exportmodul hinzu und sorge dafür, dass die gesamte Test-Suite besteht." +- Nicht so gut: "Beheb das" oder "Mach mit der Idee weiter." + +Für kleine nachgelagerte Rückfragen mit Kontext brauchst du kein Ziel — sende einfach eine normale Nachricht. + +## So funktioniert es + +Nachdem der Agent anhält und die Sitzung für einen Moment ruhig bleibt, macht OpenChamber Folgendes: + +1. Es bittet ein kleines, günstiges Modell, den letzten Zug gegen das Ziel zu prüfen: weitermachen, fertig oder festgefahren? +2. Wenn das Urteil "weitermachen" lautet, sendet es eine Fortsetzungsaufforderung und der Agent nimmt die Arbeit wieder auf. +3. Wenn das Ziel nachweislich erreicht ist, ist es abgeschlossen und du bekommst eine Benachrichtigung. +4. Wenn der Agent wirklich feststeckt (deine Eingabe braucht), wird das Ziel erst dann als blockiert gestoppt — aber nur nachdem der Prüfer das dreimal in Folge gesagt hat, damit ein einzelner Ausrutscher das Ziel nie beendet. + +Es gibt auch harte Sicherheitsstopps: ein optionales Token-Budget, eine Obergrenze für automatische Fortsetzungen und ein Stopp bei Fehlern in einem Zug. Wenn der Kontext der Sitzung mitten in der Arbeit komprimiert wird, geht das Ziel einfach weiter — in das Kontextfenster zu laufen ist ein Beweis dafür, dass die Arbeit noch nicht fertig war. + +### Stoppen und fortsetzen + +- Die **Stopp-Schaltfläche** bricht den laufenden Zug ab und pausiert das Ziel — dein ausdrückliches "stop" gewinnt immer gegen die Schleife. +- **Pause** in der Zielleiste macht aus der anderen Richtung dasselbe: Es pausiert das Ziel und stoppt den laufenden Zug. +- Während der Pause kannst du normal chatten — die Schleife bleibt dir aus dem Weg. +- **Resume** aktiviert die Schleife neu: Bei einer ruhenden Sitzung geht der Fortsetzungsanstupser sofort raus; wenn der Agent gerade arbeitet, hängt sich die Schleife beim nächsten Halt stillschweigend wieder an. + +## Beobachten und verwalten + +- Die Leiste über dem Composer zeigt die neueste Fortschrittsnotiz, den Status und die Token-Nutzung des Ziels sowie eine Inline-Schaltfläche zum Pausieren/Fortsetzen. Wenn der Agent angehalten hat und das Ziel aktiv ist, zeigt die Leiste ein drehendes **Evaluating…** — das ist das ruhige Zeitfenster und der laufende Audit. +- Die Zielschaltfläche bleibt während der Laufzeit blau beleuchtet, wird bei Abschluss grün und bei Blockierung oder erreichtem Budget rot. Drücke sie, um den Zieldialog zu öffnen: Bearbeite das Ziel oder das Budget oder entferne das Ziel. Ein abgeschlossenes Ziel ist schreibgeschützt — entferne es und aktiviere dann ein neues. +- In der Sitzungs-Seitenleiste erscheint neben dem Sitzungsdatum ein kleines Zielsymbol, dessen Farbe den Zustand des Ziels widerspiegelt. + +## Benachrichtigungen + +Während ein Ziel aktiv ist, werden die "Agent ist bereit"-Benachrichtigungen pro Zug unterdrückt — sie würden nur die eigenen Fortsetzungen der Zielschleife wiederholen. Wenn das Ziel zur Ruhe kommt (abgeschlossen, blockiert oder Budget erreicht), bekommst du stattdessen eine letzte Benachrichtigung, auf dem Desktop und als mobile Push-Nachricht. Sie folgt derselben Einstellung "bei Abschluss benachrichtigen"; Berechtigungsanfragen, Fragen und Fehlermeldungen funktionieren währenddessen wie gewohnt weiter. + +## Token-Budget + +In **Einstellungen → Chat → Goal** kannst du ein Standard-Token-Budget für neue Ziele festlegen. Wenn ein Ziel sein Budget erreicht, stoppt es als "budget reached" statt weiter zu verbrauchen — du kannst das Budget erhöhen und im Zieldialog fortsetzen. + +## Beachten + +- Die Zielschleife läuft im OpenChamber-Server, nicht in deinem Browser-Tab. Schließe den Tab, sperre das Telefon — der Agent arbeitet weiter, und du bekommst eine Benachrichtigung, wenn das Ziel zur Ruhe kommt. Der Server (Desktop-App oder `openchamber`-Prozess) muss weiterlaufen. +- Ziele verwenden den eigenen Provider und das Modell deiner Sitzung, einschließlich der Prüferaufrufe — nichts verlässt die Provider, die du bereits nutzt. +- Pro Sitzung immer nur ein Ziel gleichzeitig. + +## Verwandt + +- [Geplante Aufgaben](/scheduled-tasks/) — eine Aufforderung nach Zeitplan ausführen; dort "Run as goal" aktivieren, damit ein geplanter Lauf seine Aufforderung bis zum Abschluss verfolgt +- [Benachrichtigungen](/notifications/) — wie du von einem fertigen Ziel erfährst diff --git a/packages/docs/content/docs/de/skills-catalog.mdx b/packages/docs/content/docs/de/skills-catalog.mdx new file mode 100644 index 00000000..1b1c35a7 --- /dev/null +++ b/packages/docs/content/docs/de/skills-catalog.mdx @@ -0,0 +1,27 @@ +--- +title: Skills-Katalog +description: Fertige Skills durchsuchen und installieren. +--- + +# Skills-Katalog + +Im Skills-Katalog kannst du Skills installieren, die andere veröffentlicht haben, statt selbst welche zu schreiben. Du öffnest ihn unter **Einstellungen → Skills → Katalog**. + +Zum Schreiben eigener Skills siehe [Skills](/skills/). + +## Einen Skill installieren + +1. Öffne den Katalog. +2. Durchsuche die eingebauten Quellen — das Anthropic-Skills-Repo und die ClawdHub-Community-Registry — oder nutze die Suche. +3. Wähle einen Skill aus und installiere ihn. +4. Entscheide, wo er installiert werden soll: für alles, was du tust, oder nur für das aktuelle Projekt. + +Wenn bereits ein Skill mit demselben Namen existiert, fragt OpenChamber, was geschehen soll — überspringen, überschreiben oder für jeden Skill einzeln entscheiden. + +## Eigene Quelle hinzufügen + +Du kannst jedes Git-Repository als Quelle hinzufügen — entweder mit seinem `owner/repo`-Namen oder mit einer vollständigen Git-URL. Private Repositories brauchen Zugriff auf deinem Rechner (einen SSH-Schlüssel oder gespeicherte Anmeldedaten); wenn sich eine Quelle nicht authentifizieren kann, weist der Katalog darauf hin, statt still zu scheitern. + +## Weiterführend + +- [Skills](/skills/) — installierte Skills erstellen und verwalten diff --git a/packages/docs/content/docs/de/skills.mdx b/packages/docs/content/docs/de/skills.mdx new file mode 100644 index 00000000..f0aabce4 --- /dev/null +++ b/packages/docs/content/docs/de/skills.mdx @@ -0,0 +1,30 @@ +--- +title: Skills +description: Erstelle wiederverwendbare Anweisungen, die Agents bei Bedarf laden. +--- + +# Skills + +Ein Skill ist ein wiederverwendbarer Satz von Anweisungen, den ein Agent bei Bedarf laden kann — zum Beispiel „wie wir Commit-Nachrichten schreiben“ oder „unsere API-Konventionen“. Du verwaltest sie unter **Einstellungen → Skills**. + +Wenn du fertige Skills installieren statt eigene schreiben möchtest, siehe [Skills-Katalog](/skills-catalog/). + +## Einen Skill erstellen + +1. Öffne **Einstellungen → Skills**. +2. Erstelle einen Skill und gib ihm einen Namen und eine kurze Beschreibung. Die Beschreibung ist die Grundlage dafür, wann der Agent den Skill anwendet. Sei daher konkret. +3. Schreibe die Anweisungen. Füge unterstützende Dateien hinzu, falls der Skill sie braucht. +4. Wähle den Speicherort: + - **persönlich** — in jedem Projekt verfügbar + - **projektbezogen** — nur im aktuellen Projekt verfügbar + +## Einen Skill im Chat verwenden + +Gib mitten in einer Nachricht `/` ein, um die Skill-Auswahl zu öffnen, und wähle dann einen Skill aus. Der Agent lädt für die Antwort die Anweisungen dieses Skills. + +Ein `/` ganz am Anfang der Nachricht öffnet stattdessen [Befehle](/commands-snippets/). + +## Weiterführend + +- [Skills-Katalog](/skills-catalog/) — installiere Skills, die andere veröffentlicht haben +- [Befehle & Snippets](/commands-snippets/) — weitere Möglichkeiten, Text im Chat wiederzuverwenden diff --git a/packages/docs/content/docs/de/ssh-hosts-proxying.mdx b/packages/docs/content/docs/de/ssh-hosts-proxying.mdx new file mode 100644 index 00000000..4ce4021a --- /dev/null +++ b/packages/docs/content/docs/de/ssh-hosts-proxying.mdx @@ -0,0 +1,48 @@ +--- +title: SSH-Hosts & Proxying +description: Importiere gespeicherte SSH-Hosts und füge in der Desktop-App zusätzliche SSH-Portweiterleitungen hinzu. +--- + +# SSH-Hosts & Proxying + +Verwende in der Desktop-App **Settings → Remote Instances**, um gespeicherte SSH-Hosts zu importieren, dich mit einem entfernten Rechner zu verbinden und über dieselbe SSH-Verbindung zusätzliche Ports bereitzustellen. + +> SSH-Hosts und SSH-Proxying sind Funktionen **nur für den Desktop**. Sie verwenden den SSH-Client auf deinem Rechner. + +## SSH-Host importieren + +OpenChamber kann Hosts aus deiner lokalen SSH-Konfiguration lesen, also aus demselben Ort, den auch Befehle wie `ssh work-server` verwenden. + +1. Öffne **Settings → Remote Instances**. +2. Wähle einen Host unter **Saved SSH hosts**. +3. Wenn es sich um ein Muster handelt, gib das tatsächliche Ziel ein, zum Beispiel `deploy@app.example.com`. +4. Speichern und verbinden. + +OpenChamber erstellt mit diesem SSH-Befehl eine Remote-Instanz. Wenn sie **ready** erreicht, öffnet sich die entfernte OpenChamber-UI in der Desktop-App. + +## Zusätzliche Portweiterleitungen hinzufügen + +Jede Remote-Instanz hat den Bereich **Port Forwards**. Verwende ihn, wenn etwas auf einer Seite der SSH-Verbindung einen Port auf der anderen Seite erreichen muss. + +OpenChamber unterstützt drei Weiterleitungstypen: + +- **Local (-L)** — öffne einen Port auf deinem Rechner, der mit etwas auf dem entfernten Rechner verbunden ist. +- **Remote (-R)** — öffne einen Port auf dem entfernten Rechner, der zurück zu deinem Rechner verbindet. +- **Dynamic (-D)** — öffne einen lokalen SOCKS-Proxy über die SSH-Verbindung. + +Für die meisten App-Vorschauen und Dashboards, die auf dem entfernten Rechner laufen, verwende **Local (-L)**. + +## Den SOCKS-Proxy verwenden + +Wähle **Dynamic (-D)**, wenn andere Tools auf deinem Rechner über den entfernten Rechner browsen sollen. OpenChamber öffnet dafür einen lokalen SOCKS-Proxy-Port für diese SSH-Verbindung. + +Sobald die Verbindung bereit ist, kopiere oder verwende die lokale Proxy-Adresse aus der Weiterleitungszeile. Konfiguriere deinen Browser oder dein Tool so, dass es sie als SOCKS5-Proxy verwendet. + +## Privat halten + +Verwende `127.0.0.1` oder `localhost` für den lokalen Bind-Host, es sei denn, du möchtest ausdrücklich, dass andere Geräte in deinem Netzwerk den weitergeleiteten Port erreichen. + +## Verwandt + +- [Remote Instances](/remote-instances/) — die Desktop-App mit OpenChamber auf einem anderen Rechner verbinden +- [Remote access](/troubleshooting/remote-access/) — wenn SSH oder Remote-Zugriff keine Verbindung herstellen diff --git a/packages/docs/content/docs/de/themes.mdx b/packages/docs/content/docs/de/themes.mdx new file mode 100644 index 00000000..2d4a3c4c --- /dev/null +++ b/packages/docs/content/docs/de/themes.mdx @@ -0,0 +1,30 @@ +--- +title: Themes +description: Passe OpenChamber mit eingebauten und benutzerdefinierten Themes an. +--- + +# Themes + +OpenChamber unterstützt eingebaute Themes und benutzerdefinierte Theme-JSON-Dateien. + +## Ein benutzerdefiniertes Theme hinzufügen + +1. Erstelle das Themes-Verzeichnis: + +```bash +mkdir -p ~/.config/openchamber/themes +``` + +2. Füge deine JSON-Datei in dieses Verzeichnis ein (zum Beispiel `my-theme.json`). +3. Öffne OpenChamber und gehe dann zu **Einstellungen -> Theme -> Themes neu laden**. +4. Wähle dein Theme im Dropdown aus. Es wird sofort angewendet. + +## Speicherort der Themes + +- macOS/Linux: `~/.config/openchamber/themes/` + +## Vollständige Referenz des JSON-Formats + +Verwende die vollständige Formatbeschreibung in den Haupt-Repository-Dokumenten: + +- [`docs/CUSTOM_THEMES.md`](https://github.com/openchamber/openchamber/blob/main/docs/CUSTOM_THEMES.md) diff --git a/packages/docs/content/docs/de/troubleshooting.mdx b/packages/docs/content/docs/de/troubleshooting.mdx new file mode 100644 index 00000000..4e83f2d5 --- /dev/null +++ b/packages/docs/content/docs/de/troubleshooting.mdx @@ -0,0 +1,40 @@ +--- +title: Fehlerbehebung +description: Häufige Einrichtungs- und Laufzeitprobleme mit schnellen Lösungen. +--- + +# Fehlerbehebung + +Hakt es irgendwo? Suchen Sie unten das Symptom und probieren Sie die Lösung. + +## OpenChamber-Befehl beendet sich oder startet nicht + +- bestätigen Sie Node.js `>=22` +- führen Sie `openchamber --version` aus +- installieren Sie die neueste CLI bei Bedarf neu + +## Web-UI ist nicht erreichbar + +- prüfen Sie die Server-Logs mit `openchamber logs` +- verifizieren Sie den aktiven Port (standardmäßig `3000`) +- öffnen Sie `http://localhost:3000` zuerst direkt, bevor Sie Tunnel-Links testen + +## Remote-/Tunnel-Link funktioniert nicht + +- führen Sie `openchamber tunnel status --all` aus +- starten Sie den Tunnel von derselben Instanz / demselben Port neu +- erzeugen Sie den Connect-Link neu, wenn das vorherige Token bereits verwendet wurde + +Die vollständige Einrichtung steht unter [Tunnel](/tunnels/) und [Reverse Proxy](/reverse-proxy/), falls Sie einen nutzen. + +## VS-Code-Erweiterung verbindet sich nicht + +- bestätigen Sie, dass der OpenChamber-Server läuft +- prüfen Sie, ob die Erweiterung aktualisiert ist +- laden Sie das VS-Code-Fenster neu und versuchen Sie die Verbindung erneut + +## Verwandt + +- [Schnellstart](/quickstart/) +- [Tunnel](/tunnels/) +- [Reverse Proxy](/reverse-proxy/) diff --git a/packages/docs/content/docs/de/troubleshooting/opencode-connection.mdx b/packages/docs/content/docs/de/troubleshooting/opencode-connection.mdx new file mode 100644 index 00000000..dd14cdc6 --- /dev/null +++ b/packages/docs/content/docs/de/troubleshooting/opencode-connection.mdx @@ -0,0 +1,34 @@ +--- +title: OpenCode-Verbindung +description: Beheben Sie, dass OpenChamber keine Verbindung zu seinem OpenCode-Server herstellt. +--- + +# OpenCode-Verbindung + +Wenn OpenChamber lädt, aber nie über „OpenCode is restarting“ hinauskommt, oder der Chat nicht antwortet, ist der Server, mit dem es spricht, nicht erreichbar. Gehen Sie diese Punkte durch. + +## Es hängt bei „OpenCode is restarting“ + +- geben Sie ihm direkt nach dem Start einen Moment — dieser Zustand ist normal, während der Server hochfährt +- prüfen Sie mit `openchamber status`, ob der Server noch läuft +- starten Sie ihn mit `openchamber restart` neu +- sehen Sie sich mit `openchamber logs` die Startdetails an + +## Sie verbinden sich mit Ihrem eigenen Server + +Wenn Sie OpenChamber so konfiguriert haben, dass es einen vorhandenen Server nutzt, prüfen Sie die Einrichtung aus [OpenCode Server](/opencode-server/) noch einmal: + +- `OPENCODE_HOST` muss den Port enthalten, aber keinen Pfad — zum Beispiel `http://localhost:4096` +- setzen Sie `OPENCODE_SKIP_START=true`, damit OpenChamber nicht auch noch seinen eigenen Server startet +- wenn die Adresse ungültig ist, ignoriert OpenChamber sie und startet stattdessen seinen eigenen Server — achten Sie in den Logs auf eine `[config]`-Warnung + +## Es schlägt immer noch fehl + +- bestätigen Sie, dass Node.js Version `20` oder neuer ist +- installieren Sie die neueste CLI neu +- öffnen Sie `http://localhost:3000` direkt, bevor Sie einen Tunnel- oder Remote-Link testen + +## Verwandt + +- [OpenCode Server](/opencode-server/) — wie OpenChamber den Server findet und verwaltet +- [Fehlerbehebung](/troubleshooting/) — weitere häufige Probleme diff --git a/packages/docs/content/docs/de/troubleshooting/remote-access.mdx b/packages/docs/content/docs/de/troubleshooting/remote-access.mdx new file mode 100644 index 00000000..377c6eaa --- /dev/null +++ b/packages/docs/content/docs/de/troubleshooting/remote-access.mdx @@ -0,0 +1,47 @@ +--- +title: Remote-Zugriff +description: Beheben Sie Tunnel, Remote-Instanzen und den Zugriff auf OpenChamber von einem anderen Gerät aus. +--- + +# Remote-Zugriff + +Wenn Sie OpenChamber von Ihrem Telefon oder einem anderen Rechner aus nicht erreichen, hängt die Lösung davon ab, wie Sie sich verbinden. + +## Prüfen Sie zuerst die Grundlagen + +- öffnen Sie zuerst `http://localhost:3000` auf demselben Computer — wenn das fehlschlägt, ist es kein Remote-Problem; sehen Sie unter [OpenCode-Verbindung](/troubleshooting/opencode-connection/) nach +- bestätigen Sie mit `openchamber status`, dass der Server läuft + +## Gekoppeltes Gerät verbindet sich nicht + +- der QR-Code / Pairing-Link ist **nur einmal nutzbar** — wenn er bereits gescannt wurde (oder abgelaufen ist), erstellen Sie über **Gerät hinzufügen** einen neuen +- wenn das Gerät mit **Nur Heimnetzwerk** gekoppelt wurde, kann es sich von außerhalb dieses Netzwerks nicht verbinden — koppeln Sie es erneut mit **Überall** +- prüfen Sie bei **Überall**-Pairing auf dem Server **Einstellungen → Remote-Instanzen → OpenChamber Relay**: Dort sollte **Verbunden** stehen; wenn nicht, deaktivieren und reaktivieren Sie es +- wenn ein Gerät **widerrufen** wurde, ist sein Token endgültig weg — koppeln Sie es mit einem neuen QR-Code erneut + +Wie diese Verbindungen funktionieren, steht unter [Ein Gerät verbinden](/connect-devices/) und [Private Relay](/private-relay/). + +## Tunnel-Link funktioniert nicht + +- führen Sie `openchamber tunnel status --all` aus +- starten Sie den Tunnel von derselben Instanz und demselben Port neu +- erzeugen Sie den Connect-Link neu, wenn der vorherige bereits verwendet wurde + +Die vollständige Einrichtung steht unter [Tunnel](/tunnels/). + +## Remote-Instanz verbindet sich nicht (Desktop) + +Wenn eine [Remote-Instanz](/remote-instances/) hängen bleibt, nennt OpenChamber den fehlgeschlagenen Schritt: + +- **auth** — Ihr SSH- oder UI-Passwort wurde abgelehnt; geben Sie es erneut ein +- **install / start** — OpenChamber konnte den Server auf dem entfernten Rechner nicht einrichten oder starten; prüfen Sie die Anforderungen dieses Rechners +- **forwarding** — die Verbindung steht, aber der Port erreicht Sie nicht; versuchen Sie einen anderen lokalen Port + +## Hinter Ihrem eigenen Server + +Wenn Sie OpenChamber hinter einen Reverse Proxy setzen und es seltsam lädt oder keine Verbindung herstellt, sehen Sie sich [Reverse Proxy](/reverse-proxy/) an. + +## Verwandt + +- [Ein Gerät verbinden](/connect-devices/) · [Private Relay](/private-relay/) · [Tunnel](/tunnels/) · [Remote-Instanzen](/remote-instances/) · [Reverse Proxy](/reverse-proxy/) +- [Sicherheit](/security/) — schützen Sie die UI, bevor Sie sie freigeben diff --git a/packages/docs/content/docs/de/troubleshooting/worktrees-git.mdx b/packages/docs/content/docs/de/troubleshooting/worktrees-git.mdx new file mode 100644 index 00000000..4f36d1ac --- /dev/null +++ b/packages/docs/content/docs/de/troubleshooting/worktrees-git.mdx @@ -0,0 +1,35 @@ +--- +title: Worktrees & Git +description: Häufige Probleme mit Worktrees und Git beheben. +--- + +# Worktrees & Git + +Probleme, die bei [Worktree-Sitzungen](/worktrees/) und der [Git-Ansicht](/git/) auftreten, und wie Sie sie beheben. + +## Ein Worktree braucht Aufmerksamkeit + +OpenChamber markiert einen Worktree, wenn etwas nicht stimmt: + +- **Ordner fehlt** — der Ordner des Worktrees wurde gelöscht oder außerhalb von OpenChamber verschoben; entfernen Sie die Sitzung und erstellen Sie einen frischen Worktree +- **abgetrennter oder noch nicht angelegter Branch** — der Worktree liegt nicht auf einem normalen Branch; checken Sie ihn auf einen aus +- **Merge, Rebase oder Cherry-Pick läuft noch** — eine Operation wurde halb fertig gelassen; beenden oder abbrechen Sie sie in der Git-Ansicht + +## Worktree lässt sich nicht erstellen + +- **Branch existiert bereits** — wählen Sie einen anderen Branchnamen oder verwenden Sie die Option für einen bestehenden Branch +- **Name bereits vergeben** — wählen Sie einen anderen Worktree-Namen + +## Commit- oder PR-Erstellung schlägt fehl + +Das Erzeugen einer Commit-Nachricht oder einer PR-Beschreibung läuft in Ihrer aktiven Sitzung, daher brauchen Sie eine geöffnete Sitzung mit ausgewähltem Arbeitsmodell. Öffnen oder wählen Sie eine Sitzung und versuchen Sie es erneut. + +## SSH- oder Windows-Pfadprobleme + +- stellen Sie sicher, dass der SSH-Schlüssel, den das Repo verwendet, derselbe ist, den Sie in Ihrer [Git-Identität](/git-identities/) gesetzt haben +- unter Windows verwendet Git Unix-ähnliche Pfade (wie `/c/Users/...`) — OpenChamber handhabt das, aber benutzerdefinierte SSH-Schlüsselpfade sollten dieselbe Form haben + +## Verwandt + +- [Worktree-Sitzungen](/worktrees/) — wie Worktrees erstellt und entfernt werden +- [Git-Identitäten](/git-identities/) — für jedes Repo den richtigen Schlüssel und die richtige Identität setzen diff --git a/packages/docs/content/docs/de/tunnels.mdx b/packages/docs/content/docs/de/tunnels.mdx new file mode 100644 index 00000000..69475a93 --- /dev/null +++ b/packages/docs/content/docs/de/tunnels.mdx @@ -0,0 +1,120 @@ +--- +title: Tunnel +description: Stelle OpenChamber sicher für Remote- und Mobilzugriff bereit. +--- + +# Tunnel + +Ein Tunnel ist ein öffentlicher Link zu deinem OpenChamber, damit du ihn von einem normalen Browser in einem anderen Netzwerk aus erreichen kannst. Nutze `openchamber tunnel`, um einen für eine laufende Instanz zu erstellen. + +> Wenn du **deine eigenen Geräte** verbindest (die Mobile App, einen anderen Desktop), brauchst du normalerweise keinen Tunnel — [kopple sie](/connect-devices/) stattdessen und lass das Ende-zu-Ende-verschlüsselte [Private Relay](/private-relay/) den Zugriff von unterwegs ohne zusätzliche Einrichtung übernehmen. + +## Voraussetzungen + +OpenChamber startet das CLI des Tunnel-Anbieters auf deinem Rechner. Installiere zuerst den Anbieter, den du verwenden möchtest: + +```bash +brew install cloudflared +brew install ngrok +``` + +Cloudflare Quick Tunnels können mit `cloudflared` laufen. Ngrok benötigt ein ngrok-Konto und ein Authtoken aus dem ngrok-Dashboard: + +```bash +ngrok config add-authtoken +``` + +## Schnellstart + +1. Starte OpenChamber: + +```bash +openchamber +``` + +Wenn du diesen Schritt überspringst, kann `openchamber tunnel start` automatisch einen CLI-Server starten. Beim automatischen Start kannst du Serveroptionen wie `--port`, `--host`, `--lan`, `--ui-password` und `--api-only` mitgeben. + +2. Starte einen Cloudflare-Tunnel: + +```bash +openchamber tunnel start --provider cloudflare --mode quick +``` + +Oder starte einen Ngrok-Tunnel: + +```bash +openchamber tunnel start --provider ngrok --mode quick +``` + +3. Status prüfen: + +```bash +openchamber tunnel status +``` + +Sobald der Tunnel aktiv ist, zeigt `status` eine öffentliche URL. Öffne sie oder scanne den QR-Code, um OpenChamber von überall aus zu erreichen. + +Standardmäßig druckt OpenChamber in interaktiven TTY-Sitzungen einen QR-Code. Mit `--qr` erzwingst du die QR-Ausgabe, mit `--no-qr` deaktivierst du sie. + +## Anbieter + +- `cloudflare`: Quick-, Managed-Remote- und Managed-Local-Modi +- `ngrok`: Quick-Modus + +## Verwaltete Modi + +### Managed Remote + +Verwende ein von Cloudflare verwaltetes Token und einen Hostnamen: + +```bash +openchamber tunnel start --provider cloudflare --mode managed-remote --token-file ~/.secrets/cf-token --hostname app.example.com +``` + +### Managed Local + +Verwende eine lokale `cloudflared`-Konfiguration: + +```bash +openchamber tunnel start --provider cloudflare --mode managed-local --config ~/.cloudflared/config.yml +``` + +## Profile (managed-remote) + +Ein wiederverwendbares Profil speichern: + +```bash +openchamber tunnel profile add --provider cloudflare --mode managed-remote --name prod-main --hostname app.example.com --token-file ~/.secrets/cf-token +``` + +Mit dem gespeicherten Profil starten: + +```bash +openchamber tunnel start --profile prod-main +``` + +## Nützliche Befehle + +```bash +openchamber tunnel providers +openchamber tunnel ready --provider cloudflare +openchamber tunnel ready --provider ngrok +openchamber tunnel doctor --provider cloudflare +openchamber tunnel doctor --provider ngrok +openchamber tunnel stop --port 3000 +``` + +## Hinweise zum Verhalten + +- ein aktiver Tunnel pro OpenChamber-Instanz (Port) +- wenn auf derselben Instanz ein neuer Modus oder Anbieter startet, ersetzt er den vorherigen Tunnel +- das Erstellen eines neuen Verbindungslinks widerruft den vorherigen unbenutzten Link +- das automatische Starten des Tunnels bewahrt Server-Flags wie `--ui-password` und `--api-only` in den Instanzeinstellungen auf, die von Neustart-/Update-Flows verwendet werden + +## Verwandt + +- [Connect a Device](/connect-devices/) — eigene Geräte ohne öffentliche URL koppeln +- [Security](/security/) — die UI schützen, bevor du sie freigibst +- [Desktop Tunnels](/desktop-tunnels/) — Tunnel-Einrichtung in der Desktop-App ohne CLI-Start +- [PWA & Mobile](/mobile/) — OpenChamber vom Telefon aus erreichen +- [Troubleshooting](/troubleshooting/) — wenn der Tunnel-Link nicht funktioniert diff --git a/packages/docs/content/docs/de/updates.mdx b/packages/docs/content/docs/de/updates.mdx new file mode 100644 index 00000000..725de9e1 --- /dev/null +++ b/packages/docs/content/docs/de/updates.mdx @@ -0,0 +1,31 @@ +--- +title: Updates +description: Halten Sie OpenChamber auf Desktop, Web und VS Code auf dem neuesten Stand. +--- + +# Updates + +Wie Sie OpenChamber aktualisieren, hängt davon ab, wie Sie es installiert haben. In jedem Fall können Sie Ihre aktuelle Version unter **Einstellungen → OpenChamber → Über** prüfen. + +## Desktop-App + +Die Desktop-App prüft GitHub-Releases auf Updates. Wenn eines verfügbar ist, informiert OpenChamber Sie, lädt es auf Wunsch herunter und installiert es beim nächsten Neustart. Sie behalten immer die Kontrolle — nichts wird ohne Ihr Zutun installiert. + +## Web / CLI + +Wenn Sie die CLI installiert haben, aktualisieren Sie sie im Bereich **Über** mit den Schaltflächen zum Prüfen und Aktualisieren oder im Terminal: + +```bash +openchamber update +``` + +OpenChamber erkennt, wie es installiert wurde (npm, pnpm, yarn oder bun), und führt das passende Update für Sie aus. + +## Der OpenCode-Server + +OpenChamber und OpenCode werden getrennt aktualisiert. Wenn eine neue OpenCode-Version verfügbar ist, bietet OpenChamber an, sie zu aktualisieren, und startet den Server danach neu. Wie dieser Server verwaltet wird, steht unter [OpenCode Server](/opencode-server/). + +## Verwandt + +- [Installieren](/install/) — wie die jeweilige App überhaupt installiert wird +- [OpenCode Server](/opencode-server/) — den zugrunde liegenden Server aktualisieren diff --git a/packages/docs/content/docs/de/usage.mdx b/packages/docs/content/docs/de/usage.mdx new file mode 100644 index 00000000..d0129fbf --- /dev/null +++ b/packages/docs/content/docs/de/usage.mdx @@ -0,0 +1,28 @@ +--- +title: Nutzung & Kontingente +description: Verfolge, wie viel deines Provider-Tarifs du bereits genutzt hast. +--- + +# Nutzung & Kontingente + +Die Seite Nutzung zeigt, wie viel du von den Tarifen der einzelnen Provider bereits verbraucht hast, damit du sehen kannst, wie nah du an einem Limit bist. Du öffnest sie unter **Einstellungen → Nutzung**. + +## Was du siehst + +Für jeden verbundenen Provider zeigt OpenChamber: + +- wie viel des aktuellen Zeitfensters du bereits genutzt hast, als Balken +- eine Aufschlüsselung nach Modell +- einen Tempoindikator, damit du siehst, ob du rechtzeitig am Limit ankommen wirst + +Du kannst auswählen, welche Provider angezeigt werden, und dieselbe Zusammenfassung ist auch über ein Dropdown in der App-Kopfzeile verfügbar. + +## Unterstützte Provider + +Die Nutzung funktioniert für die Provider, die ein Kontingent veröffentlichen, darunter Claude, Codex, GitHub Copilot, Google, OpenRouter, Kimi, NanoGPT, z.ai, Zhipu, MiniMax, Ollama Cloud und Wafer. + +Ein Anbieter zeigt Nutzung erst dann an, wenn du dich auf der [Seite „Anbieter“](/providers/) dort angemeldet hast. Einige Anbieter brauchen noch einen zusätzlichen Schritt — Ollama Cloud liest zum Beispiel eine Sitzungsdatei, die du separat einrichtest. Wenn ein Anbieter keine Daten anzeigt, fehlt meist diese zusätzliche Anmeldeinformation. + +## Weiterführend + +- [Provider, Modelle & Agents](/providers/) — zuerst anmelden, damit Nutzung angezeigt wird diff --git a/packages/docs/content/docs/de/voice.mdx b/packages/docs/content/docs/de/voice.mdx new file mode 100644 index 00000000..02f174bd --- /dev/null +++ b/packages/docs/content/docs/de/voice.mdx @@ -0,0 +1,36 @@ +--- +title: Sprachmodus +description: Sprich mit OpenChamber und lass dir Antworten vorlesen. +--- + +# Sprachmodus + +Mit dem Sprachmodus kannst du Nachrichten diktieren und dir Antworten vorlesen lassen. Schalte ihn unter **Einstellungen → OpenChamber → Sprache** ein. + +## Antworten vorlesen lassen (Text-zu-Sprache) + +Wähle aus, wie Antworten gesprochen werden: + +- **Browser** — die integrierten Stimmen deines Browsers, keine Einrichtung nötig +- **OpenAI** — die Stimmen von OpenAI; füge deinen API-Schlüssel ein und wähle eine Stimme +- **OpenAI-kompatibel** — jeder Dienst, der das OpenAI-Format unterstützt; gib seine URL und, falls nötig, einen API-Schlüssel an +- **macOS say** — der eingebaute `say`-Befehl, auf Macs mit dieser Funktion + +Sobald es aktiviert ist, erscheint bei Nachrichten eine Abspieltaste, mit der du sie vorlesen lassen kannst. + +## Nachrichten diktieren (Sprache-zu-Text) + +Wähle aus, wie deine Sprache transkribiert wird: + +- **Browser** — die integrierte Spracherkennung deines Browsers, keine Einrichtung nötig +- **Server** — ein OpenAI-kompatibler Transkriptionsdienst; gib seine URL und, falls nötig, einen API-Schlüssel an +- **Gerätebasiert** — ein Sprachmodell, das in deinem Browser läuft und beim ersten Verwenden heruntergeladen wird + +## Hinweis für Telefone + +Beim Vorlesen auf einem Telefon sind die Optionen OpenAI oder OpenAI-kompatibel am zuverlässigsten — mobile Browser begrenzen die integrierten Stimmen. + +## Weiterführend + +- [Benachrichtigungen](/notifications/) — lasse dich statt durch Zuhören per Ping informieren +- [Provider, Modelle & Agents](/providers/) — dort liegt möglicherweise bereits dein OpenAI-Schlüssel diff --git a/packages/docs/content/docs/de/walkthrough.mdx b/packages/docs/content/docs/de/walkthrough.mdx new file mode 100644 index 00000000..04744a4f --- /dev/null +++ b/packages/docs/content/docs/de/walkthrough.mdx @@ -0,0 +1,71 @@ +--- +title: Änderungen durchgehen +description: Lesen Sie einen Diff in der Reihenfolge, in der er Sinn ergibt, nicht alphabetisch. +--- + +# Änderungen durchgehen + +Ein Diff ist nach Dateipfad sortiert, und das ist fast nie die Reihenfolge, in der die Änderung Sinn ergibt. Das Walkthrough ordnet ihn neu: Zusammengehörige Änderungen werden in **Stops** gruppiert, jeder Stop erklärt, was der Code jetzt anders macht, und die Stops sind so angeordnet, dass jeder auf dem vorherigen aufbaut. + +Es erklärt und ordnet. Es bewertet Ihren Code nicht und fällt kein Urteil — dafür ist [Review](/git/) da. + +Öffnen Sie es über das **Walkthrough**-Symbol in der rechten Leiste oder über die Schaltfläche **AI walkthrough** in den Bereichen Changes und Pull Request. Beides öffnet nur das Panel; generiert wird erst, wenn Sie **Generate walkthrough** drücken. + +## Was es prüfen kann + +| Bereich | Was enthalten ist | +| --- | --- | +| Alle uncommitteten | Alles, was noch nicht committet ist: gestagte, ungestagte und neue Dateien | +| Gestaged | Nur das, was jetzt in einen Commit gehen würde | +| Ungestaged | Arbeitsbaum und neue Dateien | +| Dieser Branch | Jeder Commit auf diesem Branch, der nicht auf seinem Basis-Stand ist | +| Pull Request | Die Änderung so, wie sie auf GitHub existiert | + +**Dieser Branch** bedeutet nicht „nicht gepushte Commits“ — es ist alles, was der Branch gegenüber seiner Basis hinzufügt, egal ob gepusht oder nicht. Nach einem Commit, aber vor dem Push, unterscheiden sich also Branch und Pull Request absichtlich: Der eine zeigt, was Sie getan haben, der andere, was Reviewer gerade sehen. + +Jeder Bereich wird separat gespeichert, sodass der Wechsel zwischen ihnen nichts verliert. + +## Das Modell wählen + +Walkthroughs nutzen standardmäßig Ihr kleines Modell. Wählen Sie unter **Einstellungen → Sitzungen → Modell für Änderungen durchgehen** ein anderes aus oder für eine einzelne Prüfung im Kopfbereich des Panels — nützlich, wenn eine Änderung riskant genug ist, ein stärkeres Modell zu brauchen. + +Die Auswahl zeigt nur Modelle an, die strukturierten Output zurückgeben können, weil sich das Walkthrough ohne ihn nicht zusammensetzen lässt. Wenn ein Modell für den Diff zu klein ist, wird die Generierung mit einer Erklärung abgelehnt, statt die Eingabe stillschweigend zu kürzen: Ein Walkthrough über einen halben Diff wirkt sicher und ist falsch. + +Wenn Sie ein Panel erneut öffnen, sehen Sie das Modell, das den aktuellen Inhalt erzeugt hat, sodass **Regenerate** mit demselben Modell wiederholt, sofern Sie es nicht ändern. + +## Die Sprache wählen + +Walkthroughs werden standardmäßig in Ihrer UI-Sprache geschrieben. Der Sprachwähler im Panelkopf startet dort, und Sie können für eine einzelne Prüfung jede andere Sprache wählen, in die OpenChamber übersetzt ist — eine geführte Erklärung ist nur in einer Sprache nützlich, die Sie bequem lesen. + +Übersetzt wird nur der Fließtext. Bezeichner, Dateipfade und API-Namen bleiben genau so, wie sie im Code erscheinen, damit das, was ein Stop nennt, weiterhin suchbar bleibt. + +Wenn in der von Ihnen gewählten Sprache noch nichts erzeugt wurde, zeigt das Panel weiterhin das vorhandene Walkthrough an und sagt das auch, statt leer zu werden. Drücken Sie **Generate walkthrough**, um eines in der neuen Sprache zu erhalten. + +## Kosten und Caching + +Nichts wird von selbst generiert. Die Generierung startet nur, wenn Sie sie anfordern, und auch das erneute Generieren ist manuell. + +Ergebnisse werden gegen den exakten Inhalt des Diffs gecacht. Wenn Sie den Arbeitsbaum auf einen früheren Stand zurücksetzen, erscheint das frühere Walkthrough kostenlos wieder, ohne Modellaufruf. Sprache und Modell sind ebenfalls Teil dieses Schlüssels, also wird jede Kombination separat gespeichert: Hat ein Diff einmal Walkthroughs in zwei Sprachen, ist das Umschalten zwischen ihnen sofort und kostenlos. + +Die Generierung läuft auf dem OpenChamber-Server, nicht in Ihrem Browser-Tab. Laden Sie die Seite neu oder schließen Sie das Panel, und sie läuft weiter; wenn Sie zurückkommen, wartet das Ergebnis. Nur **Cancel** beendet sie. + +## Ehrlich mit Aktualität umgehen + +Jeder Stop ist an den exakten Codeinhalt gebunden, den er beschreibt, damit das Panel Ihnen sagen kann, wenn sich dieser Code weiterentwickelt hat: + +- **Veraltete Schritte** — der Code, den ein Stop beschrieben hat, wurde geändert oder ist verschwunden. Das Walkthrough bleibt sichtbar und markiert, damit Sie entscheiden können, ob Sie es neu generieren. +- **Nicht abgedeckt** — Änderungen im aktuellen Diff, die kein Stop beschreibt. Dazu gehören spätere Bearbeitungen nach der Generierung, Änderungen, die das Walkthrough als Routine eingestuft hat, sowie Lockfiles und andere generierte Dateien, die absichtlich aus der Modelleingabe herausgehalten werden. Sie werden alle am Ende des Streams aufgelistet, damit nichts stillschweigend verschwindet. + +Neu generieren bedeutet neu schreiben statt patchen: Das vorherige Walkthrough geht als Kontext an das Modell, damit richtige Teile erhalten bleiben, und alles wird neu an den aktuellen Code angeheftet. + +## Hinweise + +- Kommentieren Sie im Walkthrough jede Zeile genau wie in der Diff-Ansicht; Kommentare werden an den Chat-Composer angehängt. +- Verfügbar auf Desktop- und Tablet-Breiten. In der VS-Code-Erweiterung oder der mobilen App nicht angeboten. +- Eine Pull-Request-Review benötigt ein verbundenes GitHub-Konto — siehe [GitHub Issues & PRs](/github/). + +## Verwandt + +- [Git & GitHub](/git/) — das Changes-Panel, aus dem dies liest, und die Review-Aktion, die tatsächlich Code bewertet +- [GitHub Issues & PRs](/github/) — GitHub verbinden, um Pull Requests zu prüfen +- [Anbieter, Modelle & Agents](/providers/) — woher das kleine Modell kommt diff --git a/packages/docs/content/docs/de/worktrees.mdx b/packages/docs/content/docs/de/worktrees.mdx new file mode 100644 index 00000000..eabdda54 --- /dev/null +++ b/packages/docs/content/docs/de/worktrees.mdx @@ -0,0 +1,36 @@ +--- +title: Worktree-Sitzungen +description: Gib einer Sitzung einen eigenen Branch und Ordner, damit die Arbeit isoliert bleibt. +--- + +# Worktree-Sitzungen + +Eine Worktree-Sitzung läuft in einer eigenen ausgecheckten Kopie deines Repos auf einem eigenen Branch (einem Git-Worktree). So kommen sich parallele Sitzungen nicht in die Quere — die eine kann refaktorieren, während eine andere einen Fehler behebt, ohne ständiges Hin- und Herwechseln. + +## Eine erstellen + +1. Öffne den Dialog für einen neuen Worktree über die Schaltfläche oben in der Sitzungs-Seitenleiste. +2. Wähle einen Startpunkt: + - **new branch** — benenne den Branch und wähle, von welchem Branch aus gestartet werden soll + - **existing branch** — checke einen bereits vorhandenen Branch aus +3. Bestätige den Worktree-Ordner (OpenChamber schlägt einen anhand des Branchnamens vor). +4. Erstelle ihn. + +OpenChamber legt den Branch an, richtet den Ordner ein und startet dort eine Sitzung. Du kannst auch direkt von einer [todo](/notes-todos-plans/) oder einem [GitHub issue or PR](/github/) aus damit beginnen. + +## Die Arbeit zurückbringen + +Wenn die Arbeit gut ist, verwende **Integrate** in der Git-Ansicht, um die Commits des Worktrees auf einen anderen Branch (etwa `main`) zu übertragen. Wenn ein Konflikt auftritt, kannst du den Konflikt dem Agenten zur Lösung geben. + +## Aufräumen + +Das Löschen oder Archivieren der Sitzung kann den Worktree entfernen. Du entscheidest, ob auch der Branch gelöscht werden soll — lokal und, falls vorhanden, remote. Nichts wird gelöscht, ohne dass du es anforderst. + +## Wenn etwas seltsam aussieht + +Ein Worktree braucht möglicherweise Aufmerksamkeit, wenn sein Ordner fehlt, sein Branch in einem detached-Zustand ist oder ein Merge oder Rebase noch nicht abgeschlossen wurde. OpenChamber markiert das, damit du es beheben kannst — siehe [Worktrees & Git](/troubleshooting/worktrees-git/). + +## Verwandt + +- [Multi-run](/multi-run/) — viele Worktree-Sitzungen gleichzeitig starten +- [Git- & GitHub-Workflows](/git/) — von innen in OpenChamber committen und integrieren diff --git a/packages/docs/content/docs/es/walkthrough.mdx b/packages/docs/content/docs/es/walkthrough.mdx new file mode 100644 index 00000000..a8df7498 --- /dev/null +++ b/packages/docs/content/docs/es/walkthrough.mdx @@ -0,0 +1,71 @@ +--- +title: Recorrido por los cambios +description: Lee un diff en el orden que tiene sentido, no en orden alfabético. +--- + +# Recorrido por los cambios + +Un diff está ordenado por ruta de archivo, que casi nunca es el orden en el que el cambio cobra sentido. El recorrido lo reordena: las ediciones relacionadas se agrupan en **paradas**, cada parada explica qué hace ahora el código de forma distinta, y las paradas se ordenan para que cada una se apoye en la anterior. + +Explica y ordena. No juzga tu código ni emite veredictos — para eso está [Review](/git/). + +Ábrelo con el icono **Recorrido** en la barra derecha, o con el botón **Recorrido con IA** en los paneles de cambios y de pull request. Ambos solo abren el panel; no se genera nada hasta que pulsas **Generar recorrido**. + +## Qué puede recorrer + +| Ámbito | Qué incluye | +| --- | --- | +| Todo sin confirmar | Todo lo que aún no está en un commit: preparado, sin preparar y archivos nuevos | +| Preparados | Solo lo que iría a un commit ahora mismo | +| Sin preparar | Árbol de trabajo y archivos nuevos | +| Esta rama | Todos los commits de la rama que no están en su base | +| Pull request | El cambio tal como existe en GitHub | + +**Esta rama** no significa "commits sin subir": es todo lo que la rama añade a su base, se haya subido o no. Por eso, tras hacer commit pero antes de subirlo, esta y el pull request difieren a propósito: una muestra lo que hiciste, el otro lo que ven ahora quienes revisan. + +Cada ámbito se guarda por separado, así que cambiar entre ellos nunca pierde nada. + +## Elegir el modelo + +Los recorridos usan tu modelo pequeño por defecto. Elige otro en **Ajustes → Sesiones → Modelo del recorrido de cambios**, o solo para una revisión desde la cabecera del panel — útil cuando un cambio es lo bastante delicado como para merecer un modelo más potente. + +El selector solo ofrece modelos capaces de devolver salida estructurada, porque sin ella el recorrido no se puede montar. Si un modelo se queda corto para el diff, la generación se rechaza con una explicación en vez de recortar la entrada en silencio: un recorrido escrito sobre medio diff suena seguro y se equivoca. + +Al reabrir el panel verás el modelo que produjo lo que tienes delante, así que **Regenerar** repite con el mismo salvo que lo cambies. + +## Elegir el idioma + +Los recorridos se escriben en el idioma de tu interfaz por defecto. El selector de idioma de la cabecera del panel arranca ahí, y puedes elegir cualquier otro idioma al que esté traducido OpenChamber para una sola revisión: una explicación guiada solo sirve en un idioma que leas con soltura. + +Solo se traduce la prosa. Los identificadores, las rutas de archivo y los nombres de API se quedan tal cual aparecen en tu código, así que lo que nombra una parada sigue siendo lo que puedes buscar. + +Si todavía no hay nada generado en el idioma que elegiste, el panel no se vacía: sigue mostrando el recorrido que tiene y lo dice. Pulsa **Generar recorrido** para obtenerlo en el idioma nuevo. + +## Coste y caché + +Nada se genera por su cuenta. La generación solo empieza cuando la pides, y regenerar también es manual. + +Los resultados se guardan en caché según el contenido exacto del diff. Devuelve el árbol de trabajo a un estado anterior y el recorrido anterior vuelve gratis, sin llamar al modelo. El idioma y el modelo forman parte de esa clave, así que cada combinación se guarda por separado: cuando un diff ya tiene recorrido en dos idiomas, cambiar entre ellos es instantáneo y no cuesta nada. + +La generación se ejecuta en el servidor de OpenChamber, no en la pestaña del navegador. Recarga la página o cierra el panel y continúa; al volver, el resultado te espera. Solo **Cancelar** la detiene. + +## Ser honesto sobre lo desactualizado + +Cada parada está anclada al contenido exacto del código que describe, así que el panel puede avisarte cuando ese código ha cambiado: + +- **Pasos desactualizados** — el código que describía una parada cambió o desapareció. El recorrido se sigue mostrando, marcado, para que decidas si regenerar. +- **Sin cubrir** — cambios del diff actual que ninguna parada describe. Ahí entran las ediciones hechas después de generar, los cambios que el recorrido consideró rutinarios y los archivos de bloqueo u otra salida generada, que se dejan fuera del modelo a propósito. Todo aparece al final para que nada desaparezca en silencio. + +Regenerar no parchea, reescribe: el recorrido anterior va al modelo como contexto, así que lo que sigue siendo cierto se conserva, y todo se reancla al código actual. + +## Notas + +- Puedes comentar cualquier línea igual que en la vista de diff; los comentarios se adjuntan al campo del chat. +- Disponible en escritorio y anchos de tableta. No se ofrece en la extensión de VS Code ni en la app móvil. +- Recorrer un pull request requiere una cuenta de GitHub conectada — consulta [Issues y PR de GitHub](/github/). + +## Relacionado + +- [Git y GitHub](/git/) — el panel de cambios del que lee, y la acción Review que sí juzga el código +- [Issues y PR de GitHub](/github/) — conecta GitHub para recorrer pull requests +- [Proveedores, modelos y agentes](/providers/) — de dónde sale el modelo pequeño diff --git a/packages/docs/content/docs/fr/walkthrough.mdx b/packages/docs/content/docs/fr/walkthrough.mdx new file mode 100644 index 00000000..39262b71 --- /dev/null +++ b/packages/docs/content/docs/fr/walkthrough.mdx @@ -0,0 +1,71 @@ +--- +title: Parcours des modifications +description: Lisez un diff dans l’ordre qui a du sens, pas dans l’ordre alphabétique. +--- + +# Parcours des modifications + +Un diff est trié par chemin de fichier, ce qui n’est presque jamais l’ordre dans lequel la modification prend son sens. Le parcours le réorganise : les changements liés sont regroupés en **étapes**, chaque étape explique ce que le code fait désormais différemment, et les étapes s’enchaînent pour que chacune s’appuie sur la précédente. + +Il explique et ordonne. Il ne juge pas votre code et ne rend aucun verdict — c’est le rôle de [Review](/git/). + +Ouvrez-le par l’icône **Parcours** dans la barre de droite, ou par le bouton **Parcours IA** dans les panneaux des modifications et de la pull request. Les deux se contentent d’ouvrir le panneau ; rien n’est généré tant que vous n’appuyez pas sur **Générer le parcours**. + +## Ce qu’il peut parcourir + +| Portée | Ce qu’elle couvre | +| --- | --- | +| Tout non validé | Tout ce qui n’est pas encore dans un commit : indexé, non indexé et nouveaux fichiers | +| Indexées | Uniquement ce qui partirait dans un commit maintenant | +| Non indexées | Copie de travail et nouveaux fichiers | +| Cette branche | Tous les commits de la branche absents de sa base | +| Pull request | La modification telle qu’elle existe sur GitHub | + +**Cette branche** ne veut pas dire « commits non poussés » : c’est tout ce que la branche ajoute à sa base, poussé ou non. Après un commit mais avant un push, elle et la pull request diffèrent donc volontairement : l’une montre ce que vous avez fait, l’autre ce que voient les relecteurs. + +Chaque portée est stockée séparément : passer de l’une à l’autre ne perd jamais rien. + +## Choisir le modèle + +Les parcours utilisent votre petit modèle par défaut. Choisissez-en un autre dans **Paramètres → Sessions → Modèle du parcours des modifications**, ou pour une seule relecture depuis l’en-tête du panneau — utile quand une modification est assez risquée pour mériter un modèle plus solide. + +Le sélecteur ne propose que des modèles capables de sortie structurée, sans laquelle le parcours ne peut pas être assemblé. Si un modèle est trop petit pour le diff, la génération est refusée avec une explication plutôt que de tronquer l’entrée en silence : un parcours écrit sur la moitié d’un diff sonne assuré et se trompe. + +En rouvrant le panneau, vous voyez le modèle qui a produit ce que vous avez sous les yeux ; **Régénérer** reprend donc le même tant que vous n’en changez pas. + +## Choisir la langue + +Les parcours sont rédigés dans la langue de votre interface par défaut. Le sélecteur de langue dans l’en-tête du panneau démarre là, et vous pouvez choisir n’importe quelle autre langue dans laquelle OpenChamber est traduit pour une seule relecture : une explication guidée ne sert que dans une langue que vous lisez à l’aise. + +Seule la prose est traduite. Les identifiants, les chemins de fichiers et les noms d’API restent exactement tels qu’ils apparaissent dans votre code, si bien que ce qu’une étape nomme reste ce que vous pouvez rechercher. + +Si rien n’a encore été généré dans la langue choisie, le panneau ne se vide pas : il continue d’afficher le parcours qu’il a et le signale. Appuyez sur **Générer le parcours** pour l’obtenir dans la nouvelle langue. + +## Coût et cache + +Rien ne se génère tout seul. La génération ne démarre que sur votre demande, et la régénération est manuelle elle aussi. + +Les résultats sont mis en cache d’après le contenu exact du diff. Ramenez la copie de travail à un état antérieur et le parcours d’alors revient gratuitement, sans appel au modèle. La langue et le modèle font partie de cette clé, donc chaque combinaison est conservée séparément : dès qu’un diff a un parcours en deux langues, passer de l’une à l’autre est instantané et gratuit. + +La génération tourne sur le serveur OpenChamber, pas dans votre onglet. Rechargez la page ou fermez le panneau : le travail continue et le résultat vous attend. Seul **Annuler** l’interrompt. + +## Rester honnête sur l’obsolescence + +Chaque étape est ancrée au contenu exact du code qu’elle décrit, ce qui permet au panneau de signaler quand ce code a bougé : + +- **Étapes obsolètes** — le code décrit par une étape a changé ou disparu. Le parcours reste affiché, marqué, pour que vous décidiez s’il faut régénérer. +- **Non traité** — des modifications du diff actuel qu’aucune étape ne décrit. On y trouve les changements faits après la génération, ceux que le parcours a jugés courants, ainsi que les fichiers de verrouillage et autres sorties générées, délibérément tenus hors du modèle. Tout est listé à la fin pour que rien ne disparaisse en silence. + +Régénérer ne rapièce pas, cela réécrit : le parcours précédent est fourni au modèle comme contexte, ce qui reste vrai est conservé, et tout est réancré sur le code actuel. + +## Notes + +- Vous pouvez commenter n’importe quelle ligne comme dans la vue diff ; les commentaires se rattachent au champ du chat. +- Disponible sur ordinateur et sur les largeurs de tablette. Non proposé dans l’extension VS Code ni dans l’application mobile. +- Parcourir une pull request exige un compte GitHub connecté — voir [Issues et PR GitHub](/github/). + +## Voir aussi + +- [Git et GitHub](/git/) — le panneau des modifications qu’il lit, et l’action Review qui, elle, juge le code +- [Issues et PR GitHub](/github/) — connectez GitHub pour parcourir les pull requests +- [Fournisseurs, modèles et agents](/providers/) — d’où vient le petit modèle diff --git a/packages/docs/content/docs/ja/walkthrough.mdx b/packages/docs/content/docs/ja/walkthrough.mdx new file mode 100644 index 00000000..ad12a3d3 --- /dev/null +++ b/packages/docs/content/docs/ja/walkthrough.mdx @@ -0,0 +1,71 @@ +--- +title: 変更のウォークスルー +description: 差分をアルファベット順ではなく、意味の通る順序で読みます。 +--- + +# 変更のウォークスルー + +差分はファイルパス順に並びますが、それは変更の意味が通る順序であることはほとんどありません。ウォークスルーはこれを並べ替えます。関連する編集を**ステップ**にまとめ、各ステップはコードが今までと何が違う動きをするのかを説明し、前のステップの上に次が積み上がる順序で並びます。 + +説明し、順序を与えるものです。コードを評価したり判定を下したりはしません。それは [Review](/git/) の役割です。 + +右側のレールの**ウォークスルー**アイコン、または変更パネルとプルリクエストパネルの **AI ウォークスルー**ボタンから開きます。どちらもパネルを開くだけで、**ウォークスルーを生成**を押すまで何も生成されません。 + +## 対象にできる範囲 + +| 範囲 | 含まれるもの | +| --- | --- | +| 未コミットすべて | まだコミットされていないもの全部: ステージ済み、未ステージ、新規ファイル | +| ステージ済み | 今コミットすれば入るものだけ | +| 未ステージ | 作業ツリーと新規ファイル | +| このブランチ | ベースに無い、このブランチのすべてのコミット | +| プルリクエスト | GitHub 上に存在する形の変更 | + +**このブランチ**は「未プッシュのコミット」ではなく、プッシュの有無に関わらずブランチがベースに追加したすべてです。そのためコミット後・プッシュ前には、これとプルリクエストは意図的に食い違います。前者はあなたが何をしたかを、後者はレビュアーが今何を見ているかを示します。 + +範囲ごとに別々に保存されるため、切り替えても失われるものはありません。 + +## モデルの選択 + +ウォークスルーは既定でスモールモデルを使います。**設定 → セッション → 変更ウォークスルーのモデル**で別のモデルを選べます。一度だけならパネルのヘッダーからも選べます。リスクの高い変更を、より強いモデルに任せたいときに便利です。 + +選択肢に出るのは構造化出力を返せるモデルだけです。それが無ければウォークスルーは組み立てられません。差分に対してモデルが小さすぎる場合は、入力を黙って切り詰めるのではなく、理由を示して生成を拒否します。差分の半分だけを見て書かれたウォークスルーは、自信ありげに間違えるからです。 + +パネルを開き直すと、目の前の内容を生成したモデルが表示されます。したがって**再生成**は、変更しない限り同じモデルで繰り返します。 + +## 言語の選択 + +ウォークスルーは既定でインターフェースの言語で書かれます。パネル上部の言語セレクターはその言語から始まり、1 回のレビューだけ OpenChamber が翻訳されている他の言語に切り替えることもできます。案内は、無理なく読める言語でなければ意味がありません。 + +翻訳されるのは文章だけです。識別子、ファイルパス、API 名はコードにあるままなので、ストップが指し示すものはそのまま検索できます。 + +選んだ言語でまだ何も生成されていない場合、パネルは空にならず、手元にあるウォークスルーを表示したままその旨を伝えます。**ウォークスルーを生成** を押すと、新しい言語で生成されます。 + +## コストとキャッシュ + +勝手に生成されることはありません。生成はあなたが求めたときだけ始まり、再生成も手動です。 + +結果は差分の正確な内容に対してキャッシュされます。作業ツリーを以前の状態に戻せば、そのときのウォークスルーがモデル呼び出し無しで戻ります。言語とモデルもこのキーの一部なので、組み合わせごとに別々に保存されます。ある差分に 2 つの言語のウォークスルーができれば、その切り替えは即座で無料です。 + +生成はブラウザのタブではなく OpenChamber サーバー上で動きます。ページを再読み込みしてもパネルを閉じても処理は続き、戻れば結果が待っています。止められるのは**キャンセル**だけです。 + +## 古くなったことを正直に示す + +各ステップは説明対象のコードの正確な内容に紐づいているため、そのコードが動いたことをパネルが伝えられます。 + +- **古くなったステップ** — ステップが説明していたコードが変わった、あるいは無くなった。ウォークスルーは印を付けたまま表示され、再生成するかはあなたが決めます。 +- **未対応** — 現在の差分のうち、どのステップも説明していない変更。生成後に加えた編集、ウォークスルーが定型的と判断した変更、そして意図的にモデルへ渡していないロックファイルなどの生成物が含まれます。すべて末尾に一覧されるので、黙って消えるものはありません。 + +再生成は継ぎ当てではなく書き直しです。前回のウォークスルーが文脈としてモデルに渡るため、まだ正しい部分は残り、すべてが現在のコードに紐づけ直されます。 + +## 補足 + +- 差分ビューと同じように任意の行にコメントできます。コメントはチャットの入力欄に添付されます。 +- デスクトップとタブレット幅で利用できます。VS Code 拡張とモバイルアプリでは提供されません。 +- プルリクエストのウォークスルーには GitHub アカウントの接続が必要です。[GitHub の Issue と PR](/github/) を参照してください。 + +## 関連 + +- [Git と GitHub](/git/) — 読み取り元となる変更パネルと、実際にコードを評価する Review アクション +- [GitHub の Issue と PR](/github/) — プルリクエストを扱うために GitHub を接続する +- [プロバイダー・モデル・エージェント](/providers/) — スモールモデルの出どころ diff --git a/packages/docs/content/docs/ko/walkthrough.mdx b/packages/docs/content/docs/ko/walkthrough.mdx new file mode 100644 index 00000000..33f23ba7 --- /dev/null +++ b/packages/docs/content/docs/ko/walkthrough.mdx @@ -0,0 +1,71 @@ +--- +title: 변경 워크스루 +description: diff를 알파벳순이 아니라 이해되는 순서로 읽습니다. +--- + +# 변경 워크스루 + +diff는 파일 경로순으로 정렬되지만, 그 순서가 변경을 이해하기 좋은 순서인 경우는 거의 없습니다. 워크스루는 이를 다시 배열합니다. 관련된 수정들을 **단계**로 묶고, 각 단계는 코드가 이제 무엇을 다르게 하는지 설명하며, 앞 단계 위에 다음 단계가 쌓이도록 순서를 정합니다. + +설명하고 순서를 부여할 뿐, 코드를 심사하거나 판정을 내리지 않습니다. 그건 [Review](/git/)의 역할입니다. + +오른쪽 레일의 **워크스루** 아이콘이나, 변경 패널과 풀 리퀘스트 패널의 **AI 워크스루** 버튼으로 엽니다. 둘 다 패널을 열기만 하며, **워크스루 생성**을 누르기 전에는 아무것도 생성되지 않습니다. + +## 다룰 수 있는 범위 + +| 범위 | 포함되는 것 | +| --- | --- | +| 커밋되지 않은 전체 | 아직 커밋되지 않은 모든 것: 스테이지됨, 스테이지 안 됨, 새 파일 | +| 스테이지됨 | 지금 커밋하면 들어갈 것만 | +| 스테이지 안 됨 | 작업 트리와 새 파일 | +| 이 브랜치 | 베이스에 없는 이 브랜치의 모든 커밋 | +| 풀 리퀘스트 | GitHub에 존재하는 형태의 변경 | + +**이 브랜치**는 "푸시하지 않은 커밋"이 아니라, 푸시 여부와 무관하게 브랜치가 베이스에 더한 전부입니다. 그래서 커밋한 뒤 푸시하기 전에는 이것과 풀 리퀘스트가 의도적으로 달라집니다. 하나는 당신이 한 일을, 다른 하나는 리뷰어가 지금 보는 것을 보여줍니다. + +범위마다 따로 저장되므로 전환해도 잃는 것이 없습니다. + +## 모델 선택 + +워크스루는 기본적으로 스몰 모델을 사용합니다. **설정 → 세션 → 변경 워크스루 모델**에서 다른 모델을 고르거나, 한 번만 쓸 모델은 패널 헤더에서 고를 수 있습니다. 변경이 충분히 위험해서 더 강한 모델에 맡기고 싶을 때 유용합니다. + +선택 목록에는 구조화된 출력을 반환할 수 있는 모델만 나옵니다. 그것 없이는 워크스루를 구성할 수 없기 때문입니다. 모델이 diff에 비해 작으면 입력을 조용히 잘라내는 대신 이유를 설명하며 생성을 거부합니다. diff의 절반만 보고 쓴 워크스루는 자신 있게 틀리기 때문입니다. + +패널을 다시 열면 지금 보고 있는 결과를 만든 모델이 표시되므로, 바꾸지 않는 한 **다시 생성**은 같은 모델로 반복합니다. + +## 언어 선택 + +워크스루는 기본적으로 인터페이스 언어로 작성됩니다. 패널 헤더의 언어 선택기가 그 언어에서 시작하며, 한 번의 리뷰에 한해 OpenChamber가 번역된 다른 언어를 고를 수 있습니다. 안내는 편하게 읽을 수 있는 언어여야 쓸모가 있습니다. + +번역되는 것은 서술뿐입니다. 식별자, 파일 경로, API 이름은 코드에 있는 그대로 남으므로, 각 지점이 가리키는 이름을 그대로 검색할 수 있습니다. + +선택한 언어로 아직 생성된 것이 없으면 패널은 비워지지 않고, 가지고 있는 워크스루를 계속 보여주면서 그 사실을 알립니다. **워크스루 생성**을 누르면 새 언어로 만들어집니다. + +## 비용과 캐시 + +저절로 생성되는 것은 없습니다. 생성은 요청할 때만 시작되고, 재생성도 수동입니다. + +결과는 diff의 정확한 내용을 기준으로 캐시됩니다. 작업 트리를 이전 상태로 되돌리면 그때의 워크스루가 모델 호출 없이 그대로 돌아옵니다. 언어와 모델도 이 키의 일부라서 조합마다 따로 보관됩니다. 한 diff에 두 언어의 워크스루가 생기면 그 사이를 오가는 것은 즉시 이루어지고 비용도 들지 않습니다. + +생성은 브라우저 탭이 아니라 OpenChamber 서버에서 실행됩니다. 페이지를 새로 고치거나 패널을 닫아도 작업은 계속되고, 돌아오면 결과가 기다립니다. 멈추는 것은 **취소**뿐입니다. + +## 오래됨을 정직하게 알리기 + +각 단계는 설명 대상 코드의 정확한 내용에 묶여 있어서, 그 코드가 변했을 때 패널이 알려줄 수 있습니다. + +- **오래된 단계** — 단계가 설명하던 코드가 바뀌었거나 사라졌습니다. 워크스루는 표시된 채로 계속 보이며, 다시 생성할지는 당신이 정합니다. +- **미포함** — 현재 diff에서 어떤 단계도 설명하지 않는 변경입니다. 생성 이후에 한 수정, 워크스루가 일상적이라고 판단한 변경, 그리고 의도적으로 모델에 넘기지 않는 잠금 파일 등 생성물이 여기에 들어갑니다. 모두 끝에 나열되므로 조용히 사라지는 것은 없습니다. + +재생성은 기우는 것이 아니라 다시 쓰는 것입니다. 이전 워크스루가 맥락으로 모델에 전달되어 여전히 맞는 부분은 살아남고, 전체가 현재 코드에 다시 묶입니다. + +## 참고 + +- diff 보기와 똑같이 아무 줄에나 코멘트할 수 있고, 코멘트는 채팅 입력창에 첨부됩니다. +- 데스크톱과 태블릿 너비에서 사용할 수 있습니다. VS Code 확장과 모바일 앱에서는 제공되지 않습니다. +- 풀 리퀘스트 워크스루에는 연결된 GitHub 계정이 필요합니다 — [GitHub 이슈와 PR](/github/)을 참고하세요. + +## 관련 문서 + +- [Git과 GitHub](/git/) — 이 기능이 읽어오는 변경 패널, 그리고 실제로 코드를 심사하는 Review 액션 +- [GitHub 이슈와 PR](/github/) — 풀 리퀘스트를 다루려면 GitHub를 연결하세요 +- [프로바이더, 모델, 에이전트](/providers/) — 스몰 모델이 어디서 오는지 diff --git a/packages/docs/content/docs/pl/walkthrough.mdx b/packages/docs/content/docs/pl/walkthrough.mdx new file mode 100644 index 00000000..2215808e --- /dev/null +++ b/packages/docs/content/docs/pl/walkthrough.mdx @@ -0,0 +1,71 @@ +--- +title: Przewodnik po zmianach +description: Czytaj różnice w kolejności, która ma sens, a nie alfabetycznie. +--- + +# Przewodnik po zmianach + +Różnice są posortowane po ścieżkach plików, a to prawie nigdy nie jest kolejność, w której zmiana staje się zrozumiała. Przewodnik układa je na nowo: powiązane edycje trafiają do wspólnych **kroków**, każdy krok tłumaczy, co kod robi teraz inaczej, a kolejność kroków jest taka, by każdy opierał się na poprzednim. + +Tłumaczy i porządkuje. Nie ocenia kodu i nie wydaje werdyktów — od tego jest [Review](/git/). + +Otwórz go ikoną **Przewodnik** na prawym pasku albo przyciskiem **Przewodnik AI** w panelach zmian i pull requestu. Oba tylko otwierają panel; nic nie powstaje, dopóki nie naciśniesz **Wygeneruj przewodnik**. + +## Co można przejrzeć + +| Zakres | Co obejmuje | +| --- | --- | +| Wszystko niezatwierdzone | Wszystko, czego nie ma jeszcze w commicie: poczekalnia, drzewo robocze i nowe pliki | +| W poczekalni | Tylko to, co trafiłoby teraz do commita | +| Poza poczekalnią | Drzewo robocze i nowe pliki | +| Ta gałąź | Wszystkie commity gałęzi, których nie ma w jej bazie | +| Pull request | Zmiana w postaci, w jakiej istnieje na GitHubie | + +**Ta gałąź** to nie „commity bez pusha", lecz wszystko, co gałąź dokłada do swojej bazy — niezależnie od pusha. Dlatego po commicie, a przed pushem, ona i pull request celowo się różnią: jedno pokazuje, co zrobiłeś, drugie to, co widzą teraz recenzenci. + +Każdy zakres jest zapisywany osobno, więc przełączanie między nimi niczego nie gubi. + +## Wybór modelu + +Przewodniki domyślnie używają małego modelu. Inny wybierzesz w **Ustawienia → Sesje → Model przewodnika po zmianach** albo — na jeden raz — w nagłówku panelu. Przydaje się, gdy zmiana jest na tyle ryzykowna, że zasługuje na mocniejszy model. + +Lista pokazuje tylko modele potrafiące zwracać ustrukturyzowaną odpowiedź, bo bez niej przewodnika nie da się złożyć. Jeśli model jest za mały na te różnice, generowanie zostaje odrzucone z wyjaśnieniem, zamiast po cichu obciąć wejście: przewodnik napisany na podstawie połowy różnic brzmi pewnie i się myli. + +Po ponownym otwarciu panelu zobaczysz model, który stworzył to, co masz przed sobą, więc **Wygeneruj ponownie** powtórzy tym samym, dopóki go nie zmienisz. + +## Wybór języka + +Przewodniki są domyślnie pisane w języku Twojego interfejsu. Selektor języka w nagłówku panelu zaczyna właśnie od niego, a dla pojedynczego przeglądu możesz wybrać dowolny inny język, na który przetłumaczono OpenChamber — prowadzone wyjaśnienie ma sens tylko w języku, który czytasz swobodnie. + +Tłumaczona jest wyłącznie proza. Identyfikatory, ścieżki plików i nazwy API pozostają dokładnie takie, jakie są w Twoim kodzie, więc to, co nazywa dany przystanek, nadal da się wyszukać. + +Jeśli w wybranym języku nic jeszcze nie powstało, panel się nie opróżnia: nadal pokazuje przewodnik, który ma, i informuje o tym. Naciśnij **Wygeneruj przewodnik**, aby otrzymać go w nowym języku. + +## Koszt i pamięć podręczna + +Nic nie generuje się samo. Generowanie zaczyna się wyłącznie na Twoje żądanie, ponowne również jest ręczne. + +Wyniki są zapisywane w pamięci podręcznej według dokładnej treści różnic. Przywróć drzewo robocze do wcześniejszego stanu, a tamten przewodnik wróci za darmo, bez wywołania modelu. Język i model są częścią tego klucza, więc każda kombinacja jest przechowywana osobno: gdy różnice mają już przewodnik w dwóch językach, przełączanie między nimi jest natychmiastowe i nic nie kosztuje. + +Generowanie działa na serwerze OpenChamber, nie w karcie przeglądarki. Odśwież stronę albo zamknij panel, a praca trwa dalej; po powrocie wynik czeka. Zatrzymuje ją tylko **Anuluj**. + +## Uczciwość wobec nieaktualności + +Każdy krok jest przypięty do dokładnej treści kodu, który opisuje, więc panel potrafi powiedzieć, kiedy ten kod się zmienił: + +- **Nieaktualne kroki** — kod opisywany przez krok zmienił się albo zniknął. Przewodnik nadal się wyświetla, z oznaczeniem, żebyś sam zdecydował o ponownym wygenerowaniu. +- **Nieuwzględnione** — zmiany w bieżących różnicach, których nie opisuje żaden krok. Trafiają tu edycje zrobione po wygenerowaniu, zmiany uznane przez przewodnik za rutynowe oraz pliki blokad i inne wyniki narzędzi, celowo trzymane poza modelem. Wszystko jest wypisane na końcu, żeby nic nie zniknęło po cichu. + +Ponowne wygenerowanie nie łata, lecz pisze od nowa: poprzedni przewodnik trafia do modelu jako kontekst, więc to, co nadal jest prawdą, zostaje, a całość zostaje przypięta do bieżącego kodu. + +## Uwagi + +- Możesz komentować dowolną linię tak samo jak w widoku różnic; komentarze dołączają się do pola czatu. +- Dostępne na komputerze i przy szerokościach tabletu. Nie ma tego w rozszerzeniu VS Code ani w aplikacji mobilnej. +- Przewodnik po pull requeście wymaga połączonego konta GitHub — zobacz [Issues i PR na GitHubie](/github/). + +## Powiązane + +- [Git i GitHub](/git/) — panel zmian, z którego to czyta, oraz akcja Review, która faktycznie ocenia kod +- [Issues i PR na GitHubie](/github/) — połącz GitHub, aby przeglądać pull requesty +- [Dostawcy, modele i agenci](/providers/) — skąd bierze się mały model diff --git a/packages/docs/content/docs/pt-br/walkthrough.mdx b/packages/docs/content/docs/pt-br/walkthrough.mdx new file mode 100644 index 00000000..d541ace8 --- /dev/null +++ b/packages/docs/content/docs/pt-br/walkthrough.mdx @@ -0,0 +1,71 @@ +--- +title: Percurso pelas mudanças +description: Leia um diff na ordem que faz sentido, não em ordem alfabética. +--- + +# Percurso pelas mudanças + +Um diff é ordenado por caminho de arquivo, que quase nunca é a ordem em que a mudança faz sentido. O percurso reorganiza isso: edições relacionadas viram **paradas**, cada parada explica o que o código passa a fazer de diferente, e as paradas seguem uma ordem em que cada uma se apoia na anterior. + +Ele explica e ordena. Não julga o seu código nem dá veredictos — isso é papel do [Review](/git/). + +Abra pelo ícone **Percurso** na barra direita ou pelo botão **Percurso com IA** nos painéis de mudanças e de pull request. Ambos apenas abrem o painel; nada é gerado até você apertar **Gerar percurso**. + +## O que dá para percorrer + +| Escopo | O que inclui | +| --- | --- | +| Tudo sem commit | Tudo que ainda não está em commit: no stage, fora do stage e arquivos novos | +| No stage | Só o que iria para um commit agora | +| Fora do stage | Árvore de trabalho e arquivos novos | +| Este branch | Todos os commits do branch que não estão na base | +| Pull request | A mudança como ela existe no GitHub | + +**Este branch** não quer dizer "commits sem push": é tudo o que o branch acrescenta à sua base, com push ou sem. Por isso, depois do commit e antes do push, ele e o pull request divergem de propósito: um mostra o que você fez, o outro o que os revisores veem agora. + +Cada escopo é guardado separadamente, então alternar entre eles nunca perde nada. + +## Escolhendo o modelo + +Os percursos usam o seu modelo pequeno por padrão. Escolha outro em **Configurações → Sessões → Modelo do percurso de mudanças**, ou apenas para uma revisão no cabeçalho do painel — útil quando a mudança é arriscada o bastante para merecer um modelo mais forte. + +O seletor só oferece modelos capazes de devolver saída estruturada, porque sem ela o percurso não se monta. Se o modelo for pequeno demais para o diff, a geração é recusada com explicação em vez de cortar a entrada em silêncio: um percurso escrito sobre metade de um diff soa seguro e está errado. + +Ao reabrir o painel você vê o modelo que produziu o que está na tela, então **Gerar novamente** repete com o mesmo, a menos que você troque. + +## Escolhendo o idioma + +Os percursos são escritos no idioma da sua interface por padrão. O seletor de idioma no cabeçalho do painel começa por ele, e você pode escolher qualquer outro idioma para o qual o OpenChamber esteja traduzido para uma única revisão — uma explicação guiada só serve num idioma que você lê com folga. + +Só a prosa é traduzida. Identificadores, caminhos de arquivo e nomes de API continuam exatamente como aparecem no seu código, então o que uma parada nomeia continua sendo o que você consegue buscar. + +Se ainda não houver nada gerado no idioma escolhido, o painel não se esvazia: ele continua mostrando o percurso que tem e avisa. Toque em **Gerar percurso** para obtê-lo no novo idioma. + +## Custo e cache + +Nada é gerado sozinho. A geração só começa quando você pede, e gerar de novo também é manual. + +Os resultados ficam em cache pelo conteúdo exato do diff. Volte a árvore de trabalho para um estado anterior e aquele percurso retorna de graça, sem chamar o modelo. O idioma e o modelo fazem parte dessa chave, então cada combinação é guardada separadamente: quando um diff já tem percurso em dois idiomas, alternar entre eles é instantâneo e não custa nada. + +A geração roda no servidor do OpenChamber, não na aba do navegador. Recarregue a página ou feche o painel e o trabalho continua; ao voltar, o resultado está esperando. Só **Cancelar** interrompe. + +## Honestidade sobre o que ficou velho + +Cada parada está ancorada ao conteúdo exato do código que descreve, então o painel consegue avisar quando esse código mudou: + +- **Etapas desatualizadas** — o código que a parada descrevia mudou ou sumiu. O percurso continua aparecendo, marcado, para você decidir se regenera. +- **Sem cobertura** — mudanças do diff atual que nenhuma parada descreve. Entram aí as edições feitas depois de gerar, as mudanças que o percurso considerou rotineiras e os arquivos de lock e outras saídas geradas, mantidas fora do modelo de propósito. Tudo aparece no fim, para que nada suma em silêncio. + +Regenerar não remenda, reescreve: o percurso anterior vai ao modelo como contexto, o que ainda é verdade permanece, e tudo é reancorado no código atual. + +## Notas + +- Você pode comentar qualquer linha como na visão de diff; os comentários se anexam ao campo do chat. +- Disponível em desktop e larguras de tablet. Não é oferecido na extensão do VS Code nem no app móvel. +- Percorrer um pull request exige uma conta do GitHub conectada — veja [Issues e PRs do GitHub](/github/). + +## Relacionado + +- [Git e GitHub](/git/) — o painel de mudanças de onde isso lê, e a ação Review, que de fato julga o código +- [Issues e PRs do GitHub](/github/) — conecte o GitHub para percorrer pull requests +- [Provedores, modelos e agentes](/providers/) — de onde vem o modelo pequeno diff --git a/packages/docs/content/docs/uk/walkthrough.mdx b/packages/docs/content/docs/uk/walkthrough.mdx new file mode 100644 index 00000000..187d5ff6 --- /dev/null +++ b/packages/docs/content/docs/uk/walkthrough.mdx @@ -0,0 +1,71 @@ +--- +title: Розбір змін +description: Читайте diff у порядку, який має сенс, а не в алфавітному. +--- + +# Розбір змін + +Diff упорядкований за шляхами файлів, а це майже ніколи не той порядок, у якому зміна стає зрозумілою. Розбір перебудовує його: пов'язані правки збираються в **кроки**, кожен крок пояснює, що саме код тепер робить інакше, а самі кроки йдуть так, щоб кожен спирався на попередній. + +Він пояснює й упорядковує. Він не оцінює ваш код і не виносить вердиктів — для цього є [Review](/git/). + +Відкрийте його іконкою **Розбір** у правому рейлі або кнопкою **AI-розбір** у панелях змін і pull request. Обидві лише відкривають панель; нічого не генерується, доки ви не натиснете **Створити розбір**. + +## Що можна розібрати + +| Область | Що охоплює | +| --- | --- | +| Усе незакомічене | Усе, що ще не в комітах: індекс, робоче дерево й нові файли | +| В індексі | Лише те, що зараз пішло б у коміт | +| Поза індексом | Робоче дерево й нові файли | +| Ця гілка | Усі коміти гілки, яких немає в базовій | +| Pull request | Зміна в тому вигляді, у якому вона є на GitHub | + +**Ця гілка** — це не «незапушені коміти», а все, що гілка додає до базової, незалежно від пушу. Тому після коміту, але до пушу, вона й pull request навмисно розходяться: одне показує, що ви зробили, друге — що зараз бачать рецензенти. + +Кожна область зберігається окремо, тож перемикання між ними нічого не втрачає. + +## Вибір моделі + +За замовчуванням розбір використовує вашу small model. Іншу можна обрати в **Налаштування → Сесії → Модель для розбору змін** або для одного разу в шапці панелі — корисно, коли зміна достатньо ризикована, щоб віддати її сильнішій моделі. + +У списку показані лише моделі, які вміють structured output, бо без нього розбір неможливо зібрати. Якщо модель замала для цього diff, генерація відхиляється з поясненням, а не обрізає вхід мовчки: розбір, написаний за половиною diff, звучить упевнено й при цьому помиляється. + +Відкривши панель знову, ви побачите модель, яка створила те, що перед вами, тож **Створити заново** повторить тією самою, доки ви її не зміните. + +## Вибір мови + +За замовчуванням розбір пишеться мовою вашого інтерфейсу. Саме з неї починає селектор мови в шапці панелі, і для окремого розбору можна обрати будь-яку іншу мову, якою перекладено OpenChamber — пояснення має сенс лише тією мовою, яку ви читаєте вільно. + +Перекладається лише проза. Ідентифікатори, шляхи до файлів і назви API лишаються точно такими, як у вашому коді, тож те, що називає зупинка, і далі можна знайти пошуком. + +Якщо обраною мовою ще нічого не згенеровано, панель не порожніє: вона й далі показує наявний розбір і повідомляє про це. Натисніть **Створити розбір**, щоб отримати його новою мовою. + +## Витрати й кеш + +Ніщо не генерується саме. Генерація починається лише на ваш запит, і повторна теж робиться вручну. + +Результати кешуються за точним вмістом diff. Поверніть робоче дерево до попереднього стану — і попередній розбір повернеться безкоштовно, без звернення до моделі. Мова й модель — частина цього ключа, тож кожна комбінація зберігається окремо: коли для diff уже є розбір двома мовами, перемикання між ними миттєве й безкоштовне. + +Генерація виконується на сервері OpenChamber, а не у вкладці браузера. Перезавантажте сторінку чи закрийте панель — робота триває, а результат чекатиме на вас. Зупиняє її лише кнопка **Скасувати**. + +## Чесність щодо застарілого + +Кожен крок прив'язаний до точного вмісту коду, який він описує, тож панель може сказати, коли той код змінився: + +- **Застарілі кроки** — код, який описував крок, змінився або зник. Розбір усе одно показується, з позначкою, щоб ви самі вирішили, чи перегенеровувати. +- **Не описано** — зміни в поточному diff, яких не описує жоден крок. Сюди потрапляють правки, зроблені після генерації, зміни, які розбір визнав рутинними, а також lock-файли й інші згенеровані файли, які свідомо не потрапляють до моделі. Усе це перелічено в кінці, щоб нічого не зникло непомітно. + +Повторна генерація не латає, а переписує: попередній розбір іде в модель як контекст, тому точні частини зберігаються, а прив'язки заново перераховуються під поточний код. + +## Примітки + +- Коментувати можна будь-який рядок, так само як у вигляді diff; коментарі чіпляються до поля вводу в чаті. +- Доступно на десктопі та планшетних ширинах. У розширенні для VS Code і в мобільному застосунку не пропонується. +- Для розбору pull request потрібен під'єднаний акаунт GitHub — див. [Issues та PR на GitHub](/github/). + +## Пов'язане + +- [Git і GitHub](/git/) — панель змін, з якої це читається, і дія Review, яка таки оцінює код +- [Issues та PR на GitHub](/github/) — під'єднайте GitHub, щоб розбирати pull request +- [Провайдери, моделі та агенти](/providers/) — звідки береться small model diff --git a/packages/docs/content/docs/walkthrough.mdx b/packages/docs/content/docs/walkthrough.mdx new file mode 100644 index 00000000..6d372bf5 --- /dev/null +++ b/packages/docs/content/docs/walkthrough.mdx @@ -0,0 +1,71 @@ +--- +title: Changes Walkthrough +description: Read a diff in the order it makes sense, not in alphabetical order. +--- + +# Changes Walkthrough + +A diff is sorted by file path, which is almost never the order in which the change makes sense. The walkthrough reorders it: related edits are grouped into **stops**, each stop explains what the code now does differently, and the stops are ordered so each one builds on the last. + +It explains and orders. It does not judge your code or hand out verdicts — that is what [Review](/git/) is for. + +Open it from the **Walkthrough** icon in the right rail, or from the **AI walkthrough** button in the Changes and Pull Request panels. Both just open the panel; nothing is generated until you press **Generate walkthrough**. + +## What it can review + +| Scope | What it covers | +| --- | --- | +| All uncommitted | Everything not yet committed: staged, unstaged, and new files | +| Staged | Only what would go into a commit right now | +| Unstaged | Working tree and new files | +| This branch | Every commit on this branch that is not on its base | +| Pull request | The change as it exists on GitHub | + +**This branch** is not "unpushed commits" — it is everything the branch adds to its base, pushed or not. So after committing but before pushing, it and the pull request deliberately differ: one shows what you did, the other what reviewers currently see. + +Each scope is stored separately, so switching between them never loses anything. + +## Choosing the model + +Walkthroughs use your small model by default. Pick a different one in **Settings → Sessions → Changes Walkthrough Model**, or for a single review in the panel header — useful when a change is risky enough to deserve a stronger model. + +The picker only offers models that can return structured output, because the walkthrough cannot be assembled without it. If a model is too small for the diff, generation is refused with an explanation rather than silently truncating the input: a walkthrough written against half a diff reads as confident and is wrong. + +Reopening a panel shows the model that produced what you are looking at, so **Regenerate** repeats with the same one unless you change it. + +## Choosing the language + +Walkthroughs are written in your interface language by default. The language picker in the panel header starts there, and you can pick any other language OpenChamber is translated into for a single review — a guided explanation is only useful in a language you read comfortably. + +Only the prose is translated. Identifiers, file paths, and API names stay exactly as they appear in your code, so what a stop names is still what you can search for. + +When nothing has been generated yet in the language you picked, the panel keeps showing the walkthrough it has and says so, rather than emptying itself. Press **Generate walkthrough** to get one in the new language. + +## Cost and caching + +Nothing generates on its own. Generation only ever starts when you ask, and regeneration is manual too. + +Results are cached against the exact content of the diff. Return the working tree to an earlier state and the earlier walkthrough comes back for free, no model call. Language and model are both part of that key, so each combination is kept separately: once a diff has a walkthrough in two languages, switching between them is instant and costs nothing. + +Generation runs on the OpenChamber server, not in your browser tab. Reload the page or close the panel and it keeps going; come back and the result is waiting. Pressing **Cancel** is the only thing that stops it. + +## Staying honest about staleness + +Every stop is anchored to the exact content of the code it describes, so the panel can tell you when that code has moved on: + +- **Outdated steps** — the code a stop described has changed or is gone. The walkthrough still shows, marked, so you can decide whether to regenerate. +- **Not covered** — changes in the current diff that no stop describes. That includes edits made after generating, changes the walkthrough judged routine, and lockfiles and other generated files, which are deliberately kept out of the model's input. They are all listed at the end of the stream so nothing disappears silently. + +Regenerating re-authors rather than patches: the previous walkthrough goes to the model as context so accurate parts survive, and everything is re-anchored to the current code. + +## Notes + +- Comment on any line in the walkthrough exactly as in the diff view; comments attach to the chat composer. +- Available on desktop and tablet widths. Not offered in the VS Code extension or the mobile app. +- A pull request review needs a connected GitHub account — see [GitHub Issues & PRs](/github/). + +## Related + +- [Git & GitHub](/git/) — the Changes panel this reads from, and the Review action that does judge code +- [GitHub Issues & PRs](/github/) — connect GitHub to review pull requests +- [Providers, Models & Agents](/providers/) — where the small model comes from diff --git a/packages/docs/content/docs/zh-cn/walkthrough.mdx b/packages/docs/content/docs/zh-cn/walkthrough.mdx new file mode 100644 index 00000000..24d982d7 --- /dev/null +++ b/packages/docs/content/docs/zh-cn/walkthrough.mdx @@ -0,0 +1,71 @@ +--- +title: 改动导读 +description: 按讲得通的顺序读差异,而不是按字母顺序。 +--- + +# 改动导读 + +差异按文件路径排序,而这几乎从来不是让改动讲得通的顺序。导读会重新编排:相关的修改被归入一个个**步骤**,每个步骤说明代码现在有什么不同的行为,步骤的先后顺序保证后一步建立在前一步之上。 + +它负责解释和排序,不评判你的代码,也不给结论——那是 [Review](/git/) 的职责。 + +从右侧栏的**导读**图标打开,或在改动面板和拉取请求面板中点击 **AI 导读**按钮。两者都只是打开面板;在你按下**生成导读**之前不会生成任何内容。 + +## 可以导读的范围 + +| 范围 | 包含内容 | +| --- | --- | +| 全部未提交 | 尚未进入提交的一切:已暂存、未暂存和新文件 | +| 已暂存 | 只包含此刻提交会带上的内容 | +| 未暂存 | 工作区和新文件 | +| 当前分支 | 该分支上基线分支所没有的全部提交 | +| 拉取请求 | GitHub 上现有形态的改动 | + +**当前分支**不是“未推送的提交”,而是该分支相对基线新增的全部内容,无论是否推送。因此在提交之后、推送之前,它和拉取请求会有意不同:一个显示你做了什么,另一个显示评审者当前看到什么。 + +各个范围分别保存,来回切换不会丢失任何内容。 + +## 选择模型 + +导读默认使用你的小模型。可在**设置 → 会话 → 改动导读模型**中更换,或只为这一次在面板标题栏中选择——当改动的风险足以交给更强的模型时很有用。 + +选择列表只提供能返回结构化输出的模型,因为没有它就无法组装导读。如果模型对这份差异来说太小,生成会带着说明被拒绝,而不是悄悄截断输入:只看了半份差异写出的导读听起来笃定,实际却是错的。 + +重新打开面板时会显示生成当前内容的那个模型,所以只要你不更换,**重新生成**就会沿用它。 + +## 选择语言 + +导读默认使用你的界面语言书写。面板顶部的语言选择器从该语言开始,你也可以只为这一次导读改用 OpenChamber 已翻译的任意其他语言——讲解只有用你读起来轻松的语言才有价值。 + +只有叙述文字会被翻译。标识符、文件路径和 API 名称保持代码中的原样,因此每个停靠点提到的名字仍然可以直接搜索。 + +如果所选语言还没有生成过内容,面板不会清空:它会继续显示已有的导读并说明这一点。按 **生成导读** 即可得到新语言的版本。 + +## 开销与缓存 + +不会自行生成。生成只在你请求时开始,重新生成同样需要手动触发。 + +结果按差异的确切内容缓存。把工作区恢复到先前状态,当时的导读就会免费回来,不会调用模型。语言和模型都属于这个键的一部分,因此每种组合各自保存:一个差异一旦有了两种语言的导读,在它们之间切换就是即时且免费的。 + +生成运行在 OpenChamber 服务器上,而不是浏览器标签页里。刷新页面或关闭面板,工作仍在继续;回来时结果已在等你。只有**取消**能停止它。 + +## 对过时保持诚实 + +每个步骤都锚定在它所描述代码的确切内容上,因此面板能告诉你那段代码何时发生了变化: + +- **过时的步骤**——步骤所描述的代码已改变或不存在。导读仍会显示并加以标记,由你决定是否重新生成。 +- **未涵盖**——当前差异中没有任何步骤描述的改动。其中包括生成之后所做的修改、导读判定为常规的改动,以及锁文件等特意不送入模型的生成产物。它们都列在末尾,不会有内容悄无声息地消失。 + +重新生成不是打补丁,而是重写:上一版导读会作为上下文交给模型,仍然成立的部分得以保留,整体则重新锚定到当前代码。 + +## 说明 + +- 可以像在差异视图中一样对任意行发表评论,评论会附加到聊天输入框。 +- 在桌面和平板宽度下可用。VS Code 扩展和移动应用中不提供。 +- 导读拉取请求需要已连接的 GitHub 账户,参见 [GitHub Issue 与 PR](/github/)。 + +## 相关 + +- [Git 与 GitHub](/git/)——它读取的改动面板,以及确实会评判代码的 Review 操作 +- [GitHub Issue 与 PR](/github/)——连接 GitHub 以导读拉取请求 +- [提供方、模型与代理](/providers/)——小模型从何而来 diff --git a/packages/docs/sidebar.config.json b/packages/docs/sidebar.config.json index a364bf71..15673b20 100644 --- a/packages/docs/sidebar.config.json +++ b/packages/docs/sidebar.config.json @@ -10,7 +10,8 @@ "ko": "여기서 시작", "pl": "Zacznij tutaj", "fr": "Commencer ici", - "ja": "ここから開始" + "ja": "ここから開始", + "de": "Hier starten" }, "items": [ { @@ -24,7 +25,8 @@ "ko": "개요", "pl": "Przegląd", "fr": "Vue d’ensemble", - "ja": "概要" + "ja": "概要", + "de": "Übersicht" } }, { @@ -38,7 +40,8 @@ "ko": "설치", "pl": "Instalacja", "fr": "Installation", - "ja": "インストール" + "ja": "インストール", + "de": "Installation" } }, { @@ -52,7 +55,8 @@ "ko": "빠른 시작", "pl": "Szybki start", "fr": "Démarrage rapide", - "ja": "クイックスタート" + "ja": "クイックスタート", + "de": "Schnellstart" } }, { @@ -66,7 +70,8 @@ "ko": "OpenCode 서버", "pl": "Serwer OpenCode", "fr": "Serveur OpenCode", - "ja": "OpenCode サーバー" + "ja": "OpenCode サーバー", + "de": "OpenCode-Server" } }, { @@ -80,7 +85,8 @@ "ko": "환경 변수", "pl": "Zmienne środowiskowe", "fr": "Variables d’environnement", - "ja": "環境変数" + "ja": "環境変数", + "de": "Umgebungsvariablen" } } ] @@ -95,7 +101,8 @@ "ko": "워크플로", "pl": "Przepływy pracy", "fr": "Workflows", - "ja": "ワークフロー" + "ja": "ワークフロー", + "de": "Abläufe" }, "items": [ { @@ -109,7 +116,8 @@ "ko": "프로젝트", "pl": "Projekty", "fr": "Projets", - "ja": "プロジェクト" + "ja": "プロジェクト", + "de": "Projekte" } }, { @@ -123,7 +131,8 @@ "ko": "컨텍스트", "pl": "Kontekst", "fr": "Contexte", - "ja": "コンテキスト" + "ja": "コンテキスト", + "de": "Kontext" } }, { @@ -137,7 +146,8 @@ "ko": "메모, 할 일, 계획", "pl": "Notatki, zadania i plany", "fr": "Notes, todos et plans", - "ja": "メモ、Todo、計画" + "ja": "メモ、Todo、計画", + "de": "Notizen, Aufgaben und Pläne" } }, { @@ -151,7 +161,8 @@ "ko": "예약 작업", "pl": "Zaplanowane zadania", "fr": "Tâches planifiées", - "ja": "スケジュールタスク" + "ja": "スケジュールタスク", + "de": "Geplante Aufgaben" } }, { @@ -165,7 +176,8 @@ "ko": "에이전트 제어 도구", "pl": "Narzędzie sterowania dla agentów", "fr": "Outil de contrôle pour les agents", - "ja": "エージェント制御ツール" + "ja": "エージェント制御ツール", + "de": "Agenten-Steuerungstool" } }, { @@ -179,7 +191,8 @@ "ko": "세션 목표", "pl": "Cele sesji", "fr": "Objectifs de session", - "ja": "セッションゴール" + "ja": "セッションゴール", + "de": "Sitzungsziele" } }, { @@ -193,7 +206,8 @@ "ko": "프로젝트 작업", "pl": "Akcje projektu", "fr": "Actions de projet", - "ja": "プロジェクトアクション" + "ja": "プロジェクトアクション", + "de": "Projektaktionen" } }, { @@ -207,7 +221,8 @@ "ko": "미리보기 및 개발 서버", "pl": "Podgląd i serwery deweloperskie", "fr": "Aperçu et serveurs de dev", - "ja": "プレビューと開発サーバー" + "ja": "プレビューと開発サーバー", + "de": "Vorschau und Entwicklungsserver" } }, { @@ -221,7 +236,8 @@ "ko": "Worktree 세션", "pl": "Sesje worktree", "fr": "Sessions worktree", - "ja": "Worktree セッション" + "ja": "Worktree セッション", + "de": "Worktree-Sitzungen" } }, { @@ -229,7 +245,8 @@ "link": "/multi-run/", "translations": { "fr": "Multi-run", - "ja": "Multi-run" + "ja": "Multi-run", + "de": "Mehrfachausführung" } }, { @@ -237,7 +254,23 @@ "link": "/git/", "translations": { "fr": "Git et GitHub", - "ja": "Git と GitHub" + "ja": "Git と GitHub", + "de": "Git und GitHub" + } + }, + { + "label": "Changes Walkthrough", + "link": "/walkthrough/", + "translations": { + "uk": "Розбір змін", + "zh-CN": "改动导读", + "es": "Recorrido por los cambios", + "pt-BR": "Percurso pelas mudanças", + "ko": "변경 워크스루", + "pl": "Przewodnik po zmianach", + "fr": "Parcours des modifications", + "ja": "変更のウォークスルー", + "de": "Änderungsrundgang" } }, { @@ -251,7 +284,8 @@ "ko": "GitHub 이슈 및 PR", "pl": "Zgłoszenia i PR-y GitHub", "fr": "Issues et PR GitHub", - "ja": "GitHub Issues と PR" + "ja": "GitHub Issues と PR", + "de": "GitHub-Issues und PRs" } }, { @@ -265,7 +299,8 @@ "ko": "매직 프롬프트", "pl": "Magiczne prompty", "fr": "Magic Prompts", - "ja": "マジックプロンプト" + "ja": "マジックプロンプト", + "de": "Magische Prompts" } }, { @@ -279,7 +314,8 @@ "ko": "Git 아이덴티티", "pl": "Tożsamości Git", "fr": "Identités Git", - "ja": "Git ID" + "ja": "Git ID", + "de": "Git-Identitäten" } } ] @@ -294,7 +330,8 @@ "ko": "OpenCode 설정", "pl": "Konfiguracja OpenCode", "fr": "Configuration OpenCode", - "ja": "OpenCode 設定" + "ja": "OpenCode 設定", + "de": "OpenCode-Einrichtung" }, "items": [ { @@ -308,7 +345,8 @@ "ko": "공급자, 모델, 에이전트", "pl": "Dostawcy, modele i agenci", "fr": "Fournisseurs, modèles et agents", - "ja": "プロバイダー、モデル、エージェント" + "ja": "プロバイダー、モデル、エージェント", + "de": "Anbieter, Modelle und Agenten" } }, { @@ -322,7 +360,8 @@ "ko": "MCP 서버", "pl": "Serwery MCP", "fr": "Serveurs MCP", - "ja": "MCP サーバー" + "ja": "MCP サーバー", + "de": "MCP-Server" } }, { @@ -336,7 +375,8 @@ "ko": "스킬", "pl": "Umiejętności", "fr": "Skills", - "ja": "スキル" + "ja": "スキル", + "de": "Fähigkeiten" } }, { @@ -350,7 +390,8 @@ "ko": "스킬 카탈로그", "pl": "Katalog umiejętności", "fr": "Catalogue de skills", - "ja": "スキルカタログ" + "ja": "スキルカタログ", + "de": "Fähigkeitenkatalog" } }, { @@ -364,7 +405,8 @@ "ko": "명령 및 스니펫", "pl": "Polecenia i fragmenty", "fr": "Commandes et snippets", - "ja": "コマンドとスニペット" + "ja": "コマンドとスニペット", + "de": "Befehle und Snippets" } }, { @@ -378,7 +420,8 @@ "ko": "사용량 및 할당량", "pl": "Zużycie i limity", "fr": "Utilisation et quotas", - "ja": "使用量とクォータ" + "ja": "使用量とクォータ", + "de": "Nutzung und Kontingente" } } ] @@ -393,7 +436,8 @@ "ko": "원격 접속", "pl": "Dostęp zdalny", "fr": "Accès distant", - "ja": "リモートアクセス" + "ja": "リモートアクセス", + "de": "Zugriff von außen" }, "items": [ { @@ -407,7 +451,8 @@ "ko": "기기 연결", "pl": "Podłączanie urządzenia", "fr": "Connecter un appareil", - "ja": "デバイスを接続" + "ja": "デバイスを接続", + "de": "Gerät verbinden" } }, { @@ -421,7 +466,8 @@ "ko": "프라이빗 릴레이", "pl": "Prywatny relay", "fr": "Relay privé", - "ja": "プライベートリレー" + "ja": "プライベートリレー", + "de": "Privates Relay" } }, { @@ -435,7 +481,8 @@ "ko": "터널", "pl": "Tunele", "fr": "Tunnels", - "ja": "トンネル" + "ja": "トンネル", + "de": "Tunnel" } }, { @@ -449,7 +496,8 @@ "ko": "리버스 프록시", "pl": "Reverse proxy", "fr": "Reverse proxy", - "ja": "リバースプロキシ" + "ja": "リバースプロキシ", + "de": "Reverse Proxy" } }, { @@ -463,7 +511,8 @@ "ko": "모바일 앱 및 PWA", "pl": "Aplikacje mobilne i PWA", "fr": "Apps mobiles et PWA", - "ja": "モバイルアプリと PWA" + "ja": "モバイルアプリと PWA", + "de": "Mobile Apps und PWA" } }, { @@ -477,7 +526,8 @@ "ko": "보안", "pl": "Bezpieczeństwo", "fr": "Sécurité", - "ja": "セキュリティ" + "ja": "セキュリティ", + "de": "Sicherheit" } } ] @@ -492,7 +542,8 @@ "ko": "맞춤 설정", "pl": "Dostosuj", "fr": "Personnaliser", - "ja": "カスタマイズ" + "ja": "カスタマイズ", + "de": "Anpassen" }, "items": [ { @@ -506,7 +557,8 @@ "ko": "테마", "pl": "Motywy", "fr": "Thèmes", - "ja": "テーマ" + "ja": "テーマ", + "de": "Designs" } }, { @@ -520,7 +572,8 @@ "ko": "알림", "pl": "Powiadomienia", "fr": "Notifications", - "ja": "通知" + "ja": "通知", + "de": "Benachrichtigungen" } }, { @@ -534,7 +587,8 @@ "ko": "음성 모드", "pl": "Tryb głosowy", "fr": "Mode vocal", - "ja": "音声モード" + "ja": "音声モード", + "de": "Sprachmodus" } }, { @@ -548,7 +602,8 @@ "ko": "프로젝트 아이콘", "pl": "Ikony projektów", "fr": "Icônes de projet", - "ja": "プロジェクトアイコン" + "ja": "プロジェクトアイコン", + "de": "Projektsymbole" } } ] @@ -563,7 +618,8 @@ "ko": "데스크톱", "pl": "Pulpit", "fr": "Desktop", - "ja": "デスクトップ" + "ja": "デスクトップ", + "de": "Desktop" }, "items": [ { @@ -577,7 +633,8 @@ "ko": "원격 인스턴스", "pl": "Zdalne instancje", "fr": "Instances distantes", - "ja": "リモートインスタンス" + "ja": "リモートインスタンス", + "de": "Entfernte Instanzen" } }, { @@ -591,7 +648,8 @@ "ko": "데스크톱 브라우저", "pl": "Przeglądarka na pulpicie", "fr": "Navigateur desktop", - "ja": "デスクトップブラウザ" + "ja": "デスクトップブラウザ", + "de": "Desktop-Browser" } }, { @@ -605,7 +663,8 @@ "ko": "데스크톱 터널", "pl": "Tunele w aplikacji desktopowej", "fr": "Tunnels desktop", - "ja": "デスクトップトンネル" + "ja": "デスクトップトンネル", + "de": "Desktop-Tunnel" } }, { @@ -619,7 +678,8 @@ "ko": "SSH 호스트 및 프록시", "pl": "Hosty SSH i proxy", "fr": "Hosts SSH et proxy", - "ja": "SSH ホストとプロキシ" + "ja": "SSH ホストとプロキシ", + "de": "SSH-Hosts und Proxying" } }, { @@ -633,7 +693,8 @@ "ko": "업데이트", "pl": "Aktualizacje", "fr": "Mises à jour", - "ja": "更新" + "ja": "更新", + "de": "Aktualisierungen" } } ] @@ -648,7 +709,8 @@ "ko": "도움말", "pl": "Pomoc", "fr": "Aide", - "ja": "ヘルプ" + "ja": "ヘルプ", + "de": "Hilfe" }, "items": [ { @@ -662,7 +724,8 @@ "ko": "문제 해결", "pl": "Rozwiązywanie problemów", "fr": "Dépannage", - "ja": "トラブルシューティング" + "ja": "トラブルシューティング", + "de": "Fehlerbehebung" } }, { @@ -676,7 +739,8 @@ "ko": "OpenCode 연결", "pl": "Połączenie z OpenCode", "fr": "Connexion à OpenCode", - "ja": "OpenCode 接続" + "ja": "OpenCode 接続", + "de": "OpenCode-Verbindung" } }, { @@ -690,7 +754,8 @@ "ko": "Worktree 및 Git", "pl": "Worktree i Git", "fr": "Worktrees et Git", - "ja": "Worktrees と Git" + "ja": "Worktrees と Git", + "de": "Worktrees und Git" } }, { @@ -704,7 +769,8 @@ "ko": "원격 접속", "pl": "Dostęp zdalny", "fr": "Accès distant", - "ja": "リモートアクセス" + "ja": "リモートアクセス", + "de": "Externer Zugriff" } } ] diff --git a/packages/mobile/HANDOFF.md b/packages/mobile/HANDOFF.md index 92fa01e7..47b16577 100644 --- a/packages/mobile/HANDOFF.md +++ b/packages/mobile/HANDOFF.md @@ -81,9 +81,10 @@ iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (s - **Connection onboarding** — server URL entry, password unlock for locked servers, client-token issuance, saved connections, `Instances` management sheet, auto-connect to the last instance on launch. Deleting the active instance resets the runtime to the connect screen. -- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android's Google code scanner module is - downloaded on first scan (needs Play Services + network); `mobileQrScan.ts` installs/awaits it - and retries. CAMERA permission + `NSCameraUsageDescription` declared. +- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android uses the CameraX-backed + `startScan()` flow with the barcode model bundled in the app, so scanning works offline and + without Google Play Services. iOS uses the plugin's native scanner. CAMERA permission + + `NSCameraUsageDescription` declared. - **Secure storage** — `@aparajita/capacitor-secure-storage` for connection tokens. - **Deep links** — `openchamber://` URL scheme; a reusable intent vocabulary (`apps/deepLinks.ts`) used by notification taps, widgets, and Control Center. Cold-launch intents are stashed. @@ -128,8 +129,7 @@ iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (s brings `firebase-messaging`. - Manifest: permissions `INTERNET`, `CAMERA` (+ optional camera feature), `POST_NOTIFICATIONS` (Android 13+; older versions allow notifications by default). `windowSoftInputMode=adjustResize`. - ML Kit `com.google.mlkit.vision.DEPENDENCIES=barcode_ui` meta (preloads the code scanner). FCM - `default_notification_icon=@drawable/ic_stat_notify`. + FCM `default_notification_icon=@drawable/ic_stat_notify`. - Adaptive launcher icon: full-bleed color background + `ic_launcher_foreground` (sources under `packages/mobile/assets/`, regenerable with `@capacitor/assets`). diff --git a/packages/mobile/README.md b/packages/mobile/README.md index eb5f98f4..a06a96d8 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -8,8 +8,10 @@ The mobile package reuses the web build, then rewrites `mobile.html` to `index.h - The native app bundles the mobile UI only; it does not embed the OpenChamber web server or OpenCode server. - On first launch in Capacitor, the app shows a connection screen for an existing OpenChamber server. -- Connections are saved locally in the app and can be managed from the mobile overflow menu under `Instances`. -- The connection screen and `Instances` menu item are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior. +- Connections are saved locally in the app and can be managed from `Instances` in the sessions drawer footer (a persistent left sidebar on tablets). +- The connection screen and the `Instances` entry are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior. +- Phones and tablets share one navigation model: a sessions drawer/sidebar on the left, the workspace drawer (Changes / Files / Terminal / Notes / MCP) on the right, and no overflow menu. Tablets differ only in that the sessions list is a resizable persistent sidebar and the header dropdowns are anchored popovers. +- The tablet layout is a live size class (`useTabletLayout`), not a device check: any surface whose short side is at least 600px gets it, and the workspace only becomes a side panel where the width can host the sidebar, the panel and a readable chat at once. Book foldables therefore pick it up when unfolded, keep the portrait layout in both orientations (their long side is barely wider than a tablet's short one), and drop back to the phone layout when folded shut. The Android activity declares the matching `configChanges`, so folding resizes the WebView instead of recreating it. - Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection. - The Terminal workspace surface runs its PTY on the active OpenChamber server over the shared authenticated runtime transport; it never opens a local shell on the phone or tablet. Closing the surface detaches the renderer while the server session remains available for reattachment. On touch devices, dragging scrolls the buffer while long-pressing and dragging selects terminal text. diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml index d00b87da..dba5d6b5 100644 --- a/packages/mobile/android/app/src/main/AndroidManifest.xml +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -35,13 +35,6 @@ - - - ; }> = ({ side, isResizing, ariaLabel, handleProps }) => (
void }> = ({ onActiveConnectionDeleted }) => { const { t } = useI18n(); @@ -116,26 +106,20 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc // open/close so the right-edge swipe reopens where the user left off. const [workspaceOpen, setWorkspaceOpen] = React.useState(false); const [workspaceTab, setWorkspaceTab] = React.useState('changes'); - const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false); - // A plan opened from the Project notes surface, shown as a second fullscreen + // A plan opened from the workspace drawer's Notes tab, shown as a fullscreen // layer on top of it (back returns to the notes). const [openPlan, setOpenPlan] = React.useState<{ path: string; title: string } | null>(null); const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav'); - const [overflowOpen, setOverflowOpen] = React.useState(false); // When set, the Changes surface opens directly into the per-file diff for this path. const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const wideChatLayoutEnabled = useUIStore((state) => state.wideChatLayoutEnabled); const updateAvailable = useUpdateStore((state) => state.available); const updateRuntimeType = useUpdateStore((state) => state.runtimeType); const showCapacitorOnlyFeatures = React.useMemo(() => isCapacitorMobileApp(), []); const mcpServers = useMcpConfigStore((state) => state.mcpServers); const setMcpDraft = useMcpConfigStore((state) => state.setMcpDraft); const setSelectedMcp = useMcpConfigStore((state) => state.setSelectedMcp); - const refreshMcpStatus = useMcpStore((state) => state.refresh); - const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs); - const gitStatus = useGitStatus(normalizePath(currentDirectory) || null); - const dirtyChangeCount = gitStatus?.files?.length ?? 0; // NOTE: pendingChangesDiff is intentionally NOT cleared on close — it keys // the persistent Changes pane in the workspace drawer, and clearing it would @@ -158,76 +142,93 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc openSurface('settings'); }, [openSurface]); - // iPad (Capacitor): sessions live in a persistent full-height left sidebar - // and Changes/Files in a right sidebar, instead of phone sheets/surfaces. - const isIPad = React.useMemo(() => isIPadApp(), []); + // Tablet: sessions live in a persistent full-height left sidebar instead of + // the phone's drawer. Everything else — the workspace drawer, the header, the + // app-level surfaces — is shared with phones. + // + // A SIZE class, not a device check: an unfolded book foldable is a tablet + // until it is folded shut, and the shell keeps running across that change. + const { enabled: isTabletLayout, roomyForPanels } = useTabletLayout(); const orientation = useOrientation(); const isPortrait = orientation === 'portrait'; - const [ipadSidebarOpen, setIpadSidebarOpen] = React.useState(isIPad && !isPortrait); - const [ipadRightPanel, setIpadRightPanel] = React.useState<'files' | 'changes' | null>(null); + const hasHardwareKeyboard = useHardwareKeyboard(); + const [sidebarOpen, setSidebarOpen] = React.useState(() => readTabletLayout().roomyForPanels); - const toggleIpadSidebar = React.useCallback(() => { - const willOpen = !ipadSidebarOpen; - // Portrait doesn't fit both side panels next to a usable chat column: - // opening one closes the other (iPadOS behaves the same way). - if (willOpen && isPortrait) setIpadRightPanel(null); - setIpadSidebarOpen(willOpen); - }, [ipadSidebarOpen, isPortrait]); + const toggleSidebar = React.useCallback(() => { + setSidebarOpen((current: boolean) => !current); + }, []); + + // Folding shut (or losing the room for a side-by-side layout) must not leave + // a sidebar open over a phone-width screen. + React.useEffect(() => { + if (!isTabletLayout) setSidebarOpen(false); + }, [isTabletLayout]); const openFilesSurface = React.useCallback(() => { - if (isIPad) { - setPendingChangesDiff(null); - setIpadRightPanel('files'); - if (isPortrait) setIpadSidebarOpen(false); - return; - } + setPendingChangesDiff(null); setWorkspaceTab('files'); setWorkspaceOpen(true); - }, [isIPad, isPortrait]); + }, []); const openChangesSurface = React.useCallback((diff: { path: string; staged: boolean } | null = null) => { setPendingChangesDiff(diff); - if (isIPad) { - setIpadRightPanel('changes'); - if (isPortrait) setIpadSidebarOpen(false); - return; - } setWorkspaceTab('changes'); setWorkspaceOpen(true); - }, [isIPad, isPortrait]); - - const closeIpadRightPanel = React.useCallback(() => { - setIpadRightPanel(null); - setPendingChangesDiff(null); }, []); - const toggleIpadRightPanel = React.useCallback((panel: 'files' | 'changes') => { - if (ipadRightPanel === panel) { - closeIpadRightPanel(); - return; - } - if (panel === 'files') openFilesSurface(); - else openChangesSurface(); - }, [closeIpadRightPanel, ipadRightPanel, openChangesSurface, openFilesSurface]); - - // Keep the right panel's content mounted through the width-collapse - // animation; drop it once the panel is fully closed. - const lastIpadRightPanelRef = React.useRef<'files' | 'changes'>('changes'); - if (ipadRightPanel) lastIpadRightPanelRef.current = ipadRightPanel; - const [ipadRightContentMounted, setIpadRightContentMounted] = React.useState(false); - React.useEffect(() => { - if (!isIPad) return; - if (ipadRightPanel) { - setIpadRightContentMounted(true); - return; - } - const id = window.setTimeout(() => setIpadRightContentMounted(false), 240); - return () => window.clearTimeout(id); - }, [ipadRightPanel, isIPad]); - const renderedIpadRightPanel = ipadRightPanel ?? lastIpadRightPanelRef.current; - const leftResize = useIpadSidebarResize('left', 'openchamber.ipad.leftSidebarWidth', IPAD_LEFT_SIDEBAR_WIDTH); - const rightResize = useIpadSidebarResize('right', 'openchamber.ipad.rightSidebarWidth', IPAD_RIGHT_SIDEBAR_WIDTH); + const rightResize = useIpadSidebarResize( + 'right', + 'openchamber.ipad.rightSidebarWidth', + IPAD_RIGHT_SIDEBAR_WIDTH, + IPAD_WORKSPACE_SIDEBAR_MAX_WIDTH, + ); + // The workspace becomes a real side panel only where the screen can host the + // sidebar, the panel AND a readable chat at once. Everywhere else — a tablet + // in portrait, and an unfolded foldable in EITHER orientation, since its long + // side is barely wider than a tablet's short one — it stays the full-cover + // drawer, which is the layout that actually works at that width. + const workspaceAsPanel = roomyForPanels; + const workspacePanelWidth = workspaceAsPanel && workspaceOpen ? rightResize.width : 0; + const sidebarWidth = isTabletLayout && sidebarOpen ? leftResize.width : 0; + + // Publish the chat column's insets so overlays portaled to (model + // picker, directory picker, every MobileOverlayPanel) can center on the CHAT + // rather than on the window. Zero on phones, where the two are the same. + React.useEffect(() => { + if (typeof document === 'undefined') return; + const root = document.documentElement; + root.style.setProperty('--oc-chat-inset-left', `${sidebarWidth}px`); + root.style.setProperty('--oc-chat-inset-right', `${workspacePanelWidth}px`); + return () => { + root.style.removeProperty('--oc-chat-inset-left'); + root.style.removeProperty('--oc-chat-inset-right'); + }; + }, [sidebarWidth, workspacePanelWidth]); + + // Wide chat layout: the shared chat columns key off this root class, but only + // the desktop App set it — so on a tablet, where the chat column is finally + // wide enough for the setting to mean something, it did nothing. Applied for + // every mobile surface; on a phone the viewport is narrower than even the + // normal clamp, so it is a no-op there. + React.useEffect(() => { + if (typeof document === 'undefined') return; + const root = document.documentElement; + root.classList.toggle('wide-chat-layout', wideChatLayoutEnabled); + return () => root.classList.remove('wide-chat-layout'); + }, [wideChatLayoutEnabled]); + + // The draft screen keeps its starter chips while the keyboard is up when + // there is room for both: a tablet in portrait, or any tablet orientation + // with a hardware keyboard (then no software keyboard eats the screen at + // all). Landscape on the software keyboard still hides them — see mobile.css. + React.useEffect(() => { + if (typeof document === 'undefined') return; + const keep = isTabletLayout && (isPortrait || hasHardwareKeyboard); + const root = document.documentElement; + root.classList.toggle('oc-keep-draft-starters', keep); + return () => root.classList.remove('oc-keep-draft-starters'); + }, [hasHardwareKeyboard, isTabletLayout, isPortrait]); const mobileActions = React.useMemo( () => ({ @@ -246,7 +247,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc const deepLinkHandlers = React.useMemo( () => ({ openSessions: () => { - if (isIPad) setIpadSidebarOpen(true); + if (isTabletLayout) setSidebarOpen(true); else setSessionsSheetOpen(true); }, openView: (target: 'files' | 'mcp' | 'instances' | 'update') => { @@ -254,8 +255,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc openFilesSurface(); return; } - // Phones host MCP as a workspace tab now; iPad still uses the surface. - if (target === 'mcp' && !isIPad) { + if (target === 'mcp') { setWorkspaceTab('mcp'); setWorkspaceOpen(true); return; @@ -270,37 +270,25 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc openSettingsSurface(section ? 'page-content' : 'nav'); }, }), - [isIPad, openChangesSurface, openFilesSurface, openSettingsSurface, openSurface, setSettingsPage], + [isTabletLayout, openChangesSurface, openFilesSurface, openSettingsSurface, openSurface, setSettingsPage], ); useDeepLinkHandlers(deepLinkHandlers); - // Edge swipes on the chat: left edge opens the sessions drawer (the iPad - // sidebar on iPad), right edge reopens the most recent overflow surface - // (the last right panel on iPad). + // Edge swipes on the chat: left edge opens the sessions drawer (the + // persistent sidebar on a tablet), right edge the workspace drawer. const chatMainRef = React.useRef(null); useEdgeSwipe(chatMainRef, { onLeftEdgeSwipe: () => { - if (isIPad) setIpadSidebarOpen(true); + if (isTabletLayout) setSidebarOpen(true); else setSessionsSheetOpen(true); }, - onRightEdgeSwipe: () => { - if (isIPad) { - if (lastIpadRightPanelRef.current === 'files') openFilesSurface(); - else openChangesSurface(); - return; - } - setWorkspaceOpen(true); - }, + onRightEdgeSwipe: () => setWorkspaceOpen(true), }); - // Top-most layer first: a plan or fullscreen surface can now sit ABOVE a - // drawer (opened from the drawer footer / workspace tabs), so they close - // before the drawers underneath. + // Top-most layer first: a plan or fullscreen surface can sit ABOVE a drawer + // (opened from the drawer footer / workspace tabs), so they close before the + // drawers underneath. const handleNativeBack = React.useCallback(() => { - if (overflowOpen) { - setOverflowOpen(false); - return true; - } if (openPlan) { setOpenPlan(null); return true; @@ -318,7 +306,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc return true; } return false; - }, [activeSurface, closeSurface, closeWorkspace, openPlan, overflowOpen, sessionsSheetOpen, workspaceOpen]); + }, [activeSurface, closeSurface, closeWorkspace, openPlan, sessionsSheetOpen, workspaceOpen]); useNativeAndroidBackButton(handleNativeBack); @@ -329,6 +317,22 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc && updateAvailable && (updateRuntimeType === 'desktop' || updateRuntimeType === 'web'); + // Tablets pack the app-level pages (settings, instances, a plan) into a + // centered dialog instead of covering the whole screen. + const surfaceVariant = isTabletLayout ? 'dialog' as const : 'fullscreen' as const; + + // App-level footer of the sessions list — the same on a phone drawer and a + // tablet sidebar: connected instance, pending web update, settings. + const sessionsFooter = React.useMemo( + () => ({ + instanceLabel: showCapacitorOnlyFeatures ? getAutoConnectTargetLabel() : null, + onOpenInstances: showCapacitorOnlyFeatures ? () => openSurface('instances') : undefined, + onOpenSettings: () => openSettingsSurface('nav'), + onOpenUpdate: showUpdateItem ? () => openSurface('update') : undefined, + }), + [openSettingsSurface, openSurface, showCapacitorOnlyFeatures, showUpdateItem], + ); + const openMcpCreateSettings = React.useCallback(() => { const baseName = 'new-mcp-server'; let newName = baseName; @@ -361,70 +365,6 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc openSettingsSurface('page-content'); }, [mcpServers, openSettingsSurface, setMcpDraft, setSelectedMcp, setSettingsPage]); - const refreshMcpOverlay = React.useCallback(() => { - if (isMcpRefreshing) return; - setIsMcpRefreshing(true); - const directory = currentDirectory || null; - const minSpinPromise = new Promise((resolve) => window.setTimeout(resolve, 500)); - void Promise.all([ - refreshMcpStatus({ directory, silent: true }), - loadMcpConfigs({ force: true }), - minSpinPromise, - ]).finally(() => setIsMcpRefreshing(false)); - }, [currentDirectory, isMcpRefreshing, loadMcpConfigs, refreshMcpStatus]); - - const overflowItems: OverflowItem[] = React.useMemo( - () => { - const items: OverflowItem[] = []; - // Phones get Files/Changes/Terminal as workspace-drawer tabs; the iPad - // exposes Files/Changes as header shortcuts and keeps Terminal here. - if (isIPad) { - items.push({ - key: 'terminal', - icon: 'terminal', - label: t('mobile.menu.terminal'), - onSelect: () => openSurface('terminal'), - }); - } - items.push({ - key: 'mcp', - iconNode: , - label: t('mobile.menu.mcp'), - onSelect: () => openSurface('mcp'), - }); - items.push({ - key: 'notes', - icon: 'sticky-note', - label: t('contextRail.surface.notes'), - onSelect: () => openSurface('notes'), - }); - if (showCapacitorOnlyFeatures) { - items.push({ - key: 'instances', - icon: 'server', - label: t('mobile.menu.instances'), - onSelect: () => openSurface('instances'), - }); - } - if (showUpdateItem) { - items.push({ - key: 'update', - icon: 'download', - label: t('mobile.menu.update'), - onSelect: () => openSurface('update'), - }); - } - items.push({ - key: 'settings', - icon: 'settings-3', - label: t('mobile.menu.settings'), - onSelect: () => openSettingsSurface('nav'), - }); - return items; - }, - [isIPad, openSettingsSurface, openSurface, showCapacitorOnlyFeatures, showUpdateItem, t], - ); - return (
void }> = ({ onAc {/* iPad: persistent full-height sessions sidebar; the chat column and its header butt against it (iPadOS-style split layout). Always mounted so open/close animates width, same as the desktop Sidebar. */} - {isIPad ? ( + {isTabletLayout ? ( ) : null}
(isIPad ? toggleIpadSidebar() : setSessionsSheetOpen(true))} - // Phones dropped the overflow menu: its items live in the sessions - // drawer footer and the workspace tabs now. iPad keeps it until - // the dedicated iPad layout pass. - onOpenMenu={isIPad ? () => setOverflowOpen(true) : undefined} - onOpenWorkspace={isIPad ? undefined : () => setWorkspaceOpen(true)} - surfaceShortcuts={isIPad ? { - activePanel: ipadRightPanel, - changesDirty: dirtyChangeCount > 0, - onToggleFiles: () => toggleIpadRightPanel('files'), - onToggleChanges: () => toggleIpadRightPanel('changes'), - } : undefined} + onOpenSessions={() => (isTabletLayout ? toggleSidebar() : setSessionsSheetOpen(true))} + onOpenWorkspace={() => setWorkspaceOpen(true)} + compactTitle={isTabletLayout} />
@@ -511,20 +449,33 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
- {/* iPad: Changes/Files live in a full-height right sidebar instead of - the phone's fullscreen surfaces. Width animates like the desktop - RightSidebar; content stays mounted through the collapse. */} - {isIPad ? ( + {/* Mounted permanently on phones (parked off-screen while closed) so + the sessions/worktree state stays warm and the drawer opens with + data already on screen — see MobileSessionsDrawerContainer. */} + {!isTabletLayout ? ( + + ) : null} + + {/* Tablet: the workspace lives inside an animated aside so landscape + gets a real sidebar. The drawer element keeps its position in the + tree across rotation — only its `variant` changes — so the mounted + panes (open diff, edited file, attached terminal) survive it. In + portrait the drawer portals itself out and this aside stays at 0. */} + {isTabletLayout ? ( - ) : null} - - {isIPad ? ( - setOverflowOpen(false)} - items={overflowItems} - rightOffset={ipadRightPanel ? rightResize.width : 0} - /> - ) : null} - - {/* Mounted permanently on phones (parked off-screen while closed) so - the sessions/worktree state stays warm and the drawer opens with - data already on screen — see MobileSessionsDrawerContainer. */} - {!isIPad ? ( - openSurface('instances') : undefined, - onOpenSettings: () => openSettingsSurface('nav'), - onOpenUpdate: showUpdateItem ? () => openSurface('update') : undefined, - }} - /> - ) : null} - - {/* Mounted only while open (like the sessions sheet) so each surface - computes its safe-area / fixed-position layout fresh on open. Keeping - them always-mounted left a stale startup layout, which made the - top-inset dimming appear only intermittently on iOS. */} - {!isIPad ? ( + ) : ( void }> = ({ onAc onOpenPlan={setOpenPlan} onOpenMcpSettings={openMcpCreateSettings} /> - ) : null} + )} - {activeSurface === 'terminal' ? ( - - - - - - ) : null} - - {activeSurface === 'mcp' ? ( - - - - - )} - > - - - - - ) : null} - - {activeSurface === 'notes' ? ( - - - - - - ) : null} - - {/* Layered above whichever surface opened it — the notes fullscreen - surface (iPad) or the workspace drawer's Notes tab (phones). */} + {/* Layered above the workspace drawer's Notes tab, which opened it. */} {openPlan ? ( setOpenPlan(null)} ariaLabel={openPlan.title} title={openPlan.title} @@ -707,6 +552,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc {activeSurface === 'instances' && showCapacitorOnlyFeatures ? ( void }> = ({ onAc {activeSurface === 'settings' ? ( void }> = ({ onAc {activeSurface === 'update' ? ( (null); const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); // QR pairing is the primary flow; the manual URL form stays collapsed unless // scanning is unavailable (web build) or the user asks for it. @@ -60,19 +63,27 @@ export const MobileConnectionWelcome: React.FC<{ }, [conn]); const handleScanQr = React.useCallback(async () => { - if (isScanning || isBusy) return; + if (scanAbortRef.current || isBusy) return; conn.setError(null); setIsScanning(true); + const controller = new AbortController(); + scanAbortRef.current = controller; try { - const result = await scanConnectionQr(); + const result = await scanConnectionQr({ signal: controller.signal }); + if (scanAbortRef.current === controller) { + scanAbortRef.current = null; + setIsScanning(false); + } switch (result.status) { case 'ok': + setIsCompletingScan(true); setServerUrl(result.url); if (result.label) setConnectionName(result.label); if (result.clientToken) setClientToken(result.clientToken); await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label }); break; case 'pairing': + setIsCompletingScan(true); await conn.redeemPairingConnection(result.pairing); break; case 'permission-denied': @@ -92,9 +103,15 @@ export const MobileConnectionWelcome: React.FC<{ break; } } finally { - setIsScanning(false); + setIsCompletingScan(false); + if (scanAbortRef.current === controller) { + scanAbortRef.current = null; + setIsScanning(false); + } } - }, [conn, isBusy, isScanning, t]); + }, [conn, isBusy, t]); + + React.useEffect(() => () => scanAbortRef.current?.abort(), []); const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { event.preventDefault(); @@ -107,6 +124,9 @@ export const MobileConnectionWelcome: React.FC<{ }, [conn]); return ( + <> + {isScanning ? scanAbortRef.current?.abort()} /> : null} + {isCompletingScan ? : null}
@@ -297,5 +317,6 @@ export const MobileConnectionWelcome: React.FC<{ )}
+ ); }; diff --git a/packages/ui/src/apps/MobileFullscreenSurface.tsx b/packages/ui/src/apps/MobileFullscreenSurface.tsx index 8d93e277..26d58bdd 100644 --- a/packages/ui/src/apps/MobileFullscreenSurface.tsx +++ b/packages/ui/src/apps/MobileFullscreenSurface.tsx @@ -35,6 +35,18 @@ export type MobileFullscreenSurfaceProps = { /** Drop the header's bottom divider (quiet single-page surfaces). */ noHeaderBorder?: boolean; ariaLabel?: string; + /** + * `dialog` packs the same surface into a centered card over a scrim instead + * of covering the app. Tablets use it: a settings or instances page stretched + * across a 13" screen is mostly empty space, and losing the chat entirely for + * an app-level page is a heavier context switch than the content deserves. + */ + variant?: 'fullscreen' | 'dialog'; + /** + * What the dialog centers on. App-level pages (settings, instances) belong to + * the whole window; content that came out of the chat column stays with it. + */ + dialogAlign?: 'chat' | 'app'; children: React.ReactNode; }; @@ -51,6 +63,8 @@ export const MobileFullscreenSurface: React.FC = ( headerless = false, noHeaderBorder = false, ariaLabel, + variant = 'fullscreen', + dialogAlign = 'chat', children, }) => { const { t } = useI18n(); @@ -148,15 +162,29 @@ export const MobileFullscreenSurface: React.FC = ( if (!open || !rootRef.current) return null; - return createPortal( + const isDialog = variant === 'dialog'; + + const surface = (
= ( transition: `transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`, }} onTransitionEnd={(event) => { - // Reveal content exactly when the enter slide ends — not on a fixed timer. + // Reveal content exactly when the enter transition ends — not on a fixed timer. if (entered && event.target === event.currentTarget && event.propertyName === 'transform') { setContentReady(true); } @@ -214,7 +242,29 @@ export const MobileFullscreenSurface: React.FC = ( ) : null}
- , + + ); + + if (!isDialog) return createPortal(surface, rootRef.current); + + return createPortal( +
+
event.stopPropagation()}> + {surface} +
+
, rootRef.current, ); }; diff --git a/packages/ui/src/apps/MobileHeader.tsx b/packages/ui/src/apps/MobileHeader.tsx index 94cd6427..7660f5e9 100644 --- a/packages/ui/src/apps/MobileHeader.tsx +++ b/packages/ui/src/apps/MobileHeader.tsx @@ -10,23 +10,14 @@ import { useSession } from '@/sync/sync-context'; import { MobileSessionMetadataButton } from './MobileSessionMetadata'; import { MobileSessionSwitcher } from './MobileSessionSwitcher'; -export type MobileHeaderSurfaceShortcuts = { - activePanel: 'files' | 'changes' | null; - changesDirty: boolean; - onToggleFiles: () => void; - onToggleChanges: () => void; -}; - export const MobileHeader: React.FC<{ onOpenSessions: () => void; - /** iPad only for now: the legacy overflow menu. Phones distribute its items - across the sessions drawer footer and the workspace drawer tabs. */ - onOpenMenu?: () => void; - /** Phone only: opens the right workspace drawer (Changes / Files / Terminal). */ - onOpenWorkspace?: () => void; - /** iPad only: Files/Changes header shortcuts that toggle the right sidebar. */ - surfaceShortcuts?: MobileHeaderSurfaceShortcuts; -}> = ({ onOpenSessions, onOpenMenu, onOpenWorkspace, surfaceShortcuts }) => { + /** Opens the right workspace drawer (Changes / Files / Terminal / Notes / MCP). */ + onOpenWorkspace: () => void; + /** Tablet: size the title trigger to its text instead of the free width, so + a wide header doesn't turn the switcher into a full-width tap target. */ + compactTitle?: boolean; +}> = ({ onOpenSessions, onOpenWorkspace, compactTitle = false }) => { const { t } = useI18n(); const [metadataOpen, setMetadataOpen] = React.useState(false); const [switcherOpen, setSwitcherOpen] = React.useState(false); @@ -57,12 +48,6 @@ export const MobileHeader: React.FC<{ onOpenSessions(); }, [onOpenSessions]); - const handleOpenMenu = React.useCallback(() => { - setMetadataOpen(false); - setSwitcherOpen(false); - onOpenMenu?.(); - }, [onOpenMenu]); - // The two header popovers are mutually exclusive. const handleMetadataOpenChange = React.useCallback((value: boolean | ((open: boolean) => boolean)) => { setMetadataOpen((current) => { @@ -101,7 +86,10 @@ export const MobileHeader: React.FC<{ + {/* Compact title: this takes the leftover width so the trailing + controls stay pinned to the right edge. */} + {compactTitle ?
: null} + - {surfaceShortcuts ? ( - <> - - - - ) : null} - - {onOpenMenu ? ( - - ) : null} - - {onOpenWorkspace ? ( - - ) : null} +
void; @@ -28,6 +29,8 @@ export const MobileInstancesSurface: React.FC<{ const [clientToken, setClientToken] = React.useState(''); const [password, setPassword] = React.useState(''); const [isScanning, setIsScanning] = React.useState(false); + const [isCompletingScan, setIsCompletingScan] = React.useState(false); + const scanAbortRef = React.useRef(null); const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); // The manual add/edit form is hidden until asked for — the sheet leads with // the list of instances (with live status), not a wall of inputs. @@ -61,11 +64,17 @@ export const MobileInstancesSurface: React.FC<{ // Scan a pairing QR into the add/edit form fields (does not change edit mode, so // the form-reset effect doesn't wipe the scanned values). The user reviews + saves. const handleScanInstance = React.useCallback(async () => { - if (isScanning) return; + if (scanAbortRef.current) return; setError(null); setIsScanning(true); + const controller = new AbortController(); + scanAbortRef.current = controller; try { - const result = await scanConnectionQr(); + const result = await scanConnectionQr({ signal: controller.signal }); + if (scanAbortRef.current === controller) { + scanAbortRef.current = null; + setIsScanning(false); + } switch (result.status) { case 'ok': // Legacy token QR: prefill the manual form for review before saving. @@ -75,6 +84,7 @@ export const MobileInstancesSurface: React.FC<{ setFormOpen(true); break; case 'pairing': + setIsCompletingScan(true); await conn.redeemPairingConnection(result.pairing); break; case 'permission-denied': @@ -94,9 +104,15 @@ export const MobileInstancesSurface: React.FC<{ break; } } finally { - setIsScanning(false); + setIsCompletingScan(false); + if (scanAbortRef.current === controller) { + scanAbortRef.current = null; + setIsScanning(false); + } } - }, [conn, isScanning, setError, t]); + }, [conn, setError, t]); + + React.useEffect(() => () => scanAbortRef.current?.abort(), []); const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { event.preventDefault(); @@ -170,6 +186,9 @@ export const MobileInstancesSurface: React.FC<{ } return ( + <> + {isScanning ? scanAbortRef.current?.abort()} /> : null} + {isCompletingScan ? : null}
@@ -358,5 +377,6 @@ export const MobileInstancesSurface: React.FC<{
+ ); }; diff --git a/packages/ui/src/apps/MobileOverflowMenu.tsx b/packages/ui/src/apps/MobileOverflowMenu.tsx deleted file mode 100644 index 15cd23e3..00000000 --- a/packages/ui/src/apps/MobileOverflowMenu.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; - -import { Icon } from '@/components/icon/Icon'; -import type { IconName } from '@/components/icon/icons'; -import { useI18n } from '@/lib/i18n'; - -export type OverflowItem = { - key: 'files' | 'changes' | 'terminal' | 'mcp' | 'notes' | 'instances' | 'update' | 'settings'; - icon?: IconName; - iconNode?: React.ReactNode; - label: string; - badge?: number; - onSelect: () => void; -}; - -export const MobileOverflowMenu: React.FC<{ - open: boolean; - onClose: () => void; - items: OverflowItem[]; - /** Extra viewport-right inset so the dropdown stays anchored to the - three-dots button when the iPad right sidebar shifts the header. */ - rightOffset?: number; -}> = ({ open, onClose, items, rightOffset = 0 }) => { - const { t } = useI18n(); - React.useEffect(() => { - if (!open) return; - const handleKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') onClose(); - }; - document.addEventListener('keydown', handleKey); - return () => document.removeEventListener('keydown', handleKey); - }, [onClose, open]); - - if (!open) return null; - - return ( -
- - ))} -
- -
- ); -}; diff --git a/packages/ui/src/apps/MobileQrScannerOverlay.tsx b/packages/ui/src/apps/MobileQrScannerOverlay.tsx new file mode 100644 index 00000000..f1aec6a8 --- /dev/null +++ b/packages/ui/src/apps/MobileQrScannerOverlay.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; + +import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; +import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; +import { useI18n } from '@/lib/i18n'; + +export const MobileQrScannerOverlay: React.FC<{ onCancel: () => void }> = ({ onCancel }) => { + const { t } = useI18n(); + const overlayRef = React.useRef(null); + + React.useLayoutEffect(() => { + const htmlBackground = { + value: document.documentElement.style.getPropertyValue('background-color'), + priority: document.documentElement.style.getPropertyPriority('background-color'), + }; + const bodyBackground = { + value: document.body.style.getPropertyValue('background-color'), + priority: document.body.style.getPropertyPriority('background-color'), + }; + // The app's reduced-transparency theme deliberately uses an !important + // background. Use an inline important color while CameraX is behind the + // WebView; the CSS minifier collapses `background: transparent` in a way + // that does not reset that important background color on Android WebView. + document.documentElement.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important'); + document.body.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important'); + + // startScan() places CameraX behind the WebView. OpenChamber has several + // independent portal roots, so hiding only #root (or relying on inherited + // visibility) can leave a sheet/sidebar painted over the preview. Opacity on + // each top-level sibling is composited for its whole subtree and cannot be + // overridden by descendants. + const hidden = new Map(); + const hideBodySibling = (node: Node) => { + if (!(node instanceof HTMLElement) || node === overlayRef.current || hidden.has(node)) return; + hidden.set(node, { opacity: node.style.opacity, pointerEvents: node.style.pointerEvents }); + node.style.setProperty('opacity', '0'); + node.style.setProperty('pointer-events', 'none'); + }; + Array.from(document.body.children).forEach(hideBodySibling); + const observer = new MutationObserver((records) => { + records.forEach((record) => record.addedNodes.forEach(hideBodySibling)); + }); + observer.observe(document.body, { childList: true }); + + return () => { + observer.disconnect(); + hidden.forEach((previous, element) => { + element.style.opacity = previous.opacity; + element.style.pointerEvents = previous.pointerEvents; + }); + if (htmlBackground.value) { + document.documentElement.style.setProperty('background-color', htmlBackground.value, htmlBackground.priority); + } else { + document.documentElement.style.removeProperty('background-color'); + } + if (bodyBackground.value) { + document.body.style.setProperty('background-color', bodyBackground.value, bodyBackground.priority); + } else { + document.body.style.removeProperty('background-color'); + } + }; + }, []); + + React.useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') onCancel(); + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [onCancel]); + + return createPortal( +
+
+
+

+ {t('mobile.connect.welcome.scanHint')} +

+
+ +
, + document.body, + ); +}; + +export const MobileQrConnectionLoading: React.FC = () => { + const { t } = useI18n(); + return createPortal( +
+ +
+ + {t('mobile.connect.connecting')} +
+
, + document.body, + ); +}; diff --git a/packages/ui/src/apps/MobileSessionMetadata.tsx b/packages/ui/src/apps/MobileSessionMetadata.tsx index c2a42ac1..85a93ff5 100644 --- a/packages/ui/src/apps/MobileSessionMetadata.tsx +++ b/packages/ui/src/apps/MobileSessionMetadata.tsx @@ -4,8 +4,8 @@ import { Icon } from '@/components/icon/Icon'; import type { IconName } from '@/components/icon/icons'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { preloadProviderLogos } from '@/hooks/useProviderLogo'; +import { useTabletLayout } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; -import { isIPadApp } from '@/lib/platform'; import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; import { getDisplayModelName } from '@/lib/quota/model-families'; import { cn } from '@/lib/utils'; @@ -16,7 +16,7 @@ import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionMessages } from '@/sync/sync-context'; -const IPAD_METADATA_POPOVER_WIDTH = 380; +const TABLET_METADATA_POPOVER_WIDTH = 380; const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => { if (!limit || typeof limit !== 'object') return undefined; @@ -139,18 +139,18 @@ const SessionMetadataOverlay: React.FC<{ const panelRef = React.useRef(null); const [shouldRender, setShouldRender] = React.useState(open); const [isExiting, setIsExiting] = React.useState(false); - // iPad: a phone-width sheet stretched across the whole chat column looks + // Tablet: a phone-width sheet stretched across the whole chat column looks // broken — render a popover anchored to the metadata button instead. - const isIPad = React.useMemo(() => isIPadApp(), []); + const { enabled: isTabletLayout } = useTabletLayout(); const wrapperRef = React.useRef(null); - const [ipadAnchorLeft, setIpadAnchorLeft] = React.useState(null); + const [anchorLeft, setIpadAnchorLeft] = React.useState(null); // The shell has transformed ancestors, so the fixed wrapper's containing // block is the chat column, NOT the viewport. Anchor the popover in the // wrapper's own coordinate space — viewport-based lefts would double-count // the sidebar offset. React.useLayoutEffect(() => { - if (!open || !isIPad || !shouldRender) return; + if (!open || !isTabletLayout || !shouldRender) return; const compute = () => { const anchorRect = anchorRef.current?.getBoundingClientRect(); const wrapperRect = wrapperRef.current?.getBoundingClientRect(); @@ -161,7 +161,7 @@ const SessionMetadataOverlay: React.FC<{ const relativeLeft = anchorRect.left - wrapperRect.left; const left = Math.min( Math.max(relativeLeft, 8), - Math.max(8, wrapperRect.width - IPAD_METADATA_POPOVER_WIDTH - 8), + Math.max(8, wrapperRect.width - TABLET_METADATA_POPOVER_WIDTH - 8), ); setIpadAnchorLeft(left); }; @@ -173,9 +173,9 @@ const SessionMetadataOverlay: React.FC<{ const observer = new ResizeObserver(compute); observer.observe(wrapper); return () => observer.disconnect(); - }, [anchorRef, isIPad, open, shouldRender]); + }, [anchorRef, isTabletLayout, open, shouldRender]); - const ipadPopover = isIPad && ipadAnchorLeft !== null; + const isPopover = isTabletLayout && anchorLeft !== null; React.useEffect(() => { if (open) { @@ -233,17 +233,17 @@ const SessionMetadataOverlay: React.FC<{ aria-label={t('mobile.header.openMetadataAria')} className={cn( 'overflow-y-auto overscroll-contain rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform', - ipadPopover ? 'absolute origin-top-left' : 'mx-3 mt-2', + isPopover ? 'absolute origin-top-left' : 'mx-3 mt-2', isExiting ? 'pointer-events-none' : 'pointer-events-auto', )} style={{ animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`, maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))', - ...(ipadPopover + ...(isPopover ? { top: 8, - left: ipadAnchorLeft ?? 8, - width: `min(${IPAD_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`, + left: anchorLeft ?? 8, + width: `min(${TABLET_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`, } : null), }} diff --git a/packages/ui/src/apps/MobileSessionSwitcher.tsx b/packages/ui/src/apps/MobileSessionSwitcher.tsx index 7f13948c..4e2a44a8 100644 --- a/packages/ui/src/apps/MobileSessionSwitcher.tsx +++ b/packages/ui/src/apps/MobileSessionSwitcher.tsx @@ -4,6 +4,7 @@ import type { Session } from '@opencode-ai/sdk/v2'; import { Icon } from '@/components/icon/Icon'; import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils'; import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems'; +import { useTabletLayout } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; @@ -13,6 +14,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionStatus } from '@/sync/sync-context'; const RECENT_SESSIONS_LIMIT = 10; +/** Matches the metadata popover's width so both header dropdowns read as a pair. */ +const TABLET_POPOVER_WIDTH = 380; const getSessionTitle = (session: Session, fallback: string): string => session.title?.trim() || fallback; @@ -76,6 +79,42 @@ export const MobileSessionSwitcher: React.FC<{ const panelRef = React.useRef(null); const [shouldRender, setShouldRender] = React.useState(open); const [isExiting, setIsExiting] = React.useState(false); + // Tablet: a phone-width sheet stretched across the whole chat column looks + // broken — anchor a popover under the title instead. Mirror image of the + // metadata/usage popover, which anchors to the ring on the right. + const { enabled: isTabletLayout } = useTabletLayout(); + const wrapperRef = React.useRef(null); + const [anchorLeft, setAnchorLeft] = React.useState(null); + + // The shell has transformed ancestors, so the fixed wrapper's containing + // block is the chat column, NOT the viewport — anchor in the wrapper's own + // coordinate space (see SessionMetadataOverlay for the same reasoning). + React.useLayoutEffect(() => { + if (!open || !isTabletLayout || !shouldRender) return; + const compute = () => { + const anchorRect = anchorRef.current?.getBoundingClientRect(); + const wrapperRect = wrapperRef.current?.getBoundingClientRect(); + if (!anchorRect || !wrapperRect) { + setAnchorLeft(null); + return; + } + const relativeLeft = anchorRect.left - wrapperRect.left; + setAnchorLeft(Math.min( + Math.max(relativeLeft, 8), + Math.max(8, wrapperRect.width - TABLET_POPOVER_WIDTH - 8), + )); + }; + compute(); + // Re-anchor if the chat column shifts while the popover is open (sidebar + // toggle/resize, orientation change) — the header buttons move with it. + const wrapper = wrapperRef.current; + if (typeof ResizeObserver === 'undefined' || !wrapper) return; + const observer = new ResizeObserver(compute); + observer.observe(wrapper); + return () => observer.disconnect(); + }, [anchorRef, isTabletLayout, open, shouldRender]); + + const isPopover = isTabletLayout && anchorLeft !== null; const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); @@ -132,18 +171,26 @@ export const MobileSessionSwitcher: React.FC<{ if (!shouldRender) return null; return ( -
+
diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index 30a5ce90..fb85e607 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -1437,8 +1437,11 @@ export const MobileSessionsSheet: React.FC = ({ open, ) : null; + // flex-1 + min-h-0 rather than h-full: both hosts put a fixed-height header + // above this, so a 100% height overflows by exactly that header — and the + // clipped overflow swallowed the footer. const surfaceContent = ( -
+
{/* The search bar scrolls WITH the list (iOS-style): the open-time auto-scroll to the current session naturally tucks it away, and diff --git a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx index 3d8e6392..b3c94c24 100644 --- a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx +++ b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx @@ -85,10 +85,18 @@ const McpWorkspacePane: React.FC<{ onOpenMcpSettings: () => void }> = ({ onOpenM ); }; -/** Full-width right drawer with the phone workspace surfaces as tabs - (Changes / Files / Terminal / Notes / MCP). Slides in from the right edge; - closes via the header X, Escape (unless the terminal tab owns the keys), - or the Android back button (handled by MobileShell). */ +/** The workspace surfaces as tabs (Changes / Files / Terminal / Notes / MCP). + + Two hosts, same content and same state: + - `drawer` (default) covers the app and slides in from the right edge — + the phone, and a tablet in portrait where a side panel would leave no + usable chat column; + - `panel` renders inline so the caller can size it as a real sidebar + beside the chat (tablet, landscape). The caller owns the width and the + open/close animation there; this component only fills it. + + Closes via the header X, Escape (unless the terminal tab owns the keys), or + the Android back button (handled by MobileShell). */ export const MobileWorkspaceDrawer: React.FC<{ open: boolean; onClose: () => void; @@ -100,7 +108,8 @@ export const MobileWorkspaceDrawer: React.FC<{ onOpenPlan: (plan: { path: string; title: string }) => void; /** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */ onOpenMcpSettings: () => void; -}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings }) => { + variant?: 'drawer' | 'panel'; +}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => { const { t } = useI18n(); const rootRef = React.useRef(null); const [entered, setEntered] = React.useState(false); @@ -150,20 +159,22 @@ export const MobileWorkspaceDrawer: React.FC<{ React.useEffect(() => { if (!open) return; + // Only the full-cover drawer owns the page scroll; the inline panel sits + // inside the shell and must leave the chat beside it scrollable. const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; + if (variant === 'drawer') document.body.style.overflow = 'hidden'; const handleKeyDown = (event: KeyboardEvent) => { // The terminal owns Escape (it goes to the PTY) — don't hijack it. if (event.key === 'Escape' && tabRef.current !== 'terminal') onCloseRef.current(); }; document.addEventListener('keydown', handleKeyDown); return () => { - document.body.style.overflow = previousOverflow; + if (variant === 'drawer') document.body.style.overflow = previousOverflow; document.removeEventListener('keydown', handleKeyDown); }; - }, [open]); + }, [open, variant]); - if (!rootRef.current) return null; + if (variant === 'drawer' && !rootRef.current) return null; const tabItems: SortableTabsStripItem[] = [ { id: 'changes', label: t('mobile.menu.changes'), icon: }, @@ -173,23 +184,8 @@ export const MobileWorkspaceDrawer: React.FC<{ { id: 'mcp', label: t('mobile.menu.mcp'), icon: }, ]; - return createPortal( -
+ const body = ( + <>
{/* Mounted only while shown; nonCompositedIndicator keeps the active @@ -268,7 +264,36 @@ export const MobileWorkspaceDrawer: React.FC<{
) : null}
+ + ); + + if (variant === 'panel') { + // The caller animates the width; the content itself is plain flow so it + // never gets its own compositing layer (iOS clips those to the safe-area + // viewport, which is exactly what the drawer's settled `transform: none` + // avoids on the other host). + return
{body}
; + } + + return createPortal( +
+ {body}
, - rootRef.current, + rootRef.current as HTMLElement, ); }; diff --git a/packages/ui/src/apps/ipadSidebarResize.ts b/packages/ui/src/apps/ipadSidebarResize.ts index 87463186..88a3a529 100644 --- a/packages/ui/src/apps/ipadSidebarResize.ts +++ b/packages/ui/src/apps/ipadSidebarResize.ts @@ -4,17 +4,25 @@ export const IPAD_LEFT_SIDEBAR_WIDTH = 320; export const IPAD_RIGHT_SIDEBAR_WIDTH = 380; const IPAD_SIDEBAR_MIN_WIDTH = 280; const IPAD_SIDEBAR_MAX_WIDTH = 560; +/** The workspace panel holds diffs, a file editor and a terminal, so it earns + far more room than the sessions list ever needs. */ +export const IPAD_WORKSPACE_SIDEBAR_MAX_WIDTH = 900; /** Drag-resize for the iPad sidebars: same live-width mechanics as the desktop Sidebar (imperative styles during the drag, committed to state at the end), but with a finger-sized grab strip instead of a 3px hover handle. */ -export function useIpadSidebarResize(side: 'left' | 'right', storageKey: string, defaultWidth: number) { +export function useIpadSidebarResize( + side: 'left' | 'right', + storageKey: string, + defaultWidth: number, + maxWidth: number = IPAD_SIDEBAR_MAX_WIDTH, +) { const asideRef = React.useRef(null); const [width, setWidth] = React.useState(() => { if (typeof window === 'undefined') return defaultWidth; const stored = Number.parseInt(window.localStorage.getItem(storageKey) ?? '', 10); if (!Number.isFinite(stored)) return defaultWidth; - return Math.min(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, stored)); + return Math.min(maxWidth, Math.max(IPAD_SIDEBAR_MIN_WIDTH, stored)); }); const [isResizing, setIsResizing] = React.useState(false); const startXRef = React.useRef(0); @@ -23,8 +31,8 @@ export function useIpadSidebarResize(side: 'left' | 'right', storageKey: string, const pointerIdRef = React.useRef(null); const clampWidth = React.useCallback((value: number) => ( - Math.min(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, Math.round(value))) - ), []); + Math.min(maxWidth, Math.max(IPAD_SIDEBAR_MIN_WIDTH, Math.round(value))) + ), [maxWidth]); const applyLiveWidth = React.useCallback((nextWidth: number) => { const aside = asideRef.current; diff --git a/packages/ui/src/apps/mobileNativeChrome.ts b/packages/ui/src/apps/mobileNativeChrome.ts index 3c4eb92a..a26c6eb4 100644 --- a/packages/ui/src/apps/mobileNativeChrome.ts +++ b/packages/ui/src/apps/mobileNativeChrome.ts @@ -1,5 +1,7 @@ import React from 'react'; +import { observeNativeKeyboardHeight, resetHardwareKeyboardDetection, startHardwareKeyboardBridge } from '@/lib/hardwareKeyboard'; + /** True when running inside the native Capacitor shell (iOS/Android app). */ export const isCapacitorMobileApp = (): boolean => { if (typeof window === 'undefined') return false; @@ -27,6 +29,10 @@ export const useNativeMobileChrome = (): void => { root.classList.add('oc-platform-android'); } + // iOS reports hardware keyboards natively (GCKeyboard); adopting that + // answer switches the layout off its keyboard-event inference entirely. + cleanup.push(startHardwareKeyboardBridge()); + const setInset = (px: number) => { root.style.setProperty('--oc-keyboard-inset', `${Math.max(0, Math.round(px))}px`); }; @@ -88,7 +94,8 @@ export const useNativeMobileChrome = (): void => { // oc-keyboard-open drives CSS (draft starters, composer padding), and // the settled event gives the chat its one deterministic re-pin after // the native resize (the auto-follow idle gate ignores it otherwise). - const willShowHandle = await Keyboard.addListener('keyboardWillShow', () => { + const willShowHandle = await Keyboard.addListener('keyboardWillShow', (info) => { + observeNativeKeyboardHeight(info.keyboardHeight); root.classList.add('oc-keyboard-open'); // The composer already expanded on tap — re-pin the chat to it now, // so the native resize that follows is the only remaining movement. @@ -178,6 +185,7 @@ export const useNativeMobileChrome = (): void => { const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => { clearSettle(); + observeNativeKeyboardHeight(info.keyboardHeight); keyboardOpen = true; keyboardHeight = info.keyboardHeight; if (!layoutApplied) { @@ -330,6 +338,7 @@ export const useNativeMobileChrome = (): void => { return () => { disposed = true; cleanup.forEach((remove) => remove()); + resetHardwareKeyboardDetection(); root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-kb-animating', 'oc-kb-hide', 'oc-kb-caret-hold', 'oc-platform-android'); root.style.removeProperty('--oc-keyboard-inset'); root.style.removeProperty('--oc-kb-shift'); diff --git a/packages/ui/src/apps/mobileQrScan.test.ts b/packages/ui/src/apps/mobileQrScan.test.ts index 927461fd..cba12b20 100644 --- a/packages/ui/src/apps/mobileQrScan.test.ts +++ b/packages/ui/src/apps/mobileQrScan.test.ts @@ -1,8 +1,8 @@ -import { describe, expect, test } from 'bun:test'; +import { afterEach, describe, expect, mock, test } from 'bun:test'; import { encodePairingConnectionPayload, buildPairingConnectionPayload } from '@/lib/connectionPayload'; -import { parseConnectionPayload } from './mobileQrScan'; +import { parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const; @@ -40,3 +40,126 @@ describe('parseConnectionPayload', () => { expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay#offer=eyJ2IjoxfQ')).toBeNull(); }); }); + +describe('scanConnectionQr on Android', () => { + const originalWindow = globalThis.window; + + afterEach(() => { + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); + }); + + test('uses the bundled startScan flow and cleans up after a result', async () => { + const listeners = new Map }) => void>(); + let removeCalls = 0; + let stopCalls = 0; + let scanCalls = 0; + let startOptions: unknown; + const remove = () => { removeCalls += 1; }; + const stopScan = async () => { stopCalls += 1; }; + const scan = async () => { scanCalls += 1; return { barcodes: [] }; }; + const startScan = async (options?: unknown) => { + startOptions = options; + listeners.get('barcodesScanned')?.({ barcodes: [{ rawValue: 'https://oc.example' }] }); + }; + const plugin = { + requestPermissions: mock(async () => ({ camera: 'granted' })), + scan, + startScan, + stopScan, + addListener: mock((event: string, callback: (info: { barcodes?: Array<{ rawValue?: string }> }) => void) => { + listeners.set(event, callback); + return Promise.resolve({ remove }); + }), + }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, + }); + + expect(await scanConnectionQr()).toEqual({ status: 'ok', url: 'https://oc.example' }); + expect(startOptions).toEqual({ formats: ['QR_CODE'] }); + expect(scanCalls).toBe(0); + expect(stopCalls).toBe(1); + expect(removeCalls).toBe(2); + }); + + test('stops scanning when the caller aborts', async () => { + let stopCalls = 0; + const stopScan = async () => { stopCalls += 1; }; + const plugin = { + requestPermissions: mock(async () => ({ camera: 'granted' })), + startScan: mock(async () => undefined), + stopScan, + addListener: mock(async () => ({ remove: mock(() => undefined) })), + }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, + }); + const controller = new AbortController(); + const result = scanConnectionQr({ signal: controller.signal }); + await Promise.resolve(); + controller.abort(); + + expect(await result).toEqual({ status: 'cancelled' }); + expect(stopCalls).toBe(1); + }); + + test('waits for listener setup to finish before cleaning up an aborted scan', async () => { + let finishListenerSetup: (() => void) | undefined; + let removeCalls = 0; + let startCalls = 0; + let stopCalls = 0; + const listenerSetup = new Promise((resolve) => { finishListenerSetup = resolve; }); + const remove = () => { removeCalls += 1; }; + const startScan = async () => { startCalls += 1; }; + const plugin = { + requestPermissions: mock(async () => ({ camera: 'granted' })), + startScan, + stopScan: async () => { stopCalls += 1; }, + addListener: mock(async () => { + await listenerSetup; + return { remove }; + }), + }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, + }); + const controller = new AbortController(); + const result = scanConnectionQr({ signal: controller.signal }); + await Promise.resolve(); + controller.abort(); + finishListenerSetup?.(); + + expect(await result).toEqual({ status: 'cancelled' }); + expect(startCalls).toBe(0); + expect(removeCalls).toBe(2); + expect(stopCalls).toBe(1); + }); + + test('cleans up successful listener registration when the other listener fails', async () => { + let removeCalls = 0; + let startCalls = 0; + let stopCalls = 0; + const remove = () => { removeCalls += 1; }; + const plugin = { + requestPermissions: mock(async () => ({ camera: 'granted' })), + startScan: async () => { startCalls += 1; }, + stopScan: async () => { stopCalls += 1; }, + addListener: mock(async (event: string) => { + if (event === 'scanError') throw new Error('listener setup failed'); + return { remove }; + }), + }; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } }, + }); + + expect(await scanConnectionQr()).toEqual({ status: 'failed' }); + expect(startCalls).toBe(0); + expect(removeCalls).toBe(1); + expect(stopCalls).toBe(1); + }); +}); diff --git a/packages/ui/src/apps/mobileQrScan.ts b/packages/ui/src/apps/mobileQrScan.ts index e4b12c3f..e1980e99 100644 --- a/packages/ui/src/apps/mobileQrScan.ts +++ b/packages/ui/src/apps/mobileQrScan.ts @@ -1,14 +1,8 @@ // Connection payload parsing + native QR scanning for the dedicated mobile app. // -// Pairing v2 links (openchamber://connect?v=2&p=) carry a one-time -// secret and a list of transport candidates (lan / tunnel / relay); they are -// redeemed server-side over whichever candidate connects first. We also accept a -// bare http(s) URL so a QR encoding only the server address works. -// -// QR scanning is delegated to a Capacitor barcode-scanner plugin if the native -// shell registered one (`window.Capacitor.Plugins.BarcodeScanner`). We resolve it -// at runtime instead of importing the package so the web build stays dependency-free -// and the browser-hosted mobile UI degrades to `unsupported` cleanly. +// Android uses the plugin's CameraX-backed startScan() flow. Unlike its ready-made +// scan() activity, this path bundles the barcode model in the app and does not need +// Google Play Services. iOS keeps the native ready-made scanner. import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload'; @@ -32,74 +26,16 @@ export type QrScanResult = | { status: 'failed' }; type ScannedBarcode = { rawValue?: string; displayValue?: string }; - -type ModuleInstallProgress = { state?: number }; -type ListenerHandle = { remove: () => void }; - +type ListenerHandle = { remove: () => void | Promise }; type BarcodeScannerPlugin = { requestPermissions?: () => Promise<{ camera?: string } | undefined>; scan?: (options?: { formats?: string[] }) => Promise<{ barcodes?: ScannedBarcode[] } | undefined>; - // Android-only: the Google code scanner used by scan() needs the ML Kit barcode module, - // which Play Services must download once before the first scan. Absent on iOS. - isGoogleBarcodeScannerModuleAvailable?: () => Promise<{ available?: boolean } | undefined>; - installGoogleBarcodeScannerModule?: () => Promise; + startScan?: (options?: { formats?: string[] }) => Promise; + stopScan?: () => Promise; addListener?: ( - event: 'googleBarcodeScannerModuleInstallProgress', - cb: (info: ModuleInstallProgress) => void, - ) => Promise; -}; - -// Google's ModuleInstallProgress states: 4 = COMPLETED, 3 = CANCELED, 5 = FAILED. -const MODULE_STATE_COMPLETED = 4; -const MODULE_STATE_CANCELED = 3; -const MODULE_STATE_FAILED = 5; -const MODULE_INSTALL_TIMEOUT_MS = 90_000; - -// Ensure the Android Google barcode module is downloaded before scanning. No-op on platforms -// where these methods don't exist (iOS) or when it's already available. Resolves once the module -// is usable; rejects if the install is canceled, fails, or times out. -const ensureScannerModule = async (plugin: BarcodeScannerPlugin): Promise => { - const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; - if ( - capacitor?.getPlatform?.() !== 'android' || - !plugin.isGoogleBarcodeScannerModuleAvailable || - !plugin.installGoogleBarcodeScannerModule - ) { - return; - } - const status = await plugin.isGoogleBarcodeScannerModuleAvailable().catch(() => undefined); - if (status?.available) return; - - await new Promise((resolve, reject) => { - let handle: ListenerHandle | undefined; - const finish = (fn: () => void) => { - window.clearTimeout(timer); - handle?.remove(); - fn(); - }; - const timer = window.setTimeout( - () => finish(() => reject(new Error('module install timed out'))), - MODULE_INSTALL_TIMEOUT_MS, - ); - // addListener may return a handle synchronously OR a Promise depending on the - // Capacitor proxy — normalize with Promise.resolve so a non-thenable handle doesn't throw - // and abort the install call below. - Promise.resolve( - plugin.addListener?.('googleBarcodeScannerModuleInstallProgress', (info) => { - if (info?.state === MODULE_STATE_COMPLETED) finish(resolve); - else if (info?.state === MODULE_STATE_CANCELED || info?.state === MODULE_STATE_FAILED) { - finish(() => reject(new Error('module install failed'))); - } - }), - ) - .then((h) => { - handle = h as ListenerHandle | undefined; - }) - .catch(() => undefined); - Promise.resolve(plugin.installGoogleBarcodeScannerModule?.()).catch((error) => - finish(() => reject(error instanceof Error ? error : new Error('module install failed'))), - ); - }); + event: 'barcodesScanned' | 'scanError', + cb: (info: { barcodes?: ScannedBarcode[]; message?: string }) => void, + ) => Promise | ListenerHandle; }; const getScannerPlugin = (): BarcodeScannerPlugin | null => { @@ -108,7 +44,12 @@ const getScannerPlugin = (): BarcodeScannerPlugin | null => { Capacitor?: { Plugins?: Record }; }).Capacitor; const plugin = capacitor?.Plugins?.BarcodeScanner as BarcodeScannerPlugin | undefined; - return plugin && typeof plugin.scan === 'function' ? plugin : null; + return plugin && (typeof plugin.scan === 'function' || typeof plugin.startScan === 'function') ? plugin : null; +}; + +const isAndroid = (): boolean => { + const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; + return capacitor?.getPlatform?.() === 'android'; }; export const parseConnectionPayload = (raw: string): MobileConnectionPayload | MobilePairingPayload | null => { @@ -124,54 +65,85 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | M return null; }; -// The Google code scanner can briefly still throw "module not available" in the moments right -// after its install completes. Detect that specific error so we can re-ensure + retry rather -// than surfacing a failure the user would have to manually tap through. -const isModuleUnavailableError = (error: unknown): boolean => { - const message = - typeof error === 'object' && error && 'message' in error - ? String((error as { message?: unknown }).message ?? '') - : String(error ?? ''); - return /module/i.test(message) && /not\s*available|unavailable/i.test(message); +const resultFromRawValue = (raw: string): QrScanResult => { + const payload = parseConnectionPayload(raw); + if (!payload) return { status: 'invalid' }; + if ('pairing' in payload) return { status: 'pairing', ...payload }; + return { status: 'ok', ...payload }; +}; + +const scanWithBundledAndroidScanner = async ( + plugin: BarcodeScannerPlugin, + signal?: AbortSignal, +): Promise => { + if (!plugin.startScan || !plugin.stopScan || !plugin.addListener) return { status: 'unsupported' }; + if (signal?.aborted) return { status: 'cancelled' }; + + let barcodeListener: ListenerHandle | undefined; + let errorListener: ListenerHandle | undefined; + let settled = false; + let resolveResult: (result: QrScanResult) => void = () => undefined; + + const result = new Promise((resolve) => { + resolveResult = resolve; + }); + const finish = (scanResult: QrScanResult) => { + if (settled) return; + settled = true; + resolveResult(scanResult); + }; + const abort = () => finish({ status: 'cancelled' }); + signal?.addEventListener('abort', abort, { once: true }); + + try { + const listenerResults = await Promise.allSettled([ + Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => { + const barcode = barcodes?.[0]; + const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim(); + if (raw) finish(resultFromRawValue(raw)); + })).then((handle) => { barcodeListener = handle; }), + Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' }))) + .then((handle) => { errorListener = handle; }), + ]); + + if (listenerResults.some(({ status }) => status === 'rejected')) { + finish({ status: 'failed' }); + } else if (!settled) { + void plugin.startScan({ formats: ['QR_CODE'] }).catch(() => finish({ status: 'failed' })); + } + + return await result; + } finally { + signal?.removeEventListener('abort', abort); + await Promise.allSettled([ + Promise.resolve(barcodeListener?.remove()), + Promise.resolve(errorListener?.remove()), + plugin.stopScan(), + ]); + } }; export const isQrScanSupported = (): boolean => getScannerPlugin() !== null; -export const scanConnectionQr = async (): Promise => { +export const scanConnectionQr = async (options?: { signal?: AbortSignal }): Promise => { const plugin = getScannerPlugin(); - if (!plugin?.scan) return { status: 'unsupported' }; + if (!plugin) return { status: 'unsupported' }; try { if (plugin.requestPermissions) { const permission = await plugin.requestPermissions(); const camera = permission?.camera; - if (camera && camera !== 'granted' && camera !== 'limited') { - return { status: 'permission-denied' }; - } + if (camera && camera !== 'granted' && camera !== 'limited') return { status: 'permission-denied' }; } - // First scan on Android downloads the Google barcode module (the button stays in its - // scanning state for the whole wait). The module can still report "not available" for a - // moment right after install, so re-ensure + retry within this same call instead of erroring - // out — the user shouldn't have to guess to tap again. - for (let attempt = 0; attempt < 3; attempt++) { - try { - await ensureScannerModule(plugin); - const result = await plugin.scan({ formats: ['QR_CODE'] }); - const barcode = result?.barcodes?.[0]; - const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim(); - if (!raw) return { status: 'cancelled' }; + if (options?.signal?.aborted) return { status: 'cancelled' }; + if (isAndroid()) return scanWithBundledAndroidScanner(plugin, options?.signal); + if (!plugin.scan) return { status: 'unsupported' }; - const payload = parseConnectionPayload(raw); - if (!payload) return { status: 'invalid' }; - if ('pairing' in payload) return { status: 'pairing', ...payload }; - return { status: 'ok', ...payload }; - } catch (error) { - if (!isModuleUnavailableError(error) || attempt === 2) return { status: 'failed' }; - await new Promise((resolve) => window.setTimeout(resolve, 600)); - } - } - return { status: 'failed' }; + const result = await plugin.scan({ formats: ['QR_CODE'] }); + const barcode = result?.barcodes?.[0]; + const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim(); + return raw ? resultFromRawValue(raw) : { status: 'cancelled' }; } catch { return { status: 'failed' }; } diff --git a/packages/ui/src/apps/useEdgeSwipe.ts b/packages/ui/src/apps/useEdgeSwipe.ts index 5247c00f..5aa1d5d8 100644 --- a/packages/ui/src/apps/useEdgeSwipe.ts +++ b/packages/ui/src/apps/useEdgeSwipe.ts @@ -15,6 +15,10 @@ import React from 'react'; */ const EDGE_ZONE = 32; // px from a side where the swipe must begin +// Android reserves the physical screen edge for system navigation. Accept a +// wider start area so both OpenChamber drawers can be invoked beyond the +// system Back gesture region without changing the browser/iOS gesture. +const ANDROID_EDGE_ZONE = 80; const MIN_DISTANCE = 64; // px of horizontal travel required to commit const MAX_OFF_AXIS_RATIO = 0.7; // |dy| must stay below |dx| * this (keep it horizontal) @@ -36,6 +40,8 @@ export const useEdgeSwipe = ( React.useEffect(() => { const element = ref.current; if (!element) return; + const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + const edgeZone = platform === 'android' ? ANDROID_EDGE_ZONE : EDGE_ZONE; let tracking = false; let fromLeftEdge = false; @@ -49,8 +55,8 @@ export const useEdgeSwipe = ( } const touch = event.touches[0]; const width = element.clientWidth; - const nearLeft = touch.clientX <= EDGE_ZONE; - const nearRight = touch.clientX >= width - EDGE_ZONE; + const nearLeft = touch.clientX <= edgeZone; + const nearRight = touch.clientX >= width - edgeZone; tracking = nearLeft || nearRight; fromLeftEdge = nearLeft; startX = touch.clientX; diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index b1cda99e..feda2c44 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -52,6 +52,8 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; // useMessageStore removed — messages now come from sync system import { isVSCodeRuntime } from '@/lib/desktop'; +import { useTabletLayout } from '@/lib/device'; +import { useHardwareKeyboard } from '@/lib/hardwareKeyboard'; import { isIMECompositionEvent } from '@/lib/ime'; import { getCycledPrimaryAgentName, type MobileControlsPanel } from './mobileControlsUtils'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; @@ -349,6 +351,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); const agents = getVisibleAgents(); const isMobile = useUIStore((state) => state.isMobile); + const hasHardwareKeyboard = useHardwareKeyboard(); + const { enabled: isTabletLayout } = useTabletLayout(); const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen); const inputBarOffset = useUIStore((state) => state.inputBarOffset); const persistChatDraft = useUIStore((state) => state.persistChatDraft); @@ -369,6 +373,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ); 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); @@ -445,9 +450,12 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!currentDirectory || !runtimeGit) return; return sessionEvents.onGitRefreshHint((hint) => { if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) return; - void fetchGitStatus(currentDirectory, runtimeGit); + if (hint.paths?.length) { + clearGitDiffCache(currentDirectory, hint.paths); + } + void fetchGitStatus(currentDirectory, runtimeGit, { silent: true }); }); - }, [currentDirectory, runtimeGit, fetchGitStatus]); + }, [clearGitDiffCache, currentDirectory, runtimeGit, fetchGitStatus]); const handleStartReviewFlow = React.useCallback(async (execution: ReviewFlowExecution) => { if (!currentSessionId) return; @@ -2233,6 +2241,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo editorRef: composerRef, formRef: composerFormRef, setExpandedInput, + // The pill exists to buy screen back from the soft keyboard. A tablet + // has the room regardless, and with a hardware keyboard there is no + // soft keyboard to buy it back from — keep the real composer up. + alwaysExpanded: hasHardwareKeyboard || isTabletLayout, holders: { controlsPanelOpen: Boolean(mobileControlsPanel), attachMenuOpen: mobileAttachMenuOpen, diff --git a/packages/ui/src/components/chat/composer/language/__tests__/mentions.test.ts b/packages/ui/src/components/chat/composer/language/__tests__/mentions.test.ts index effd1cd7..d36ee901 100644 --- a/packages/ui/src/components/chat/composer/language/__tests__/mentions.test.ts +++ b/packages/ui/src/components/chat/composer/language/__tests__/mentions.test.ts @@ -138,6 +138,11 @@ describe('classifyMention', () => { expect(classifyMention('', classifier)).toBeNull(); }); + test('HTML fragments do not classify as file references', () => { + expect(classifyMention('import', classifier)).toBeNull(); + expect(classifyMention('src/ { const shadowed = { knownAgentNames: new Set(['a.ts']), @@ -152,4 +157,3 @@ describe('classifyMention', () => { expect(looksLikeFilePath('plain', new Set(['plain']))).toBe(true); }); }); - diff --git a/packages/ui/src/components/chat/composer/language/mentions.ts b/packages/ui/src/components/chat/composer/language/mentions.ts index 7bedd7cc..ed79d68d 100644 --- a/packages/ui/src/components/chat/composer/language/mentions.ts +++ b/packages/ui/src/components/chat/composer/language/mentions.ts @@ -116,6 +116,9 @@ export function classifyMention( classifier: MentionClassifier, ): MentionKind | null { if (!name) return null; + // HTML fragments are prompt text, never references. In particular, do not + // interpret CSS syntax such as `@import` as a local file path. + if (name.includes('<') || name.includes('>')) return null; if (classifier.knownAgentNames.has(name.toLowerCase())) return 'agent'; if (looksLikeFilePath(name, classifier.confirmedMentions)) return 'file'; return null; diff --git a/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts b/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts index 7f46bf66..32b15868 100644 --- a/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts +++ b/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts @@ -18,6 +18,7 @@ import React from 'react'; import { flushSync } from 'react-dom'; +import { observeEditorFocus } from '@/lib/hardwareKeyboard'; import { isCapacitorApp } from '@/lib/platform'; import type { ComposerEditorHandle } from '../editor/ComposerEditor'; @@ -41,6 +42,13 @@ export interface MobileComposerShellOptions { formRef: React.RefObject; setExpandedInput: (expanded: boolean) => void; holders: MobileComposerHolders; + /** + * Keep the full composer up permanently and never fall back to the pill. + * The pill exists to buy screen back from the soft keyboard; with a + * hardware keyboard on a tablet there is no soft keyboard to hide from, + * and collapsing between keystrokes would only cost the user a tap. + */ + alwaysExpanded?: boolean; } export interface MobileComposerShell { @@ -65,9 +73,9 @@ export interface MobileComposerShell { export function useMobileComposerShell( options: MobileComposerShellOptions, ): MobileComposerShell { - const { isMobile, editorRef, formRef, setExpandedInput, holders } = options; + const { isMobile, editorRef, formRef, setExpandedInput, holders, alwaysExpanded = false } = options; - const [expanded, setExpanded] = React.useState(false); + const [expanded, setExpanded] = React.useState(alwaysExpanded && isMobile); const [focused, setFocused] = React.useState(false); const [overlayHostBusy, setOverlayHostBusy] = React.useState(false); const [dictationActive, setDictationActive] = React.useState(false); @@ -88,6 +96,16 @@ export function useMobileComposerShell( expandedRef.current = expanded; }); + // A hardware keyboard can be attached (or detached) at any moment, so this + // is a live condition rather than a mount-time one. Detaching does NOT + // force a collapse — the normal idle/keyboard-hide paths take over again. + const alwaysExpandedRef = React.useRef(alwaysExpanded); + alwaysExpandedRef.current = alwaysExpanded; + React.useEffect(() => { + if (!isMobile || !alwaysExpanded) return; + setExpanded(true); + }, [alwaysExpanded, isMobile]); + // The draft screen restructures itself around the composer: its starter // chips leave once the full composer is up, and its centered title // re-centers over whatever room remains. Announced as a root class from a @@ -95,12 +113,16 @@ export function useMobileComposerShell( // swap — keyed on the keyboard instead (oc-keyboard-open arrives with the // keyboardWillShow bridge event, ~100ms later), the chips vanished // mid-rise as a second visible jump. + // + // Not announced while `alwaysExpanded`: there the full composer is the + // resting state, not a keyboard takeover, so claiming otherwise would hide + // the starters permanently. The keyboard classes still cover that case. React.useLayoutEffect(() => { if (!isMobile || typeof document === 'undefined') return; const root = document.documentElement; - root.classList.toggle('oc-composer-expanded', expanded); + root.classList.toggle('oc-composer-expanded', expanded && !alwaysExpanded); return () => root.classList.remove('oc-composer-expanded'); - }, [expanded, isMobile]); + }, [alwaysExpanded, expanded, isMobile]); const expand = React.useCallback(() => { expandIntentRef.current = 'focus'; @@ -151,7 +173,7 @@ export function useMobileComposerShell( // insert-and-send) collapse straight back to the pill rather than // parking on the normal composer for the usual grace period. window.setTimeout(() => { - if (!expandedRef.current) return; + if (!expandedRef.current || alwaysExpandedRef.current) return; if (editorRef.current?.isFocused()) return; setExpanded(false); setExpandedInput(false); @@ -288,7 +310,7 @@ export function useMobileComposerShell( || holders.isDragging; React.useEffect(() => { - if (!isMobile || !expanded || busy) return; + if (!isMobile || !expanded || busy || alwaysExpanded) return; const timer = window.setTimeout(() => { // Authoritative DOM check: the React focus state can lag a // programmatic refocus (the overlay-close restore above). @@ -299,7 +321,7 @@ export function useMobileComposerShell( setExpandedInput(false); }, 250); return () => window.clearTimeout(timer); - }, [busy, editorRef, expanded, isMobile, setExpandedInput]); + }, [alwaysExpanded, busy, editorRef, expanded, isMobile, setExpandedInput]); const busyRef = React.useRef(false); busyRef.current = busy; @@ -344,7 +366,7 @@ export function useMobileComposerShell( const handleIntent = (event: Event) => { const detail = (event as CustomEvent<{ open?: boolean }>).detail; if (!detail || detail.open !== false) return; - if (!expandedRef.current) return; + if (!expandedRef.current || alwaysExpandedRef.current) return; // Something still holds the composer open (dictation, an overlay // that closed the keyboard, a drag) — the fallback path handles it. if (busyRef.current) return; @@ -360,6 +382,9 @@ export function useMobileComposerShell( const onEditorFocus = React.useCallback(() => { if (!isMobile) return; + // Focus is the only moment a soft keyboard would be presented, so it is + // also the only moment its ABSENCE tells us a hardware one is attached. + if (isCapacitorApp()) observeEditorFocus(); if (blurTimerRef.current !== null) { window.clearTimeout(blurTimerRef.current); blurTimerRef.current = null; diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts new file mode 100644 index 00000000..86db0bbd --- /dev/null +++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test'; + +import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity'; + +describe('markdown sanitization', () => { + test('turns raw assistant HTML into inert visible text', () => { + const payload = ''; + + expect(escapeRawMarkdownHtml(payload)).toBe( + '<style>@import url("https://example.test/theme.css");</style>', + ); + }); + + test('forbids script and stylesheet elements as active content', () => { + expect(MARKDOWN_FORBIDDEN_TAGS).toContain('script'); + expect(MARKDOWN_FORBIDDEN_TAGS).toContain('style'); + }); +}); diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 250fc1cd..ad906d99 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -5,6 +5,7 @@ import DOMPurify from 'dompurify'; import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks'; import { isVSCodeRuntime } from '@/lib/desktop'; import { highlightCodeInWorker } from './markdown-worker'; +import { escapeRawMarkdownHtml, MARKDOWN_FORBIDDEN_TAGS } from './markdownSecurity'; const escapeAttr = (value: string): string => value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); @@ -166,6 +167,12 @@ const parser = marked.use({ breaks: false, extensions: [inlineMathExtension, blockMathExtension], renderer: { + // Assistant output is untrusted. Markdown constructs still render as HTML, + // but raw HTML must remain visible text so it cannot introduce active DOM + // such as stylesheets or positioned overlays into the application shell. + html({ text }) { + return escapeRawMarkdownHtml(text); + }, link({ href, title, text }) { const target = href ?? ''; const agentName = parseAgentHref(target); @@ -283,8 +290,10 @@ const SANITIZE_CONFIG = { USE_PROFILES: { html: true, mathMl: true, svg: true }, ADD_TAGS: ['svg', 'path', 'g', 'rect', 'line', 'polygon', 'polyline', 'circle', 'ellipse', 'text', 'tspan', 'defs', 'marker'], ADD_ATTR: ['d', 'viewBox', 'preserveAspectRatio', 'xmlns', 'target', 'fill', 'stroke', 'stroke-width', 'transform', 'points', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'style'], - FORBID_TAGS: ['script'], - FORBID_CONTENTS: ['script'], + // Defense in depth for generated/highlighter HTML after raw markdown HTML + // has been escaped by the marked renderer above. + FORBID_TAGS: [...MARKDOWN_FORBIDDEN_TAGS], + FORBID_CONTENTS: [...MARKDOWN_FORBIDDEN_TAGS], }; let sanitizeHookInstalled = false; diff --git a/packages/ui/src/components/chat/markdown/markdownSecurity.ts b/packages/ui/src/components/chat/markdown/markdownSecurity.ts new file mode 100644 index 00000000..158ffa0b --- /dev/null +++ b/packages/ui/src/components/chat/markdown/markdownSecurity.ts @@ -0,0 +1,6 @@ +/** Raw HTML in assistant markdown is untrusted and must stay inert text. */ +export const escapeRawMarkdownHtml = (value: string): string => + value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + +/** Active elements forbidden again at the final DOMPurify boundary. */ +export const MARKDOWN_FORBIDDEN_TAGS = ['script', 'style'] as const; diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index ee65064d..ae701111 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -34,7 +34,6 @@ import { copyTextToClipboard } from '@/lib/clipboard'; import { useChatSurfaceMode } from '@/components/chat/useChatSurfaceMode'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { toPng } from 'html-to-image'; import { toast } from '@/components/ui'; import { Icon } from "@/components/icon/Icon"; import { formatTimestampForDisplay } from './timeFormat'; @@ -1532,6 +1531,9 @@ const AssistantMessageBody = React.memo(({ let wrapper: HTMLDivElement | null = null; try { + // Load the exporter before attaching its temporary clone so a slow + // chunk request cannot leave export-only content in the page layout. + const { toPng } = await import('html-to-image'); const originalElement = sourceElement; const computedStyle = window.getComputedStyle(originalElement); const rootStyle = window.getComputedStyle(document.documentElement); diff --git a/packages/ui/src/components/chat/message/parts/ApplyPatchFileButtons.test.tsx b/packages/ui/src/components/chat/message/parts/ApplyPatchFileButtons.test.tsx new file mode 100644 index 00000000..5dad5d58 --- /dev/null +++ b/packages/ui/src/components/chat/message/parts/ApplyPatchFileButtons.test.tsx @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'bun:test'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import type { EditorAPI } from '@/lib/api/types'; + +import { ApplyPatchFileButtons } from './ApplyPatchFileButtons'; +import { openApplyPatchFileInEditor } from './applyPatchEditorAction'; + +const makePatch = (path: string, line: number, before: string, after: string) => [ + `--- a/${path}`, + `+++ b/${path}`, + `@@ -${line} +${line} @@`, + `-${before}`, + `+${after}`, +].join('\n'); + +const files = [ + { + filePath: '/workspace/project/src/first.ts', + relativePath: 'src/first.ts', + patch: makePatch('src/first.ts', 4, 'first old', 'first new'), + additions: 1, + deletions: 1, + type: 'update', + }, + { + filePath: '/workspace/project/src/second.ts', + relativePath: 'src/second.ts', + patch: makePatch('src/second.ts', 12, 'second old', 'second new'), + additions: 1, + deletions: 1, + type: 'update', + }, +]; + +describe('ApplyPatchFileButtons', () => { + test('renders one labeled button per non-deleted file', () => { + const markup = renderToStaticMarkup( + undefined} + />, + ); + + expect(markup.match(/ + ) : ( + + {content} + + ); + })} + + ); +}; diff --git a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md index 02fbd092..aaf0b2b4 100644 --- a/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/message/parts/DOCUMENTATION.md @@ -51,6 +51,10 @@ Use this doc when you ask an agent to change tool/header/description behavior. ## Current important behavior +- Assistant markdown treats raw HTML as inert visible text. The final generated + HTML is sanitized as defense in depth, with script and style elements + forbidden, so message content cannot inject active DOM or application-wide + CSS into any runtime surface. - `read` and `skill` are **static navigation tools** and render via `StaticToolRow`. - Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`. - The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card. diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index b703c356..eb3e0cf8 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -54,11 +54,23 @@ import { } from './taskToolModel'; import { areRenderRelevantPartsEqual } from '../renderCompare'; import { useI18n } from '@/lib/i18n'; -import { getDiffPatchEntries, getPatchText, type DiffPatchEntry } from './toolDiffUtils'; +import { + extractFirstChangedLineFromDiff, + getDiffPatchEntries, + getFirstChangedLineFromMetadata, + getMutatedToolPaths, + getPatchText, + getPrimaryDiffFromMetadata, + getPrimaryToolPath, + type DiffPatchEntry, +} from './toolDiffUtils'; import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat'; import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle'; import { getStreamingOutputAppend, getToolOutput } from './toolOutput'; +import { toAbsoluteFilePath } from '@/lib/path-utils'; import { getToolDescriptionFallback } from './toolRenderUtils'; +import { ApplyPatchFileButtons } from './ApplyPatchFileButtons'; +import { openApplyPatchFileInEditor } from './applyPatchEditorAction'; const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal'; const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS); @@ -77,84 +89,6 @@ interface ToolPartProps { animateTailText?: boolean; } -const getMultiFileDescription = ( - metadata: Record | undefined, - animate = true, - showFileIcons = true, -): React.ReactNode => { - const files = Array.isArray(metadata?.files) ? metadata?.files : []; - if (files.length <= 1) return null; - - const parseCount = (value: unknown): number | null => { - if (typeof value === 'number' && Number.isFinite(value)) { - return Math.max(0, Math.trunc(value)); - } - if (typeof value === 'string') { - const parsed = Number.parseInt(value, 10); - if (Number.isFinite(parsed)) { - return Math.max(0, parsed); - } - } - return null; - }; - - const combineCounts = (base: number | null, incoming: number | null): number | null => { - if (base === null) return incoming; - if (incoming === null) return base; - return base + incoming; - }; - - const entriesByPath = new Map(); - - for (const file of files) { - const fileObj = file as { relativePath?: string; filePath?: string; additions?: unknown; deletions?: unknown }; - const filePath = fileObj.relativePath || fileObj.filePath || ''; - if (!filePath) continue; - const fileName = filePath.split('/').pop() || filePath; - const added = parseCount(fileObj.additions); - const removed = parseCount(fileObj.deletions); - - const existing = entriesByPath.get(filePath); - if (existing) { - existing.added = combineCounts(existing.added, added); - existing.removed = combineCounts(existing.removed, removed); - continue; - } - - entriesByPath.set(filePath, { path: filePath, name: fileName, added, removed }); - } - - const entries = Array.from(entriesByPath.values()); - - return ( - <> - {entries.map((entry) => { - const hasPerFileDiff = entry.added !== null || entry.removed !== null; - return ( - - {showFileIcons ? : null} - - {entry.name} - - {hasPerFileDiff ? ( - - +{entry.added ?? 0} - / - -{entry.removed ?? 0} - - ) : null} - - ); - })} - - ); -}; - const normalizeToolName = (toolName: string | undefined | null): string => { if (typeof toolName !== 'string') { return ''; @@ -180,7 +114,6 @@ const GIT_REFRESH_MUTATING_TOOLS = new Set([ 'write', 'apply_patch', 'patch', - 'task', ]); const formatDuration = (start: number, end?: number, now: number = Date.now()) => { @@ -306,54 +239,6 @@ const parseWriteLineCount = (input?: Record): number | null => return lines; }; -const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => { - if (!diffText || typeof diffText !== 'string') { - return undefined; - } - - const lines = diffText.split('\n'); - let currentNewLine: number | undefined; - let firstHunkStart: number | undefined; - - for (const rawLine of lines) { - const line = rawLine.replace(/\r$/, ''); - const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); - if (hunkMatch) { - const parsed = Number.parseInt(hunkMatch[1] ?? '', 10); - if (Number.isFinite(parsed)) { - currentNewLine = Math.max(1, parsed); - if (!Number.isFinite(firstHunkStart)) { - firstHunkStart = currentNewLine; - } - } - continue; - } - - if (currentNewLine === undefined || !Number.isFinite(currentNewLine)) { - continue; - } - - if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) { - continue; - } - - if (line.startsWith('+')) { - return currentNewLine; - } - - if (line.startsWith(' ')) { - currentNewLine += 1; - continue; - } - - if (line.startsWith('-') || line.startsWith('\\')) { - continue; - } - } - - return firstHunkStart; -}; - const buildWritePreviewPatch = (filePath: string | undefined, content: string): string | undefined => { const normalizedContent = content.replace(/\r\n/g, '\n'); if (!normalizedContent.trim()) { @@ -380,73 +265,6 @@ const buildWritePreviewPatch = (filePath: string | undefined, content: string): ].join('\n'); }; -const getFirstChangedLineFromMetadata = (tool: string, metadata?: Record): number | undefined => { - if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) { - return undefined; - } - - const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff); - if (topLevelPatch) { - const line = extractFirstChangedLineFromDiff(topLevelPatch); - if (Number.isFinite(line)) { - return line; - } - } - - const files = Array.isArray(metadata.files) ? metadata.files : []; - const firstFile = files[0] as { patch?: unknown; diff?: unknown } | undefined; - const filePatch = getPatchText(firstFile?.patch) ?? getPatchText(firstFile?.diff); - if (filePatch) { - const line = extractFirstChangedLineFromDiff(filePatch); - if (Number.isFinite(line)) { - return line; - } - } - - return undefined; -}; - -const getPrimaryDiffFromMetadata = ( - tool: string, - metadata?: Record, - preferredPath?: string, -): string | undefined => { - if (!metadata || (tool !== 'edit' && tool !== 'multiedit' && tool !== 'apply_patch')) { - return undefined; - } - - const files = Array.isArray(metadata.files) ? metadata.files : []; - if (files.length > 0) { - const preferred = typeof preferredPath === 'string' && preferredPath.length > 0 - ? preferredPath - : undefined; - const matched = preferred - ? files.find((file) => { - if (!file || typeof file !== 'object') { - return false; - } - const candidate = file as { relativePath?: unknown; filePath?: unknown }; - return candidate.relativePath === preferred || candidate.filePath === preferred; - }) - : files[0]; - - if (matched && typeof matched === 'object') { - const patch = getPatchText((matched as { patch?: unknown; diff?: unknown }).patch) - ?? getPatchText((matched as { patch?: unknown; diff?: unknown }).diff); - if (patch) { - return patch; - } - } - } - - const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff); - if (topLevelPatch) { - return topLevelPatch; - } - - return undefined; -}; - const normalizeDisplayPath = (value: string): string => { const trimmed = value.trim().replace(/\\/g, '/').replace(/\/{2,}/g, '/'); if (!trimmed || trimmed === '/') { @@ -526,58 +344,6 @@ const normalizeToolDiagnostic = (value: unknown): ToolDiagnostic | null => { }; }; -const getPrimaryToolPath = ( - toolName: string, - input: Record | undefined, - metadata: Record | undefined, -): string | null => { - if (toolName === 'apply_patch') { - const files = Array.isArray(metadata?.files) ? metadata.files : []; - const first = files.find((entry) => { - if (!isRecord(entry)) { - return false; - } - return entry.type !== 'delete'; - }); - if (!isRecord(first)) { - return null; - } - return typeof first.movePath === 'string' - ? first.movePath - : typeof first.filePath === 'string' - ? first.filePath - : typeof first.relativePath === 'string' - ? first.relativePath - : null; - } - - if (toolName === 'edit' || toolName === 'multiedit') { - const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined; - if (isRecord(fileDiff) && typeof fileDiff.file === 'string') { - return fileDiff.file; - } - return typeof input?.filePath === 'string' - ? input.filePath - : typeof input?.file_path === 'string' - ? input.file_path - : typeof input?.path === 'string' - ? input.path - : null; - } - - if (toolName === 'write') { - return typeof input?.filePath === 'string' - ? input.filePath - : typeof input?.file_path === 'string' - ? input.file_path - : typeof input?.path === 'string' - ? input.path - : null; - } - - return null; -}; - const getToolDiagnosticSection = ( toolName: string, input: Record | undefined, @@ -1686,9 +1452,7 @@ const ToolExpandedContent: React.FC = React.memo(({ ); const renderResultContent = () => { - const getEntryAbsolutePath = (entry: DiffPatchEntry) => ( - entry.title.startsWith('/') ? entry.title : `${currentDirectory}/${entry.title}`.replace(/\/+/g, '/') - ); + const getEntryAbsolutePath = (entry: DiffPatchEntry) => toAbsoluteFilePath(currentDirectory, entry.filePath ?? entry.title); const openEntryFile = (entry: DiffPatchEntry, event: React.MouseEvent) => { event.stopPropagation(); const line = extractFirstChangedLineFromDiff(entry.patch); @@ -2054,7 +1818,11 @@ const ToolPartContent: React.FC = ({ onShowPopup, animateTailText = true, }) => { + const { t } = useI18n(); const state = part.state; + const stateWithData = state as ToolStateWithMetadata; + const metadata = stateWithData.metadata; + const input = stateWithData.input; const showToolFileIcons = useUIStore((s) => s.showToolFileIcons); const currentDirectory = useEffectiveDirectory() ?? ''; @@ -2063,18 +1831,19 @@ const ToolPartContent: React.FC = ({ const status = state?.status as string | undefined; const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled'; + const isSuccessfullyFinalized = status === 'completed'; const isError = status === 'error' || status === 'failed'; const [activeLatched, setActiveLatched] = React.useState(!isFinalized); const previousPartIdRef = React.useRef(part.id); - const lastGitRefreshSignatureRef = React.useRef(''); + const observedActiveGitToolRef = React.useRef(!isFinalized); React.useEffect(() => { if (previousPartIdRef.current === part.id) { return; } previousPartIdRef.current = part.id; - lastGitRefreshSignatureRef.current = ''; + observedActiveGitToolRef.current = !isFinalized; // Reset latch only when tool identity changes. setActiveLatched(!isFinalized); }, [isFinalized, part.id]); @@ -2086,20 +1855,34 @@ const ToolPartContent: React.FC = ({ }, [isFinalized]); React.useEffect(() => { - if (!isFinalized || isError || !currentDirectory) { - return; - } - if (!GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) { + if (!isFinalized) { + observedActiveGitToolRef.current = true; return; } - const signature = `${part.id}:${status ?? 'unknown'}`; - if (lastGitRefreshSignatureRef.current === signature) { + // Historical completed tools can remount when the timeline changes. + // Refresh only for a tool whose active state this instance observed. + const finalizedAfterObservedActive = observedActiveGitToolRef.current; + if (!finalizedAfterObservedActive) { return; } - lastGitRefreshSignatureRef.current = signature; - sessionEvents.requestGitRefresh({ directory: currentDirectory }); - }, [currentDirectory, isError, isFinalized, normalizedPartTool, part.id, status]); + + if (!isSuccessfullyFinalized || !GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) { + observedActiveGitToolRef.current = false; + return; + } + if (!currentDirectory) { + return; + } + + observedActiveGitToolRef.current = false; + const paths = getMutatedToolPaths(normalizedPartTool, input, metadata) + .map((path) => getRelativePath(path, currentDirectory)); + sessionEvents.requestGitRefresh({ + directory: currentDirectory, + ...(paths.length > 0 ? { paths } : {}), + }); + }, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]); const shouldNotifyStructuralChange = isFinalized || isTaskTool; @@ -2125,10 +1908,7 @@ const ToolPartContent: React.FC = ({ } }, [isExpanded, isTaskTool, shouldNotifyStructuralChange]); - const stateWithData = state as ToolStateWithMetadata; - const metadata = stateWithData.metadata; const partMetadata = (part as unknown as { metadata?: unknown }).metadata; - const input = stateWithData.input; const time = stateWithData.time; const [pinnedTime, setPinnedTime] = React.useState<{ start?: number; end?: number }>(() => ({ @@ -2347,6 +2127,26 @@ const ToolPartContent: React.FC = ({ }, [descriptionPath, normalizedPartTool, stateWithData, input]); const runtime = React.useContext(RuntimeAPIContext); + const openApplyPatchFile = (file: Record, event: React.MouseEvent) => { + if (!runtime?.editor) { + return; + } + + event.stopPropagation(); + const displayPath = typeof file.relativePath === 'string' + ? file.relativePath + : typeof file.filePath === 'string' + ? getRelativePath(file.filePath, currentDirectory) + : ''; + openApplyPatchFileInEditor({ + currentDirectory, + diffLabel: `${displayPath} (changes)`, + editor: runtime.editor, + file, + isVSCode: runtime.runtime.isVSCode, + }); + }; + const handleMainClick = (e: { stopPropagation: () => void }) => { if (isTaskTool || !runtime?.editor) { onToggle(part.id); @@ -2356,23 +2156,21 @@ const ToolPartContent: React.FC = ({ let filePath: unknown; let targetLine: number | undefined; let toolDiff: string | undefined; - if (part.tool === 'edit' || part.tool === 'multiedit') { + if (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit') { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; - targetLine = getFirstChangedLineFromMetadata(part.tool, metadata); if (typeof filePath === 'string') { - toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath); + toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath); + targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata, filePath); } - } else if (part.tool === 'apply_patch') { - const files = Array.isArray(metadata?.files) ? metadata?.files : []; - const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined; - filePath = firstFile?.relativePath || firstFile?.filePath; - targetLine = getFirstChangedLineFromMetadata(part.tool, metadata); + } else if (normalizedPartTool === 'apply_patch') { + filePath = getPrimaryToolPath(normalizedPartTool, input, metadata); if (typeof filePath === 'string') { - toolDiff = getPrimaryDiffFromMetadata(part.tool, metadata, filePath); + toolDiff = getPrimaryDiffFromMetadata(normalizedPartTool, metadata, filePath); + targetLine = getFirstChangedLineFromMetadata(normalizedPartTool, metadata, filePath); } - } else if (['write', 'create', 'file_write'].includes(part.tool)) { + } else if (['write', 'create', 'file_write'].includes(normalizedPartTool)) { filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; - } else if (part.tool === 'lsp') { + } else if (normalizedPartTool === 'lsp') { filePath = input?.filePath || input?.file_path || input?.path; const line = input?.line; targetLine = typeof line === 'number' && Number.isFinite(line) ? Math.trunc(line) : undefined; @@ -2380,11 +2178,8 @@ const ToolPartContent: React.FC = ({ if (typeof filePath === 'string') { e.stopPropagation(); - let absolutePath = filePath; - if (!filePath.startsWith('/')) { - absolutePath = currentDirectory.endsWith('/') ? currentDirectory + filePath : currentDirectory + '/' + filePath; - } - if (runtime.runtime.isVSCode && toolDiff && (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch')) { + const absolutePath = toAbsoluteFilePath(currentDirectory, filePath); + if (runtime.runtime.isVSCode && toolDiff && (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')) { const label = `${getRelativePath(absolutePath, currentDirectory)} (changes)`; void runtime.editor.openDiff('', absolutePath, label, { line: targetLine, patch: toolDiff }); return; @@ -2417,60 +2212,85 @@ const ToolPartContent: React.FC = ({ {}
onToggle(part.id) : handleMainClick} + onKeyDown={isMultiFileApplyPatch ? (event) => { + if (event.target !== event.currentTarget) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onToggle(part.id); + } : handleMainKeyDown} role="button" tabIndex={0} >
- {} -
{ event.stopPropagation(); onToggle(part.id); }} - > - {} -
- {getToolIcon(normalizedPartTool || part.tool)} -
- {} -
- {isExpanded ? : } -
-
{isMultiFileApplyPatch ? ( <> - - {displayName} - - {getMultiFileDescription(metadata, animateTailText, showToolFileIcons)} +
+ + + {getToolIcon(normalizedPartTool || part.tool)} + + + + + {displayName} + +
+ ) : ( <> +
{ event.stopPropagation(); onToggle(part.id); }} + > +
+ {getToolIcon(normalizedPartTool || part.tool)} +
+
+ {isExpanded ? : } +
+
; + isVSCode: boolean; +}): boolean => { + const filePath = getApplyPatchFilePath(file); + if (!filePath || file.type === 'delete') { + return false; + } + + const patch = getPatchText(file.patch) ?? getPatchText(file.diff); + const line = patch ? extractFirstChangedLineFromDiff(patch) : undefined; + const absolutePath = toAbsoluteFilePath(currentDirectory, filePath); + if (isVSCode && patch) { + void editor.openDiff('', absolutePath, diffLabel, { line, patch }); + } else { + void editor.openFile(absolutePath, line); + } + return true; +}; diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts index 58929c5d..62054f08 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts @@ -1,10 +1,110 @@ import { describe, expect, test } from 'bun:test'; -import { getDiffPatchEntries, getRenderablePatchInfo } from './toolDiffUtils'; +import { + getApplyPatchFilePath, + getDiffPatchEntries, + getFirstChangedLineFromMetadata, + getMutatedToolPaths, + getPrimaryDiffFromMetadata, + getPrimaryToolPath, + getRenderablePatchInfo, +} from './toolDiffUtils'; const identity = (path: string) => path; describe('toolDiffUtils', () => { + test('prefers the absolute apply_patch path over its worktree-relative label', () => { + expect(getPrimaryToolPath('apply_patch', undefined, { + files: [{ + filePath: '/workspace/project/src/file.ts', + relativePath: 'workspace/project/src/file.ts', + type: 'update', + }], + })).toBe('/workspace/project/src/file.ts'); + }); + + test('opens the move destination and skips deleted apply_patch files', () => { + expect(getPrimaryToolPath('apply_patch', undefined, { + files: [ + { filePath: '/workspace/deleted.ts', relativePath: 'deleted.ts', type: 'delete' }, + { + filePath: '/workspace/old.ts', + relativePath: 'new.ts', + movePath: '/workspace/new.ts', + type: 'move', + }, + ], + })).toBe('/workspace/new.ts'); + }); + + test('falls back to the relative apply_patch path for legacy metadata', () => { + expect(getPrimaryToolPath('apply_patch', undefined, { + files: [{ relativePath: 'src/file.ts', type: 'update' }], + })).toBe('src/file.ts'); + }); + + test('resolves each apply_patch file independently', () => { + expect(getApplyPatchFilePath({ + filePath: '/workspace/project/src/first.ts', + relativePath: 'workspace/project/src/first.ts', + })).toBe('/workspace/project/src/first.ts'); + expect(getApplyPatchFilePath({ + filePath: '/workspace/project/src/old.ts', + movePath: '/workspace/project/src/second.ts', + relativePath: 'src/second.ts', + })).toBe('/workspace/project/src/second.ts'); + }); + + test('lists every apply_patch mutation path, including both sides of a move', () => { + expect(getMutatedToolPaths('apply_patch', undefined, { + files: [ + { filePath: '/workspace/project/src/deleted.ts', type: 'delete' }, + { + filePath: '/workspace/project/src/old.ts', + movePath: '/workspace/project/src/new.ts', + type: 'move', + }, + ], + })).toEqual([ + '/workspace/project/src/deleted.ts', + '/workspace/project/src/new.ts', + '/workspace/project/src/old.ts', + ]); + }); + + test('does not invent paths for bash or task tools', () => { + expect(getMutatedToolPaths('bash', { command: 'date' }, undefined)).toEqual([]); + expect(getMutatedToolPaths('task', { description: 'inspect' }, undefined)).toEqual([]); + }); + + test('selects the move patch and line from the same non-deleted file', () => { + const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted'; + const movedPatch = '@@ -42 +42 @@\n-before\n+after'; + const metadata = { + patch: deletedPatch, + files: [ + { + filePath: '/workspace/project/src/deleted.ts', + relativePath: 'src/deleted.ts', + patch: deletedPatch, + type: 'delete', + }, + { + filePath: '/workspace/project/src/old.ts', + movePath: '/workspace/project/src/moved.ts', + relativePath: 'src/moved.ts', + patch: movedPatch, + type: 'move', + }, + ], + }; + + expect(getPrimaryDiffFromMetadata('apply_patch', metadata, '/workspace/project/src/moved.ts')) + .toBe(movedPatch); + expect(getFirstChangedLineFromMetadata('apply_patch', metadata, '/workspace/project/src/moved.ts')) + .toBe(42); + }); + test('treats raw apply_patch envelopes as text, not visual diffs', () => { const entries = getDiffPatchEntries(undefined, [ '*** Begin Patch', @@ -57,6 +157,27 @@ describe('toolDiffUtils', () => { expect(entries[0]?.title).toBe('src/file.ts'); }); + test('keeps the authoritative path for every metadata file entry', () => { + const patch = [ + '--- a/src/file.ts', + '+++ b/src/file.ts', + '@@ -1 +1 @@', + '-old', + '+new', + ].join('\n'); + const entries = getDiffPatchEntries({ + files: [ + { filePath: '/workspace/project/src/first.ts', relativePath: 'src/first.ts', patch }, + { filePath: '/workspace/project/src/second.ts', relativePath: 'src/second.ts', patch }, + ], + }, undefined, identity); + + expect(entries.map((entry) => entry.filePath)).toEqual([ + '/workspace/project/src/first.ts', + '/workspace/project/src/second.ts', + ]); + }); + test('synthesizes headers for valid headerless hunks', () => { const entries = getDiffPatchEntries(undefined, [ '@@ -1 +1 @@', diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts index 23d9aac0..0501ba98 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts @@ -3,6 +3,7 @@ import { parsePatchFiles } from '@pierre/diffs'; export type DiffPatchEntry = { id: string; title: string; + filePath?: string; patch: string; renderMode: 'diff' | 'text'; }; @@ -140,6 +141,195 @@ export const getPatchText = (value: unknown): string | undefined => { return undefined; }; +export const getApplyPatchFilePath = (file: unknown): string | null => { + if (!isRecord(file)) { + return null; + } + + return typeof file.movePath === 'string' + ? file.movePath + : typeof file.filePath === 'string' + ? file.filePath + : typeof file.relativePath === 'string' + ? file.relativePath + : null; +}; + +export const getPrimaryToolPath = ( + toolName: string, + input: Record | undefined, + metadata: Record | undefined, +): string | null => { + if (toolName === 'apply_patch') { + const files = Array.isArray(metadata?.files) ? metadata.files : []; + for (const file of files) { + if (isRecord(file) && file.type !== 'delete') { + const filePath = getApplyPatchFilePath(file); + if (filePath) { + return filePath; + } + } + } + return null; + } + + if (toolName === 'edit' || toolName === 'multiedit') { + const fileDiff = isRecord(metadata?.filediff) ? metadata.filediff : undefined; + if (fileDiff && typeof fileDiff.file === 'string') { + return fileDiff.file; + } + return typeof input?.filePath === 'string' + ? input.filePath + : typeof input?.file_path === 'string' + ? input.file_path + : typeof input?.path === 'string' + ? input.path + : null; + } + + if (toolName === 'write') { + return typeof input?.filePath === 'string' + ? input.filePath + : typeof input?.file_path === 'string' + ? input.file_path + : typeof input?.path === 'string' + ? input.path + : null; + } + + return null; +}; + +export const getMutatedToolPaths = ( + toolName: string, + input: Record | undefined, + metadata: Record | undefined, +): string[] => { + if (toolName === 'apply_patch') { + const files = Array.isArray(metadata?.files) ? metadata.files : []; + const paths = new Set(); + for (const file of files) { + if (!isRecord(file)) continue; + const filePath = getApplyPatchFilePath(file); + if (filePath) paths.add(filePath); + if (file.type === 'move' && typeof file.filePath === 'string') { + paths.add(file.filePath); + } + } + return [...paths]; + } + + const primaryPath = getPrimaryToolPath(toolName, input, metadata); + return primaryPath ? [primaryPath] : []; +}; + +const supportsDiffMetadata = (toolName: string): boolean => ( + toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch' +); + +const getMetadataFileForPath = ( + metadata: Record, + preferredPath?: string, +): Record | undefined => { + const files = Array.isArray(metadata.files) ? metadata.files : []; + if (!preferredPath) { + const first = files[0]; + return isRecord(first) ? first : undefined; + } + + return files.find((file): file is Record => ( + isRecord(file) + && (file.relativePath === preferredPath || file.filePath === preferredPath || file.movePath === preferredPath) + )); +}; + +export const getPrimaryDiffFromMetadata = ( + toolName: string, + metadata?: Record, + preferredPath?: string, +): string | undefined => { + if (!metadata || !supportsDiffMetadata(toolName)) { + return undefined; + } + + const matchedFile = getMetadataFileForPath(metadata, preferredPath); + const filePatch = getPatchText(matchedFile?.patch) ?? getPatchText(matchedFile?.diff); + if (filePatch) { + return filePatch; + } + + return getPatchText(metadata.patch) ?? getPatchText(metadata.diff); +}; + +export const extractFirstChangedLineFromDiff = (diffText: string): number | undefined => { + if (!diffText) { + return undefined; + } + + let currentNewLine: number | undefined; + let firstHunkStart: number | undefined; + for (const rawLine of diffText.split('\n')) { + const line = rawLine.replace(/\r$/, ''); + const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); + if (hunkMatch) { + const parsed = Number.parseInt(hunkMatch[1] ?? '', 10); + if (Number.isFinite(parsed)) { + currentNewLine = Math.max(1, parsed); + firstHunkStart ??= currentNewLine; + } + continue; + } + + if (currentNewLine === undefined) { + continue; + } + if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('diff ')) { + continue; + } + if (line.startsWith('+')) { + return currentNewLine; + } + if (line.startsWith(' ')) { + currentNewLine += 1; + } + } + + return firstHunkStart; +}; + +export const getFirstChangedLineFromMetadata = ( + toolName: string, + metadata?: Record, + preferredPath?: string, +): number | undefined => { + if (!metadata || !supportsDiffMetadata(toolName)) { + return undefined; + } + + if (preferredPath) { + const matchedFile = getMetadataFileForPath(metadata, preferredPath); + const matchedPatch = getPatchText(matchedFile?.patch) ?? getPatchText(matchedFile?.diff); + if (matchedPatch) { + const matchedLine = extractFirstChangedLineFromDiff(matchedPatch); + if (matchedLine !== undefined) { + return matchedLine; + } + } + } + + const topLevelPatch = getPatchText(metadata.patch) ?? getPatchText(metadata.diff); + if (topLevelPatch) { + const topLevelLine = extractFirstChangedLineFromDiff(topLevelPatch); + if (topLevelLine !== undefined) { + return topLevelLine; + } + } + + const firstFile = getMetadataFileForPath(metadata); + const firstPatch = getPatchText(firstFile?.patch) ?? getPatchText(firstFile?.diff); + return firstPatch ? extractFirstChangedLineFromDiff(firstPatch) : undefined; +}; + const normalizeParsedPath = (path: string | undefined): string => { const trimmed = (path ?? '').trim().replace(/\t.*$/, ''); if (!trimmed || trimmed === '/dev/null') { @@ -287,7 +477,7 @@ const getPatchEntriesFromText = ( }]; }; -const getFilePatch = (file: unknown): { patch: string; title: string } | null => { +const getFilePatch = (file: unknown): { filePath?: string; patch: string; title: string } | null => { if (!isRecord(file)) { return null; } @@ -304,6 +494,7 @@ const getFilePatch = (file: unknown): { patch: string; title: string } | null => : ''; return { + filePath: getApplyPatchFilePath(file) ?? undefined, patch, title: rawPath, }; @@ -325,7 +516,7 @@ export const getDiffPatchEntries = ( filePatch.title || `File ${index + 1}`, `file-${index}`, resolveTitle, - ); + ).map((entry) => ({ ...entry, filePath: filePatch.filePath })); }); if (fileEntries.length > 0) { diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 8ecfbf11..c1b957b7 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -30,6 +30,7 @@ export const iconSpriteData = { "book": ``, "book-open": ``, "booklet": ``, + "braces": ``, "brain": ``, "brain-ai-3": ``, "briefcase": ``, @@ -192,6 +193,7 @@ export const iconSpriteData = { "robot": ``, "robot-2": ``, "rocket": ``, + "route": ``, "save-3": ``, "scales-3": ``, "scan-2": ``, diff --git a/packages/ui/src/components/icons/DiffIcon.tsx b/packages/ui/src/components/icons/DiffIcon.tsx index dd46a0b8..1c13a676 100644 --- a/packages/ui/src/components/icons/DiffIcon.tsx +++ b/packages/ui/src/components/icons/DiffIcon.tsx @@ -1,9 +1,21 @@ import type { SVGProps } from 'react'; +import { Icon } from '@/components/icon/Icon'; + interface DiffIconProps extends Omit, 'children'> { size?: number | string; } +export function DiffViewIcon({ className = 'h-5 w-5' }: { className?: string }) { + return ( +
) : null} - {activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' ? activeNonChatContent : null} + {hasWalkthroughTab ? ( +
+ +
+ ) : null} + {activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null}
diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index 12fcd12c..5d522e5c 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -17,8 +17,11 @@ import { import { CSS } from '@dnd-kit/utilities'; import { Icon } from '@/components/icon/Icon'; +import { DiffViewIcon } from '@/components/icons/DiffIcon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useDeviceInfo } from '@/lib/device'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { sortContextSurfaces, @@ -30,6 +33,9 @@ import { useGitStatus } from '@/stores/useGitStore'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; const RAIL_TOOLTIP_DELAY_MS = 150; +// Tablet width and up: below this the walkthrough cannot show a stop and its +// code side by side, which is the whole point of the surface. +const WALKTHROUGH_MIN_WIDTH = 768; const EMPTY_TABS: never[] = []; type RailItemProps = { @@ -75,7 +81,11 @@ const ContextPanelRailItem: React.FC = ({ : 'text-muted-foreground hover:text-foreground', )} > - + {surface.id === 'diff' ? ( + + ) : ( + + )} {showActivityDot ? (
diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index e91d49fd..81d998a4 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -711,7 +711,9 @@ export const DirectoryExplorerDialog: React.FC = ( open={open} onClose={handleClose} title={t('directoryExplorerDialog.title')} - className="h-[88dvh] max-h-[720px] max-w-full" + // Height only — the width stays on MobileOverlayPanel's shared max-w-lg + // so this sheet matches every other mobile overlay on wide screens. + className="h-[88dvh] max-h-[720px]" contentMaxHeightClassName="flex-1" footer={
{renderFooter()}
} > diff --git a/packages/ui/src/components/session/SessionDialogs.tsx b/packages/ui/src/components/session/SessionDialogs.tsx index ce3128e7..daed832b 100644 --- a/packages/ui/src/components/session/SessionDialogs.tsx +++ b/packages/ui/src/components/session/SessionDialogs.tsx @@ -413,13 +413,7 @@ export const SessionDialogs: React.FC = () => { const target = deleteDialog.sessions[0]; const success = isWorktreeDelete ? await archiveSession(target.id) - : await deleteSession(target.id, { - // In "worktree" mode, remove the selected worktree explicitly below. - // Don't try to derive worktree removal from per-session metadata (may be missing). - archiveWorktree: false, - deleteRemoteBranch: removeRemoteBranch, - deleteLocalBranch, - }); + : await deleteSession(target.id); if (!success) { toast.error(isWorktreeDelete ? t('sessions.sidebar.session.archive.error') diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 904feb29..a518777f 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -1204,7 +1204,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { handleSessionDoubleClick(session.id, sessionTitle); }} className={cn( - 'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]', + 'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]', isTouchPressed && 'bg-interactive-hover/70', alwaysShowActions ? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass) diff --git a/packages/ui/src/components/ui/MobileOverlayPanel.tsx b/packages/ui/src/components/ui/MobileOverlayPanel.tsx index 8151f2d9..c1d56392 100644 --- a/packages/ui/src/components/ui/MobileOverlayPanel.tsx +++ b/packages/ui/src/components/ui/MobileOverlayPanel.tsx @@ -119,6 +119,15 @@ export const MobileOverlayPanel: React.FC = ({ role="dialog" aria-modal="true" onClick={onClose} + // The panel centers over the CHAT column, not the whole app: on a tablet + // the shell keeps a persistent sessions sidebar, and a sheet centered on + // the window reads as belonging to nothing. The shell publishes the + // column's insets; on phones they are 0 and this is a no-op. The scrim + // deliberately still covers everything. + style={{ + paddingLeft: 'var(--oc-chat-inset-left, 0px)', + paddingRight: 'var(--oc-chat-inset-right, 0px)', + }} >
= ({ const { t } = useI18n(); const { git, files } = useRuntimeAPIs(); const effectiveDirectory = useEffectiveDirectory(); + const openContextSurface = useUIStore((state) => state.openContextSurface); + const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource); const { screenWidth, isMobile } = useDeviceInfo(); const isGitRepo = useIsGitRepo(effectiveDirectory ?? null); @@ -969,6 +974,7 @@ export const DiffView: React.FC = ({ const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); const ensureStatus = useGitStore((state) => state.ensureStatus); const fetchStatus = useGitStore((state) => state.fetchStatus); + const clearDiffCache = useGitStore((state) => state.clearDiffCache); const setDiff = useGitStore((state) => state.setDiff); const [displayFile, setDisplayFile] = React.useState(null); const [displayFileStaged, setDisplayFileStaged] = React.useState(false); @@ -977,6 +983,7 @@ export const DiffView: React.FC = ({ const [mountedStackedFiles, setMountedStackedFiles] = React.useState>(() => new Set()); const [loadFullFiles, setLoadFullFiles] = React.useState(false); const [scrollRequestNonce, setScrollRequestNonce] = React.useState(0); + const [fileDiffRefreshNonce, setFileDiffRefreshNonce] = React.useState>(() => new Map()); const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false); const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false); const [activeDiffScope, setActiveDiffScope] = React.useState(diffScope); @@ -1003,6 +1010,9 @@ export const DiffView: React.FC = ({ const isMobileLayout = isMobile || screenWidth <= 768; const showReviewAction = Boolean(currentSessionId) && activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime(); + // Same runtime and width rules as the rail surface: no point offering an + // entry point to a surface that cannot open here. + const showWalkthroughAction = activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime(); const showFileSidebar = !hideStackedFileSidebar && !isMobileLayout && screenWidth >= 1024; const diffScrollRef = React.useRef(null); const fileSectionRefs = React.useRef(new Map()); @@ -1011,6 +1021,20 @@ export const DiffView: React.FC = ({ const shouldPinAfterAlignRef = React.useRef(false); const visibleSyncFrameRef = React.useRef(null); const stackedStateScopeRef = React.useRef(null); + const lastScrollAnchorRef = React.useRef(null); + const pendingScrollAnchorRestoreRef = React.useRef(null); + + const captureScrollAnchor = React.useCallback((): DiffScrollAnchor | null => { + const scrollRoot = diffScrollRef.current; + if (!scrollRoot) return null; + + const rootTop = scrollRoot.getBoundingClientRect().top; + const sections: Array<{ path: string; top: number }> = []; + for (const [path, node] of fileSectionRefs.current) { + if (node) sections.push({ path, top: node.getBoundingClientRect().top }); + } + return findDiffScrollAnchor(rootTop, sections); + }, []); const cancelPendingScrollAlignment = React.useCallback(() => { pendingScrollTargetRef.current = null; @@ -1164,13 +1188,17 @@ export const DiffView: React.FC = ({ const top = rootRect.top - STACKED_DIFF_MOUNT_MARGIN; const bottom = rootRect.bottom + STACKED_DIFF_MOUNT_MARGIN; const next: Record = {}; + const sectionPositions: Array<{ path: string; top: number }> = []; for (const [path, node] of fileSectionRefs.current) { - if (!node || !expandedFiles.has(path)) continue; + if (!node) continue; const rect = node.getBoundingClientRect(); + sectionPositions.push({ path, top: rect.top }); + if (!expandedFiles.has(path)) continue; if (rect.bottom < top || rect.top > bottom) continue; next[path] = true; } + lastScrollAnchorRef.current = findDiffScrollAnchor(rootRect.top, sectionPositions); setMountedStackedFiles((previous) => { let changed = false; @@ -1252,9 +1280,40 @@ export const DiffView: React.FC = ({ if (normalizePath(hint.directory) !== normalizePath(effectiveDirectory)) { return; } - void fetchStatus(effectiveDirectory, git); + if (hint.paths?.length) { + pendingScrollAnchorRestoreRef.current = captureScrollAnchor() ?? lastScrollAnchorRef.current; + clearDiffCache(effectiveDirectory, hint.paths); + setFileDiffRefreshNonce((previous) => { + const next = new Map(previous); + for (const path of hint.paths ?? []) { + next.set(path, (next.get(path) ?? 0) + 1); + } + return next; + }); + } + void fetchStatus(effectiveDirectory, git, { silent: true }); }); - }, [effectiveDirectory, fetchStatus, git]); + }, [captureScrollAnchor, clearDiffCache, effectiveDirectory, fetchStatus, git]); + + React.useLayoutEffect(() => { + const anchor = pendingScrollAnchorRestoreRef.current; + if (!anchor) return; + pendingScrollAnchorRestoreRef.current = null; + + const scrollRoot = diffScrollRef.current; + const node = fileSectionRefs.current.get(anchor.path); + if (!scrollRoot || !node) return; + + const rootTop = scrollRoot.getBoundingClientRect().top; + const currentTopOffset = node.getBoundingClientRect().top - rootTop; + scrollRoot.scrollTop = getRestoredDiffScrollTop( + scrollRoot.scrollTop, + anchor.topOffset, + currentTopOffset, + scrollRoot.scrollHeight - scrollRoot.clientHeight, + ); + lastScrollAnchorRef.current = anchor; + }, [fileDiffRefreshNonce]); // Handle pending diff file from external navigation React.useEffect(() => { @@ -1392,11 +1451,8 @@ export const DiffView: React.FC = ({ return false; } - const rootRect = scrollRoot.getBoundingClientRect(); - const nodeRect = node.getBoundingClientRect(); - const delta = nodeRect.top - rootRect.top; - const maxTop = Math.max(0, scrollRoot.scrollHeight - scrollRoot.clientHeight); - scrollRoot.scrollTop = Math.min(maxTop, Math.max(0, scrollRoot.scrollTop + delta)); + const scrollOffset = node.getBoundingClientRect().top - scrollRoot.getBoundingClientRect().top; + scrollRoot.scrollTo({ top: scrollRoot.scrollTop + scrollOffset, behavior: 'auto' }); return true; }, []); @@ -1408,14 +1464,16 @@ export const DiffView: React.FC = ({ const maxAttempts = 20; let cancelled = false; - const cancelPending = () => { + const cancelPending = (clearPinnedTarget = true) => { if (cancelled) { return; } cancelled = true; pendingScrollTargetRef.current = null; shouldPinAfterAlignRef.current = false; - setPinnedStackedTarget(null); + if (clearPinnedTarget) { + setPinnedStackedTarget(null); + } if (pendingScrollFrameRef.current !== null) { window.cancelAnimationFrame(pendingScrollFrameRef.current); pendingScrollFrameRef.current = null; @@ -1448,6 +1506,8 @@ export const DiffView: React.FC = ({ if (pinSelectedFileHeaderToTopOnNavigate && shouldPinAfterAlignRef.current) { setPinnedStackedTarget(currentTarget); + cancelPending(false); + return; } cancelPending(); }; @@ -1592,7 +1652,7 @@ export const DiffView: React.FC = ({
{changedFiles.map((file) => ( = ({ )} + {changedFiles.length > 0 && showWalkthroughAction && ( + + )} {changedFiles.length > 0 && ( diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index de607acf..f239d931 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -74,6 +74,7 @@ import { openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useI18n } from '@/lib/i18n'; +import { sessionEvents } from '@/lib/sessionEvents'; type FileNode = { name: string; @@ -1655,6 +1656,12 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return false; } setFileContent(draftContent); + if (root && isPathWithinRoot(selectedFile.path, root)) { + const relativePath = getDisplayPath(root, selectedFile.path); + if (relativePath) { + sessionEvents.requestGitRefresh({ directory: root, paths: [relativePath] }); + } + } if (selectedFile?.path && isDrawioFile(selectedFile.path)) { diagramXmlRef.current = draftContent; diagramSavedXmlRef.current = draftContent; @@ -1674,7 +1681,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } finally { setIsSaving(false); } - }, [contentDetectedBinary, draftContent, fileContent, fileLoading, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, selectedFile, t]); + }, [contentDetectedBinary, draftContent, fileContent, fileLoading, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, root, selectedFile, t]); React.useEffect(() => { if (!isDirty) { diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index a6155f36..06485c92 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -280,6 +280,7 @@ export const GitView: React.FC = ({ isActive }) => { setLogMaxCount, fetchIdentity, prefetchDiffs, + clearDiffCache, moveStatusPathsOptimistically, restoreStatus, bumpIndexRevision, @@ -293,6 +294,7 @@ export const GitView: React.FC = ({ isActive }) => { setLogMaxCount: state.setLogMaxCount, fetchIdentity: state.fetchIdentity, prefetchDiffs: state.prefetchDiffs, + clearDiffCache: state.clearDiffCache, moveStatusPathsOptimistically: state.moveStatusPathsOptimistically, restoreStatus: state.restoreStatus, bumpIndexRevision: state.bumpIndexRevision, @@ -861,9 +863,12 @@ export const GitView: React.FC = ({ isActive }) => { if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) { return; } - void fetchStatus(currentDirectory, git); + if (hint.paths?.length) { + clearDiffCache(currentDirectory, hint.paths); + } + void fetchStatus(currentDirectory, git, { silent: true }); }); - }, [isActive, currentDirectory, fetchStatus, git]); + }, [isActive, clearDiffCache, currentDirectory, fetchStatus, git]); const refreshStatusAndBranches = React.useCallback( async (showErrors = true) => { @@ -2019,14 +2024,14 @@ export const GitView: React.FC = ({ isActive }) => { } }, [currentDirectory, git, conflictOperation, refreshStatusAndBranches, refreshLog, clearConflictState, t]); - // Check if there are unresolved conflicts (files with 'U' status) - const hasUnresolvedConflicts = React.useMemo(() => { - if (!status?.files) return false; - return status.files.some((f) => + // Count unresolved conflicts (files with 'U' status) + const conflictCount = React.useMemo(() => { + if (!status?.files) return 0; + return status.files.filter((f) => (f.index === 'U' || f.working_dir === 'U') || (f.index === 'A' && f.working_dir === 'A') || (f.index === 'D' && f.working_dir === 'D') - ); + ).length; }, [status?.files]); const handleContinueOperation = React.useCallback(async () => { @@ -2350,7 +2355,7 @@ export const GitView: React.FC = ({ isActive }) => { onContinue={handleContinueOperation} onAbort={handleAbortOperation} onResolveWithAI={handleResolveWithAIFromBanner} - hasUnresolvedConflicts={hasUnresolvedConflicts} + conflictCount={conflictCount} isLoading={isLoading} /> )} diff --git a/packages/ui/src/components/views/diffScrollAnchor.test.ts b/packages/ui/src/components/views/diffScrollAnchor.test.ts new file mode 100644 index 00000000..c3a8b0c5 --- /dev/null +++ b/packages/ui/src/components/views/diffScrollAnchor.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test'; + +import { findDiffScrollAnchor, getRestoredDiffScrollTop } from './diffScrollAnchor'; + +describe('diff scroll anchoring', () => { + test('uses the last file section that reached the top of the viewport', () => { + expect(findDiffScrollAnchor(100, [ + { path: 'third.ts', top: 240 }, + { path: 'second.ts', top: 80 }, + { path: 'first.ts', top: -200 }, + ])).toEqual({ path: 'second.ts', topOffset: -20 }); + }); + + test('uses the first section when no header reached the viewport top yet', () => { + expect(findDiffScrollAnchor(100, [ + { path: 'first.ts', top: 140 }, + { path: 'second.ts', top: 300 }, + ])).toEqual({ path: 'first.ts', topOffset: 40 }); + }); + + test('restores the prior section offset and clamps at the scroll boundary', () => { + expect(getRestoredDiffScrollTop(500, -20, 80, 1000)).toBe(600); + expect(getRestoredDiffScrollTop(950, -20, 80, 1000)).toBe(1000); + }); +}); diff --git a/packages/ui/src/components/views/diffScrollAnchor.ts b/packages/ui/src/components/views/diffScrollAnchor.ts new file mode 100644 index 00000000..f7d50ee3 --- /dev/null +++ b/packages/ui/src/components/views/diffScrollAnchor.ts @@ -0,0 +1,35 @@ +export type DiffScrollAnchor = { + path: string; + topOffset: number; +}; + +export const findDiffScrollAnchor = ( + rootTop: number, + sections: Array<{ path: string; top: number }>, +): DiffScrollAnchor | null => { + if (sections.length === 0) return null; + + let beforeTop: { path: string; top: number } | null = null; + let afterTop: { path: string; top: number } | null = null; + for (const section of sections) { + if (section.top <= rootTop) { + if (!beforeTop || section.top > beforeTop.top) beforeTop = section; + } else if (!afterTop || section.top < afterTop.top) { + afterTop = section; + } + } + + const anchor = beforeTop ?? afterTop; + if (!anchor) return null; + return { path: anchor.path, topOffset: anchor.top - rootTop }; +}; + +export const getRestoredDiffScrollTop = ( + scrollTop: number, + previousTopOffset: number, + currentTopOffset: number, + maxScrollTop: number, +): number => Math.min( + Math.max(0, maxScrollTop), + Math.max(0, scrollTop + currentTopOffset - previousTopOffset), +); diff --git a/packages/ui/src/components/views/git/InProgressOperationBanner.tsx b/packages/ui/src/components/views/git/InProgressOperationBanner.tsx index 64d461ab..d364e919 100644 --- a/packages/ui/src/components/views/git/InProgressOperationBanner.tsx +++ b/packages/ui/src/components/views/git/InProgressOperationBanner.tsx @@ -10,7 +10,7 @@ interface InProgressOperationBannerProps { onContinue: () => Promise; onAbort: () => Promise; onResolveWithAI?: () => void; - hasUnresolvedConflicts?: boolean; + conflictCount?: number; isLoading?: boolean; } @@ -20,7 +20,7 @@ export const InProgressOperationBanner: React.FC onContinue, onAbort, onResolveWithAI, - hasUnresolvedConflicts = false, + conflictCount = 0, isLoading = false, }) => { const { t } = useI18n(); @@ -30,7 +30,7 @@ export const InProgressOperationBanner: React.FC const hasMergeInProgress = mergeInProgress && mergeInProgress.head; const hasRebaseInProgress = rebaseInProgress && (rebaseInProgress.headName || rebaseInProgress.onto); const operation = hasMergeInProgress ? 'merge' : hasRebaseInProgress ? 'rebase' : null; - + if (!operation) { return null; } @@ -54,54 +54,52 @@ export const InProgressOperationBanner: React.FC }; const isProcessing = processingAction !== null; + const hasUnresolvedConflicts = conflictCount > 0; const operationLabel = operation === 'merge' ? t('gitView.operation.merge') : t('gitView.operation.rebase'); - const operationIconName = operation === 'merge' ? 'git-merge' : 'git-branch'; // Build description let description = ''; if (mergeInProgress) { - description = mergeInProgress.message + description = mergeInProgress.message ? t('gitView.operation.mergingMessage', { message: mergeInProgress.message }) : t('gitView.operation.mergeInProgressWithHead', { head: mergeInProgress.head }); } else if (rebaseInProgress) { - description = rebaseInProgress.headName + description = rebaseInProgress.headName ? t('gitView.operation.rebasingOnto', { headName: rebaseInProgress.headName, onto: rebaseInProgress.onto || '' }) : t('gitView.operation.rebaseInProgress'); } + const title = !hasUnresolvedConflicts + ? t('gitView.operation.inProgressTitle', { operation: operationLabel }) + : conflictCount === 1 + ? t('gitView.operation.inProgressTitleOneConflict', { operation: operationLabel, count: conflictCount }) + : t('gitView.operation.inProgressTitleManyConflicts', { operation: operationLabel, count: conflictCount }); + + const hint = hasUnresolvedConflicts + ? t('gitView.operation.resolveConflictsHint') + : t('gitView.operation.readyToContinueHint'); + return ( -
-
-
- -
-

- {t('gitView.operation.inProgressTitle', { operation: operationLabel })} +

+
+
+

+ {title} +

+ {description && ( +

+ {description}

- {description && ( -

- {description} -

- )} -
+ )}
-
- {hasUnresolvedConflicts && onResolveWithAI && ( - - )} +
+

+ {hint} +

- {processingAction !== 'continue' && ( +
- )} - {!hasUnresolvedConflicts && ( - + ) + : ( + )} - {t('gitView.operation.continue')} - - )} +
- - {hasUnresolvedConflicts && ( -

- {t('gitView.operation.resolveConflictsHint')} -

- )}
); diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 25703471..16fbdcd6 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -21,6 +21,9 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { Icon } from "@/components/icon/Icon"; import { useUIStore } from '@/stores/useUIStore'; +import { useWalkthroughStore } from '@/stores/useWalkthroughStore'; +import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction'; +import { isVSCodeRuntime } from '@/lib/desktop'; import { formatDateTimeForPreference } from '@/lib/timeFormat'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useInlineCommentDraftStore, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; @@ -327,7 +330,12 @@ export const PullRequestSection: React.FC<{ const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); - const { isMobile, hasTouchInput } = useDeviceInfo(); + const { isMobile, hasTouchInput, screenWidth } = useDeviceInfo(); + const openContextSurface = useUIStore((state) => state.openContextSurface); + const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource); + // Mirrors the rail's gating: the surface is not available on mobile widths or + // in VS Code, so neither is its entry point. + const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime(); const openGitHubSettings = React.useCallback(() => { setSettingsPage('github'); @@ -1487,7 +1495,7 @@ export const PullRequestSection: React.FC<{
{pr ? ( -
+
{prStatusText} {checks ? ( @@ -1503,6 +1511,23 @@ export const PullRequestSection: React.FC<{ ) : null}
+ {showWalkthroughAction ? ( + + ) : null} {canMerge && pr.draft && pr.state === 'open' ? ( diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx new file mode 100644 index 00000000..2f0af021 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { ModelSelector } from '@/components/sections/agents/ModelSelector'; +import { Button } from '@/components/ui/button'; +import { useI18n } from '@/lib/i18n'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { updateDesktopSettings } from '@/lib/persistence'; +import type { WalkthroughBlockedReason, WalkthroughModel } from '@/lib/walkthrough/types'; + +interface WalkthroughBlockerProps { + reason: WalkthroughBlockedReason; + model?: WalkthroughModel; + requiredChars?: number; + availableChars?: number; + onRetry: () => void; +} + +const modelLabel = (model?: WalkthroughModel) => + model ? `${model.providerID}/${model.modelID}` : ''; + +/** + * A refusal the user can act on. Both blocking reasons come down to "this small + * model cannot do this job", so the remedy — pick a different one — is offered + * in place rather than sending the user to Settings to guess. + */ +export const WalkthroughBlocker = ({ + reason, + model, + requiredChars, + availableChars, + onRetry, +}: WalkthroughBlockerProps) => { + const { t } = useI18n(); + const modelsMetadata = useConfigStore((state) => state.modelsMetadata); + const [providers, setProviders] = useState(undefined); + const [saving, setSaving] = useState(false); + + // Every one of these means "this small model cannot do this job", so the + // remedy is the same: choose a different one, here, without a detour through + // Settings. + const canChooseModel = reason === 'context-too-small' + || reason === 'structured-output-unsupported' + || reason === 'output-exhausted'; + + useEffect(() => { + if (!canChooseModel || providers !== undefined) return; + let cancelled = false; + (async () => { + try { + const response = await runtimeFetch('/api/small-model', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return; + const payload = (await response.json().catch(() => null)) as + | { authenticatedProviders?: unknown } + | null; + if (!cancelled && Array.isArray(payload?.authenticatedProviders)) { + setProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string')); + } + } catch { + // Leave undefined: the picker then offers every provider, which is a + // worse experience but not a broken one. + } + })(); + return () => { + cancelled = true; + }; + }, [canChooseModel, providers]); + + const handleModelChange = useCallback( + async (providerId: string, modelId: string) => { + if (!providerId || !modelId || saving) return; + const value = `${providerId}/${modelId}`; + setSaving(true); + try { + // Scoped to this feature: fixing the walkthrough must not quietly + // change the model used for commit messages and recaps. + await updateDesktopSettings({ walkthroughModelOverride: value }); + onRetry(); + } catch (error) { + console.warn('Failed to save small model override:', error); + } finally { + setSaving(false); + } + }, + [onRetry, saving] + ); + + // Offering a model the catalog already says cannot do this would just move + // the same refusal one click later. + const isStructuredOutputCapable = useCallback( + (providerId: string, modelId: string) => + modelsMetadata.get(`${providerId}/${modelId}`)?.structured_output !== false, + [modelsMetadata] + ); + + const label = modelLabel(model); + + const description = () => { + if (reason === 'no-model') return t('walkthrough.blocked.noModel.description'); + if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description'); + if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description'); + if (reason === 'output-exhausted') { + return label + ? t('walkthrough.blocked.outputExhausted.description', { model: label }) + : t('walkthrough.blocked.outputExhausted.descriptionUnknownModel'); + } + if (reason === 'structured-output-unsupported') { + // Naming the model that was actually tried is the whole point of this + // screen; the unnamed variant is a defensive fallback, not the norm. + return label + ? t('walkthrough.blocked.structuredOutput.description', { model: label }) + : t('walkthrough.blocked.structuredOutput.descriptionUnknownModel'); + } + const required = Math.ceil((requiredChars ?? 0) / 1000); + const available = Math.ceil((availableChars ?? 0) / 1000); + return label + ? t('walkthrough.blocked.contextTooSmall.description', { model: label, required, available }) + : t('walkthrough.blocked.contextTooSmall.descriptionUnknownModel', { required, available }); + }; + + const title = () => { + if (reason === 'no-model') return t('walkthrough.blocked.noModel.title'); + if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title'); + if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title'); + if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title'); + if (reason === 'structured-output-unsupported') return t('walkthrough.blocked.structuredOutput.title'); + return t('walkthrough.blocked.contextTooSmall.title'); + }; + + return ( +
+ +

{title()}

+

{description()}

+ + {canChooseModel && ( +
+ + {t('walkthrough.blocked.chooseModel')} + + { + void handleModelChange(providerId, modelId); + }} + allowedProviderIds={providers} + isModelAllowed={isStructuredOutputCapable} + /> +
+ )} + + {(reason === 'empty-diff' || reason === 'only-generated') && ( + + )} +
+ ); +}; diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughHunkRun.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughHunkRun.tsx new file mode 100644 index 00000000..f26d9605 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughHunkRun.tsx @@ -0,0 +1,50 @@ +import { memo, useMemo } from 'react'; +import { PierreDiffViewer } from '@/components/views/PierreDiffViewer'; +import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff'; +import { getLanguageFromExtension } from '@/lib/toolHelpers'; +import { mergeRunPatch } from '@/lib/walkthrough/model'; +import type { WalkthroughHunk } from '@/lib/walkthrough/types'; + +interface WalkthroughHunkRunProps { + path: string; + hunks: WalkthroughHunk[]; + renderSideBySide: boolean; + wrapLines: boolean; +} + +/** + * One file's contribution to a stop. Consecutive hunks are merged back into a + * single patch so the reader sees continuous code rather than a stack of + * one-hunk cards. + * + * Inline comments are on: a review you cannot annotate is a reader, not a tool. + * They work here because the merged patch keeps the original `@@` headers, so + * the line numbers a comment captures are the file's real ones and not offsets + * into an excerpt. + */ +export const WalkthroughHunkRun = memo(function WalkthroughHunkRun({ + path, + hunks, + renderSideBySide, + wrapLines, +}: WalkthroughHunkRunProps) { + const fileDiff = useMemo(() => { + const patch = mergeRunPatch(hunks); + return patch ? fileDiffFromPatch(path, patch) : undefined; + }, [hunks, path]); + + if (!fileDiff) return null; + + return ( + + ); +}); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughStages.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughStages.tsx new file mode 100644 index 00000000..c601d278 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughStages.tsx @@ -0,0 +1,61 @@ +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import type { I18nKey } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; +import type { WalkthroughStageProgress } from './useWalkthroughStageProgress'; + +interface WalkthroughStagesProps { + progress: WalkthroughStageProgress; +} + +/** + * The wait is long and uneven — collecting a pull request diff is seconds of + * network, the model call is minutes — and a lone spinner makes those look + * identical. Naming the phase says which one you are waiting on, and that the + * cost has been committed once it reads "waiting on the model". + * + * No durations: a stopwatch on a step nobody can hurry adds pressure, not + * information. And no mention of the schema fallback — from out here it is the + * same wait, and naming our plumbing only invites the question of what it is. + */ +const STAGES: Array<{ labelKey: I18nKey }> = [ + { labelKey: 'walkthrough.stage.collecting' }, + { labelKey: 'walkthrough.stage.asking' }, + { labelKey: 'walkthrough.stage.assembling' }, +]; + +export const WalkthroughStages = ({ progress }: WalkthroughStagesProps) => { + const { t } = useI18n(); + const { completedCount, activeIndex } = progress; + + return ( +
    + {STAGES.map((entry, index) => { + const isDone = index < completedCount; + const isActive = activeIndex === index; + + return ( +
  • + + {isDone ? ( + + ) : isActive ? ( + + ) : ( + + )} + + + {t(entry.labelKey)} + +
  • + ); + })} +
+ ); +}; diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughStream.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughStream.tsx new file mode 100644 index 00000000..8b4e9f1c --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughStream.tsx @@ -0,0 +1,269 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { Button } from '@/components/ui/button'; +import { useI18n } from '@/lib/i18n'; +import { groupHunksByFile } from '@/lib/walkthrough/model'; +import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model'; +import type { WalkthroughHunk, WalkthroughStopImportance } from '@/lib/walkthrough/types'; +import { cn } from '@/lib/utils'; +import { WalkthroughHunkRun } from './WalkthroughHunkRun'; +import { stopElementId } from './stopElementId'; + +interface WalkthroughStreamProps { + view: WalkthroughView; + activeStopId: string | null; + scrollToStopId: string | null; + onActiveStopChange: (stopId: string) => void; + onScrollHandled: () => void; + renderSideBySide: boolean; + wrapLines: boolean; +} + +const IMPORTANCE_CLASS: Record = { + critical: 'bg-status-error/10 text-status-error', + normal: 'bg-surface-muted text-muted-foreground', + context: 'bg-surface-muted text-muted-foreground', +}; + +const StopHeader = ({ stopView }: { stopView: WalkthroughStopView }) => { + const { t } = useI18n(); + const { stop } = stopView; + + return ( +
+
+ + {stopView.position} + +

{stop.title}

+ {/* Same height as the step badge, so a row with an importance pill is + exactly as tall as one without: vertical padding on a smaller type + size was pushing past the tallest element in the row. */} + {stop.importance !== 'normal' && ( + + {stop.importance === 'critical' + ? t('walkthrough.importance.critical') + : t('walkthrough.importance.context')} + + )} +
+

{stop.prose}

+ {stopView.isStale && ( +

+ + {stopView.hunks.length === 0 + ? t('walkthrough.stop.staleAll') + : t('walkthrough.stop.stalePartial', { count: stopView.missingHunkIds.length })} +

+ )} +
+ ); +}; + +/** + * Sticky so the file you are reading stays named while you scroll through its + * hunks — the path is the main orientation cue in a long stream, and as a plain + * caption it was easy to scroll straight past. + */ +const FileHeader = ({ path }: { path: string }) => ( +
+ + {path} +
+); + +const HunkRuns = ({ + hunks, + renderSideBySide, + wrapLines, +}: { + hunks: WalkthroughHunk[]; + renderSideBySide: boolean; + wrapLines: boolean; +}) => { + const runs = useMemo(() => groupHunksByFile(hunks), [hunks]); + + return ( + <> + {runs.map((run, index) => ( +
+ + +
+ ))} + + ); +}; + +/** + * Everything the walkthrough covers, plus everything it does not, in one + * continuous scroll: a stop's explanation sits directly above the code it + * explains. + */ +export const WalkthroughStream = memo(function WalkthroughStream({ + view, + activeStopId, + scrollToStopId, + onActiveStopChange, + onScrollHandled, + renderSideBySide, + wrapLines, +}: WalkthroughStreamProps) { + const { t } = useI18n(); + const scrollRef = useRef(null); + const [uncoveredOpen, setUncoveredOpen] = useState(false); + + // Set while a click-driven jump is in flight. Without it the observer reports + // every stop the viewport passes over on the way to the target and the + // highlight ends up on whichever one happened to be reported last — the + // sidebar showing step 5 while the stream shows step 6. + const navigatingRef = useRef(null); + const navigationTimerRef = useRef(null); + + useEffect(() => () => { + if (navigationTimerRef.current !== null) window.clearTimeout(navigationTimerRef.current); + }, []); + + // Scrolling is driven by the DOM rather than a virtualizer: only the visible + // stops mount their diff viewers, and each stop is its own element, so there + // is nothing to translate between index space and pixel space. + useEffect(() => { + if (!scrollToStopId) return; + const element = document.getElementById(stopElementId(scrollToStopId)); + if (element) { + navigatingRef.current = scrollToStopId; + // Instant, not smooth: picking a step is a jump to a known destination, + // and a long animation only creates a window for the highlight to drift + // through everything in between. + element.scrollIntoView({ behavior: 'auto', block: 'start' }); + + // The observer fires asynchronously after the jump, and an element that + // was already in view may not fire at all — so the mute is released on a + // timer as well as on arrival. + if (navigationTimerRef.current !== null) window.clearTimeout(navigationTimerRef.current); + navigationTimerRef.current = window.setTimeout(() => { + navigatingRef.current = null; + navigationTimerRef.current = null; + }, 250); + } + onScrollHandled(); + }, [scrollToStopId, onScrollHandled]); + + const handleIntersection = useCallback( + (entries: IntersectionObserverEntry[]) => { + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; + if (!visible) return; + const stopId = visible.target.getAttribute('data-stop-id'); + if (!stopId) return; + + const navigatingTo = navigatingRef.current; + if (navigatingTo) { + // Arrived: hand control back to free scrolling. + if (stopId === navigatingTo) navigatingRef.current = null; + return; + } + + onActiveStopChange(stopId); + }, + [onActiveStopChange] + ); + + useEffect(() => { + const root = scrollRef.current; + if (!root) return; + + const observer = new IntersectionObserver(handleIntersection, { + root, + // Only count a stop as active once its header reaches the upper band of + // the viewport, so scrolling through a long diff does not flicker the + // active step back and forth. + rootMargin: '0px 0px -70% 0px', + threshold: 0, + }); + + for (const stopView of view.stops) { + const element = document.getElementById(stopElementId(stopView.stop.id)); + if (element) observer.observe(element); + } + + return () => observer.disconnect(); + }, [handleIntersection, view.stops]); + + const uncoveredRuns = useMemo(() => groupHunksByFile(view.uncoveredHunks), [view.uncoveredHunks]); + + return ( +
+ {view.stops.map((stopView) => ( +
+ + {stopView.hunks.length > 0 ? ( + + ) : ( +

+ {t('walkthrough.stop.noCode')} +

+ )} +
+ ))} + + {view.uncoveredHunks.length > 0 && ( +
+ + {!uncoveredOpen && ( +

+ {t('walkthrough.uncovered.description')} +

+ )} + {uncoveredOpen + && uncoveredRuns.map((run, index) => ( +
+ + +
+ ))} +
+ )} +
+ ); +}); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughToc.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughToc.tsx new file mode 100644 index 00000000..6d06b21d --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughToc.tsx @@ -0,0 +1,150 @@ +import { memo, useEffect, useRef } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import type { IconName } from '@/components/icon/icons'; +import { useI18n } from '@/lib/i18n'; +import { summarizeHunkFiles } from '@/lib/walkthrough/model'; +import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model'; +import type { WalkthroughChapterIcon } from '@/lib/walkthrough/types'; +import { cn } from '@/lib/utils'; + +interface WalkthroughTocProps { + view: WalkthroughView; + activeStopId: string | null; + visitedStopIds: ReadonlySet; + onSelectStop: (stopId: string) => void; + width: number; +} + +const CHAPTER_ICONS: Record = { + bug: 'bug', + wrench: 'tools', + path: 'compass-3', + flask: 'flask', + doc: 'file-text', + gear: 'settings-3', +}; + +const TocStop = ({ + stopView, + isActive, + isVisited, + onSelect, + activeRef, +}: { + stopView: WalkthroughStopView; + isActive: boolean; + isVisited: boolean; + onSelect: () => void; + activeRef: React.Ref; +}) => { + const { t } = useI18n(); + const files = summarizeHunkFiles(stopView.hunks); + + return ( +
  • + +
  • + ); +}; + +export const WalkthroughToc = memo(function WalkthroughToc({ + view, + activeStopId, + visitedStopIds, + onSelectStop, + width, +}: WalkthroughTocProps) { + const { t } = useI18n(); + const activeRef = useRef(null); + + // Scrolling the stream moves the active step, and past a certain point the + // highlighted row leaves the contents column entirely — the reader loses + // their place in the very thing meant to hold it. `nearest` keeps the move + // minimal, so clicking a row that is already visible does not jolt the list. + useEffect(() => { + activeRef.current?.scrollIntoView({ block: 'nearest' }); + }, [activeStopId]); + + return ( + + ); +}); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx new file mode 100644 index 00000000..c0403b75 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -0,0 +1,761 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Icon } from '@/components/icon/Icon'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useI18n, type Locale } from '@/lib/i18n'; +import { buildWalkthroughView } from '@/lib/walkthrough/model'; +import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types'; +import { ModelSelector } from '@/components/sections/agents/ModelSelector'; +import { deriveBaseBranch } from '@/components/views/git/baseBranch'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useGitBranches, useGitStatus } from '@/stores/useGitStore'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { + getFreshestPrStatusForBranch, + getGitHubPrStatusKey, + useGitHubPrStatusStore, +} from '@/stores/useGitHubPrStatusStore'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useUIStore } from '@/stores/useUIStore'; +import { useWalkthroughStore } from '@/stores/useWalkthroughStore'; +import { cn } from '@/lib/utils'; +import { WalkthroughBlocker } from './WalkthroughBlocker'; +import { WALKTHROUGH_ACTION_CLASS } from './walkthroughAction'; +import { WalkthroughStages } from './WalkthroughStages'; +import { useWalkthroughStageProgress } from './useWalkthroughStageProgress'; +import { WalkthroughStream } from './WalkthroughStream'; +import { WalkthroughToc } from './WalkthroughToc'; + +interface WalkthroughViewProps { + directory: string; +} + +const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working']; + +// DropdownMenuLabel defaults to the same size and weight as its items, which +// makes a heading read as another choice. This matches SelectLabel, the +// treatment used by the worktree picker. +const SCOPE_GROUP_LABEL_CLASS = 'typography-meta font-normal text-muted-foreground'; + +// Below this the table of contents would squeeze the diff into uselessness, so +// the stream takes the whole panel and the header arrows carry navigation. +const TOC_MIN_PANEL_WIDTH = 720; +const TOC_MIN_WIDTH = 180; +// The diff is the point of the surface; the contents column may never take more +// than half the panel no matter how far the user drags. +const TOC_MAX_FRACTION = 0.5; + +// Below this the header controls wrap onto a second row and the labels squeeze +// to two letters and an ellipsis, which reads as broken rather than dense. The +// controls drop their text instead: every one of them carries an icon that +// already identifies it. +// +// Every control in this row is 32px tall — `Button` size `sm` and the dropdown +// trigger's `default` size are both h-8, so this is the design system's form +// scale rather than a number picked here. Three heights in one row (28px +// pickers, 32px action, 36px arrows) read as misalignment, not hierarchy. +const HEADER_COMPACT_WIDTH = 680; + +export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { + const { t, locale, locales, label } = useI18n(); + const rootRef = useRef(null); + const [panelWidth, setPanelWidth] = useState(0); + + // Panel width, not viewport width: this surface is resizable independently of + // the window. + useEffect(() => { + const element = rootRef.current; + if (!element || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver((entries) => { + setPanelWidth(entries[0]?.contentRect.width ?? 0); + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + const storedTocWidth = useUIStore((state) => state.walkthroughTocWidth); + const setStoredTocWidth = useUIStore((state) => state.setWalkthroughTocWidth); + const [draggingToc, setDraggingToc] = useState(false); + + const showToc = panelWidth === 0 || panelWidth >= TOC_MIN_PANEL_WIDTH; + // Zero means the observer has not reported yet; assume there is room rather + // than rendering a compact header for one frame on every open. + const compactHeader = panelWidth > 0 && panelWidth < HEADER_COMPACT_WIDTH; + // Clamped on read rather than on write: the panel can be resized after the + // width was stored, and a remembered 400px column must not swallow a narrow + // panel. + const tocWidth = Math.min( + Math.max(storedTocWidth, TOC_MIN_WIDTH), + Math.max(TOC_MIN_WIDTH, (panelWidth || TOC_MIN_PANEL_WIDTH) * TOC_MAX_FRACTION) + ); + + const handleTocResizeStart = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + const startX = event.clientX; + const startWidth = tocWidth; + const maxWidth = Math.max(TOC_MIN_WIDTH, (rootRef.current?.clientWidth ?? 0) * TOC_MAX_FRACTION); + setDraggingToc(true); + + const onMove = (moveEvent: PointerEvent) => { + const next = Math.min(maxWidth, Math.max(TOC_MIN_WIDTH, startWidth + moveEvent.clientX - startX)); + setStoredTocWidth(next); + }; + const onUp = () => { + setDraggingToc(false); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + }, + [setStoredTocWidth, tocWidth] + ); + + const handleTocResizeKey = useCallback( + (event: React.KeyboardEvent) => { + const step = event.shiftKey ? 40 : 10; + const delta = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0; + if (delta === 0) return; + event.preventDefault(); + const maxWidth = Math.max(TOC_MIN_WIDTH, (rootRef.current?.clientWidth ?? 0) * TOC_MAX_FRACTION); + setStoredTocWidth(Math.min(maxWidth, Math.max(TOC_MIN_WIDTH, tocWidth + delta))); + }, + [setStoredTocWidth, tocWidth] + ); + const [scope, setScope] = useState('all'); + const [activeStopId, setActiveStopId] = useState(null); + const [scrollToStopId, setScrollToStopId] = useState(null); + const [visitedStopIds, setVisitedStopIds] = useState>(() => new Set()); + + const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference); + const wrapLines = useUIStore((state) => state.diffWrapLines); + // The walkthrough column is narrower than the diff surface and stops are read + // top-to-bottom, so `dynamic` resolves to inline here rather than guessing + // from the window width. + const renderSideBySide = diffLayoutPreference === 'side-by-side'; + + const requestedSource = useWalkthroughStore((state) => state.requestedSource[directory]); + const clearRequestedSource = useWalkthroughStore((state) => state.clearRequestedSource); + + const status = useGitStatus(directory || null); + const branches = useGitBranches(directory || null); + + // The branch source reviews everything on this branch that is not on its + // base. Three-dot semantics server-side mean merges from the base are + // already excluded. + const currentBranch = status?.current ?? null; + const branchSource = useMemo(() => { + const headRef = currentBranch; + if (!headRef) return null; + const all = branches?.all ?? []; + const localBranches = all.filter((name) => !name.startsWith('remotes/')); + const remoteNames = new Set( + all + .filter((name) => name.startsWith('remotes/')) + .map((name) => name.slice('remotes/'.length).split('/')[0]) + .filter(Boolean) + ); + const baseRef = deriveBaseBranch({ remoteNames, localBranches }); + if (!baseRef || baseRef === headRef) return null; + return { kind: 'branch', baseRef, headRef }; + }, [branches, currentBranch]); + + // The pull request for this branch used to appear only after visiting the PR + // panel, because nothing else asked GitHub about it. Ask here too: the status + // store already dedupes by signature and throttles by TTL, so several panels + // wanting the same answer produce one request. + const { github } = useRuntimeAPIs(); + const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false); + const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); + const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry); + const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams); + const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets); + + useEffect(() => { + if (!directory || !currentBranch || !githubAuthChecked || !githubConnected) return; + const key = getGitHubPrStatusKey(directory, currentBranch); + ensurePrStatusEntry(key); + setPrStatusParams(key, { + directory, + branch: currentBranch, + remoteName: null, + canShow: true, + github, + githubAuthChecked, + githubConnected, + }); + void refreshPrStatusTargets([{ directory, branch: currentBranch, remoteName: null }]); + }, [ + currentBranch, + directory, + ensurePrStatusEntry, + github, + githubAuthChecked, + githubConnected, + refreshPrStatusTargets, + setPrStatusParams, + ]); + + // Selecting the number rather than the entry map: a primitive keeps this + // panel out of every unrelated PR status update. + const branchPrNumber = useGitHubPrStatusStore((state) => ( + directory && currentBranch + ? getFreshestPrStatusForBranch(state.entries, directory, currentBranch)?.pr?.number ?? null + : null + )); + + const source = useMemo( + () => requestedSource ?? { kind: 'working-tree', scope }, + [requestedSource, scope] + ); + + // Offer whichever pull request we know about: the one already selected, or + // the one this branch has. + const prSource = useMemo | null>(() => { + if (source.kind === 'pr') return source; + return branchPrNumber ? { kind: 'pr', number: branchPrNumber } : null; + }, [branchPrNumber, source]); + + const selectWorkingTree = useCallback( + (value: WalkthroughWorkingTreeScope) => { + clearRequestedSource(directory); + setScope(value); + }, + [clearRequestedSource, directory] + ); + const entry = useWalkthroughStore((state) => state.getEntry(directory, source)); + const load = useWalkthroughStore((state) => state.load); + const generate = useWalkthroughStore((state) => state.generate); + const cancel = useWalkthroughStore((state) => state.cancel); + const requestSource = useWalkthroughStore((state) => state.requestSource); + const selectModel = useWalkthroughStore((state) => state.selectModel); + const selectedModel = useWalkthroughStore((state) => state.getSelectedModel(directory, source)); + const selectLanguage = useWalkthroughStore((state) => state.selectLanguage); + const selectedLanguage = useWalkthroughStore((state) => state.getSelectedLanguage(directory, source)); + + // Explicit pick first, then the language the walkthrough on screen is + // actually written in, then the interface locale. The middle step matters for + // the same reason it does for the model: reopening a review should describe + // what is there, not what a fresh one would be. + const generatedLanguage = entry.result?.language; + const activeLanguage: Locale = ( + selectedLanguage && locales.includes(selectedLanguage as Locale) + ? (selectedLanguage as Locale) + : generatedLanguage && locales.includes(generatedLanguage as Locale) + ? (generatedLanguage as Locale) + : locale + ); + + // Reloads on a model or language change: whether this diff fits, and whether + // the model can produce structured output, are answers about a specific + // request — and the language instruction is part of that request. + useEffect(() => { + void load(directory, source, { language: activeLanguage }); + }, [activeLanguage, directory, load, source, selectedModel]); + + const view = useMemo(() => buildWalkthroughView(entry.result), [entry.result]); + + // A new walkthrough is a new reading path: keeping the old progress would + // mark stops as visited that the user has never seen. + const generatedAt = entry.result?.generatedAt; + const lastGeneratedAt = useRef(undefined); + useEffect(() => { + if (lastGeneratedAt.current === generatedAt) return; + lastGeneratedAt.current = generatedAt; + setVisitedStopIds(new Set()); + setActiveStopId(view?.stops[0]?.stop.id ?? null); + }, [generatedAt, view]); + + const handleActiveStopChange = useCallback((stopId: string) => { + setActiveStopId(stopId); + setVisitedStopIds((visited) => { + if (visited.has(stopId)) return visited; + const next = new Set(visited); + next.add(stopId); + return next; + }); + }, []); + + const handleSelectStop = useCallback( + (stopId: string) => { + handleActiveStopChange(stopId); + setScrollToStopId(stopId); + }, + [handleActiveStopChange] + ); + + const step = useCallback( + (delta: number) => { + if (!view || view.stops.length === 0) return; + const currentIndex = view.stops.findIndex((stop) => stop.stop.id === activeStopId); + const nextIndex = Math.min(view.stops.length - 1, Math.max(0, (currentIndex < 0 ? 0 : currentIndex) + delta)); + handleSelectStop(view.stops[nextIndex].stop.id); + }, + [activeStopId, handleSelectStop, view] + ); + + const [sourceMenuOpen, setSourceMenuOpen] = useState(false); + const [languageMenuOpen, setLanguageMenuOpen] = useState(false); + const sourceValue = source.kind === 'working-tree' ? source.scope : source.kind; + const sourceLabel = source.kind === 'branch' + ? t('walkthrough.scope.branch') + : source.kind === 'pr' + ? t('walkthrough.scope.pullRequest', { number: source.number }) + : scope === 'all' + ? t('walkthrough.scope.all') + : scope === 'staged' + ? t('walkthrough.scope.staged') + : t('walkthrough.scope.working'); + + // Explicit pick first, then the model that actually produced what is on + // screen, then whatever settings resolve to. The middle step is what makes + // reopening a review show the model behind it rather than the default. + const activeModel = selectedModel + ?? (entry.result?.model ? `${entry.result.model.providerID}/${entry.result.model.modelID}` : undefined) + ?? (entry.readiness?.model ? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}` : undefined); + const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/'); + const activeModelId = activeModelParts.join('/'); + + const modelsMetadata = useConfigStore((state) => state.modelsMetadata); + const [modelProviders, setModelProviders] = useState(undefined); + + useEffect(() => { + if (modelProviders !== undefined) return; + let cancelled = false; + (async () => { + try { + const response = await runtimeFetch('/api/small-model', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return; + const payload = (await response.json().catch(() => null)) as { authenticatedProviders?: unknown } | null; + if (!cancelled && Array.isArray(payload?.authenticatedProviders)) { + setModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string')); + } + } catch { + // Leave undefined: the picker then offers every provider, which is + // worse but not broken. + } + })(); + return () => { + cancelled = true; + }; + }, [modelProviders]); + + const isStructuredOutputCapable = useCallback( + (providerId: string, modelId: string) => + modelsMetadata.get(`${providerId}/${modelId}`)?.structured_output !== false, + [modelsMetadata] + ); + + // Only generation is worth interrupting. A read is a few hundred milliseconds + // of git with nothing to cancel, and offering a Cancel button for it made the + // action flicker every time the model or language changed. + const isGeneratingEntry = entry.status === 'generating'; + + // What is on screen versus what is being asked for. A read that has settled + // is the only thing that can answer this: while one is in flight the panel is + // still showing the previous answer, and a banner claiming something is + // missing before we know would be the same flicker in another place. + const shownModel = entry.result?.model + ? `${entry.result.model.providerID}/${entry.result.model.modelID}` + : undefined; + const shownLanguage = entry.result?.language; + const shownLocale = shownLanguage && locales.includes(shownLanguage as Locale) + ? (shownLanguage as Locale) + : undefined; + const settled = entry.status === 'ready' && Boolean(view); + // An entry written before walkthroughs had a language carries none. Unknown + // is not the same as different, so it is not reported as missing. + const languageMissing = settled && Boolean(shownLocale) && shownLocale !== activeLanguage; + const modelMissing = settled && Boolean(shownModel) && Boolean(activeModel) && shownModel !== activeModel; + + // The stage list outlives the work by a beat. Assembling takes milliseconds, + // so without this the result replaces the list before the last step is ever + // seen finishing — the user is told about a step they never observe. + const stageProgress = useWalkthroughStageProgress(entry.stage, entry.status === 'generating'); + + // Only a generation that started from an empty panel gets held: regenerating + // over an existing walkthrough keeps the stream on screen with a banner, and + // hiding readable content to show a progress list would be a downgrade. + const startedFromEmptyRef = useRef(false); + const previousStatusRef = useRef(entry.status); + useEffect(() => { + if (previousStatusRef.current !== 'generating' && entry.status === 'generating') { + startedFromEmptyRef.current = !view; + } + previousStatusRef.current = entry.status; + }, [entry.status, view]); + + const showStages = startedFromEmptyRef.current + && (entry.status === 'generating' || stageProgress.holding); + const blockedReason = entry.error?.code === 'context-too-small' + || entry.error?.code === 'structured-output-unsupported' + || entry.error?.code === 'no-model' + || entry.error?.code === 'empty-diff' + || entry.error?.code === 'only-generated' + || entry.error?.code === 'output-exhausted' + ? entry.error.code + : entry.readiness && !entry.readiness.ready && !view + ? entry.readiness.reason + : undefined; + + // Both sources carry the model that was tried; the error is the more specific + // one when generation actually ran. + const blockedModel = entry.error?.model ?? entry.readiness?.model; + const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars; + const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars; + + const handleGenerate = useCallback( + (force: boolean) => { + void generate(directory, source, { force, language: activeLanguage }); + }, + [activeLanguage, directory, generate, source] + ); + + return ( +
    +
    + + + + + + { + setSourceMenuOpen(false); + if (value === 'branch') { + if (branchSource) requestSource(directory, branchSource); + return; + } + if (value === 'pr') { + if (prSource) requestSource(directory, prSource); + return; + } + selectWorkingTree(value as WalkthroughWorkingTreeScope); + }} + > + {/* Grouped so "everything" is visibly scoped to uncommitted work: + on its own next to "This branch" it read as "all changes that + exist", which is the opposite of what it selects. */} + + {t('walkthrough.scope.group.workingTree')} + + {SCOPES.map((value) => ( + + {value === 'all' + ? t('walkthrough.scope.all') + : value === 'staged' + ? t('walkthrough.scope.staged') + : t('walkthrough.scope.working')} + + ))} + {(branchSource || prSource) && ( + <> + + + {t('walkthrough.scope.group.committed')} + + + )} + {branchSource && ( + + {t('walkthrough.scope.branch')} + + )} + {prSource && ( + + {t('walkthrough.scope.pullRequest', { number: prSource.number })} + + )} + + + + +
    + {/* A walkthrough nobody can read is worth nothing, so the prose + language is a per-review choice like the model — defaulting to the + interface language, which is the best evidence of what the reader + reads. */} + + + + + + + {t('walkthrough.language.menuLabel')} + + { + setLanguageMenuOpen(false); + selectLanguage(directory, source, value); + }} + > + {locales.map((value) => ( + + {label(value)} + + ))} + + + + + {/* Choosing a roomier model for a risky change is a per-review call, + so this is panel state rather than a settings edit. */} + { + selectModel(directory, source, providerId && modelId ? `${providerId}/${modelId}` : null); + }} + allowedProviderIds={modelProviders} + isModelAllowed={isStructuredOutputCapable} + tooltipsEnabled={false} + dropdownPortalToBody + compact={compactHeader} + className={cn('h-8 min-w-0', !compactHeader && 'max-w-48')} + /> + {view && ( + <> + + + + )} + + {isGeneratingEntry ? ( + + ) : ( + + )} +
    +
    + + {/* While regenerating over an existing walkthrough the stream keeps showing + the old content, so the only other signal would be the button swapping + to Cancel — far too quiet for something that runs for tens of seconds. */} + {entry.status === 'generating' && view && ( +
    + + + {entry.stage === 'collecting' + ? t('walkthrough.stage.collecting') + : entry.stage === 'assembling' + ? t('walkthrough.stage.assembling') + : t('walkthrough.stage.asking')} + +
    + )} + + {/* Switching the model or the language is a request for a walkthrough + that may not exist yet. Falling back to the last one is better than an + empty panel, but only if the panel says so — otherwise the picker + claims Ukrainian over English prose. */} + {(languageMissing || modelMissing) && ( +
    + + + {languageMissing && modelMissing + ? t('walkthrough.missing.languageAndModel') + : languageMissing + ? t('walkthrough.missing.language', { + requested: label(activeLanguage), + shown: label(shownLocale as Locale), + }) + : t('walkthrough.missing.model', { model: activeModelId })} + + +
    + )} + + {view?.isStale && entry.status !== 'generating' && ( +
    + + + {t('walkthrough.stale.banner', { count: view.staleStopCount })} + + +
    + )} + + {entry.error && !blockedReason && ( +
    + + {/* Provider errors arrive as raw JSON bodies. Show a readable amount + and keep the rest reachable rather than filling the panel. */} + + {entry.error.message} + +
    + )} + +
    + {blockedReason ? ( + void load(directory, source)} + /> + ) : showStages ? ( +
    + +
    + ) : view ? ( + <> + {showToc && ( + <> + +
    + + )} + setScrollToStopId(null)} + renderSideBySide={renderSideBySide} + wrapLines={wrapLines} + /> + + ) : ( +
    + {entry.status === 'loading' ? ( + + ) : ( + <> + +

    + {t('walkthrough.empty.title')} +

    +

    + {t('walkthrough.empty.description')} +

    + + )} +
    + )} +
    +
    + ); +}; diff --git a/packages/ui/src/components/views/walkthrough/stopElementId.ts b/packages/ui/src/components/views/walkthrough/stopElementId.ts new file mode 100644 index 00000000..3933f1c0 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/stopElementId.ts @@ -0,0 +1,2 @@ +/** DOM id for a stop section, shared by the stream and its scroll callers. */ +export const stopElementId = (stopId: string): string => `walkthrough-stop-${stopId}`; diff --git a/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.test.ts b/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.test.ts new file mode 100644 index 00000000..73c724b9 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test'; +import { WALKTHROUGH_STAGE_ORDER, __testing } from './useWalkthroughStageProgress'; + +const { indexOfStage, nextIndex } = __testing; + +// The pacing itself is a React effect, but the two decisions it rests on are +// plain functions and are where the mistakes would live. +describe('stage ordering', () => { + test('maps every server stage onto a visible step', () => { + expect(indexOfStage('collecting')).toBe(0); + expect(indexOfStage('asking')).toBe(1); + expect(indexOfStage('assembling')).toBe(2); + }); + + test('folds the schema fallback into the same wait', () => { + // From out here it is still "waiting on the model"; the fallback is our + // plumbing and must not show up as its own step or as going backwards. + expect(indexOfStage('retrying')).toBe(indexOfStage('asking')); + }); + + test('treats an absent stage as not started', () => { + expect(indexOfStage(null)).toBe(-1); + }); +}); + +describe('advancing', () => { + test('moves one step at a time so none is skipped', () => { + expect(nextIndex(-1, 2)).toBe(0); + expect(nextIndex(0, 2)).toBe(1); + expect(nextIndex(1, 2)).toBe(2); + }); + + test('stops at the target', () => { + expect(nextIndex(2, 2)).toBe(2); + }); + + test('completion goes one past the last step so everything reads as done', () => { + expect(nextIndex(2, WALKTHROUGH_STAGE_ORDER.length)).toBe(WALKTHROUGH_STAGE_ORDER.length); + }); +}); diff --git a/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.ts b/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.ts new file mode 100644 index 00000000..eb05f8d7 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/useWalkthroughStageProgress.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react'; +import type { WalkthroughStage } from '@/lib/walkthrough/types'; + +/** + * Paces the stage display so every step is actually seen. + * + * Assembling the walkthrough takes milliseconds, so on the real timeline it + * flickers past between "waiting on the model" and the finished result — the + * user is told about a step they never observe, which is worse than not + * naming it. Each step is therefore held for a floor before the next one is + * revealed, including the final all-done state. + * + * This delays the result by well under a second at the end of a wait measured + * in minutes, and buys a legible finish in exchange. + */ + +const ORDER: WalkthroughStage[] = ['collecting', 'asking', 'assembling']; +const MIN_STEP_MS = 450; + +/** `retrying` is the same wait from the user's side: still waiting on the model. */ +const indexOfStage = (stage: WalkthroughStage | null): number => { + if (stage === 'retrying') return ORDER.indexOf('asking'); + return stage ? ORDER.indexOf(stage) : -1; +}; + +/** One step at a time, so a fast stage is still shown rather than skipped. */ +const nextIndex = (current: number, target: number): number => Math.min(current + 1, target); + +export interface WalkthroughStageProgress { + /** Steps to render as finished. */ + completedCount: number; + /** Step to render as running, or `null` when everything is done. */ + activeIndex: number | null; + /** True while the display still owes the user time after the work finished. */ + holding: boolean; +} + +export const useWalkthroughStageProgress = ( + stage: WalkthroughStage | null, + active: boolean +): WalkthroughStageProgress => { + // `ORDER.length` means "all done"; -1 means nothing started. + const [shownIndex, setShownIndex] = useState(-1); + const shownAtRef = useRef(0); + + const target = active ? Math.max(indexOfStage(stage), 0) : (shownIndex < 0 ? -1 : ORDER.length); + + useEffect(() => { + if (target <= shownIndex) { + // Work restarted: fall back to the earlier step immediately rather than + // pretending the later one is still running. + if (target < shownIndex && active) { + setShownIndex(target); + shownAtRef.current = Date.now(); + } + return; + } + + const elapsed = Date.now() - shownAtRef.current; + const advance = () => { + shownAtRef.current = Date.now(); + setShownIndex((current) => nextIndex(current, target)); + }; + + if (shownIndex < 0 || elapsed >= MIN_STEP_MS) { + advance(); + return; + } + + const timer = setTimeout(advance, MIN_STEP_MS - elapsed); + return () => clearTimeout(timer); + }, [active, shownIndex, target]); + + // A fresh run resets the display so the next generation starts from the top. + useEffect(() => { + if (active || shownIndex < ORDER.length) return; + const timer = setTimeout(() => setShownIndex(-1), MIN_STEP_MS); + return () => clearTimeout(timer); + }, [active, shownIndex]); + + const done = shownIndex >= ORDER.length; + + return { + completedCount: Math.max(0, Math.min(shownIndex, ORDER.length)), + activeIndex: done || shownIndex < 0 ? null : shownIndex, + holding: !active && shownIndex >= 0, + }; +}; + +export const WALKTHROUGH_STAGE_ORDER = ORDER; + +export const __testing = { indexOfStage, nextIndex }; diff --git a/packages/ui/src/components/views/walkthrough/walkthroughAction.ts b/packages/ui/src/components/views/walkthrough/walkthroughAction.ts new file mode 100644 index 00000000..0b7303c7 --- /dev/null +++ b/packages/ui/src/components/views/walkthrough/walkthroughAction.ts @@ -0,0 +1,14 @@ +/** + * Shared chrome for every "open / generate the AI walkthrough" action. + * + * The walkthrough is informational rather than a primary or destructive act, so + * it carries the info status tint instead of competing with the primary button + * next to it (Review in the diff toolbar, Merge in the pull request header). + * Keeping it in one constant is what stops the three entry points from drifting + * apart. + * + * No sparkle iconography: "this is AI" is not what the button does, and the + * cliché tells the user nothing about the outcome. + */ +export const WALKTHROUGH_ACTION_CLASS = + 'border-[var(--status-info-border)] bg-[var(--status-info-background)] text-[var(--status-info)] hover:bg-[var(--status-info-background)] hover:text-[var(--status-info)]'; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index a3b72a93..05a8f63f 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -891,7 +891,20 @@ html:not(.dark) .chat-scroll { } } -/* Diff toolbar: drop low-priority labels only when the context panel is genuinely tight. */ +/* Diff toolbar: the walkthrough label goes first. Review is the primary action + in this row, so it keeps its label longest; the walkthrough is reachable from + the rail as well and reads fine as an icon. */ +@container diff-toolbar (max-width: 36rem) { + .diff-toolbar__walkthrough-label { + display: none; + } + + .diff-toolbar__walkthrough-button { + padding-inline: 0.5rem; + } +} + +/* Then the rest, once the panel is genuinely tight. */ @container diff-toolbar (max-width: 28rem) { .diff-toolbar__scope-count, .diff-toolbar__review-label, @@ -905,8 +918,27 @@ html:not(.dark) .chat-scroll { } } -/* Status row: collapse optional text when narrow to keep both sides in one line. */ -@container status-row (max-width: 30rem) { +/* Pull request actions: the walkthrough label goes first, since merge controls + are the reason the row exists. */ +@container pr-actions (max-width: 30rem) { + .pr-actions__walkthrough-label { + display: none; + } + + .pr-actions__walkthrough-button { + padding-inline: 0.5rem; + } +} + +/* Status row: collapse optional text when narrow to keep both sides in one line. + * + * The todo text goes first, and it goes early. The changed-files summary on the + * left has a floor it cannot shrink past — the file count, the +/- counts and + * the chevron are all fixed width — so once the two sides no longer fit, they + * collide rather than politely truncating. 30rem was measured against the todo + * text alone; with a changed-files summary beside it the collision starts around + * 36rem, so this hides one step before that. */ +@container status-row (max-width: 38rem) { .status-row__active-todo { display: none; } diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 04e329bf..9b1ae0b7 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -146,6 +146,17 @@ export interface GetGitDiffOptions { contextLines?: number; } +/** + * Diff between two refs. Uses three-dot (`base...head`) semantics server-side, so changes + * pulled into `head` by merging `base` are excluded — only the branch's own work is returned. + */ +export interface GetGitRangeDiffOptions { + base: string; + head: string; + path?: string; + contextLines?: number; +} + export interface GitFileDiffResponse { original: string; modified: string; @@ -453,6 +464,7 @@ export interface GitAPI { getGitStatus(directory: string, options?: { mode?: 'light' }): Promise; getGitDiff(directory: string, options: GetGitDiffOptions): Promise; getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise; + getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise; revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise; stageGitFile(directory: string, filePath: string): Promise; stageGitFiles?(directory: string, filePaths: string[]): Promise; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 8b9898ad..1ec9053d 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -131,6 +131,10 @@ export type DesktopSettings = { sessionGoalDefaultBudgetEnabled?: boolean; sessionGoalDefaultBudget?: number; smallModelOverride?: string; // format: "provider/model" + // The walkthrough needs structured output and a roomy context, which the + // small model is often deliberately not chosen for. Unset means "use the + // small model"; a value replaces it for this feature only. + walkthroughModelOverride?: string; // format: "provider/model" defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id openInAppId?: string; autoCreateWorktree?: boolean; diff --git a/packages/ui/src/lib/device.ts b/packages/ui/src/lib/device.ts index ff3e1cba..172f029b 100644 --- a/packages/ui/src/lib/device.ts +++ b/packages/ui/src/lib/device.ts @@ -1,5 +1,6 @@ import React from 'react'; import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; +import { isIPadApp } from '@/lib/platform'; import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; type DeviceType = 'desktop' | 'mobile' | 'tablet'; @@ -121,7 +122,7 @@ export function getDeviceInfo(): DeviceInfo { // UI: every component in that tree is built mobile-first, so wide devices // (iPad, Android tablets, rotated phones) must not fall into // tablet/desktop branches scattered across shared components. - // iPad-specific layout upgrades gate on isIPadApp()/orientation instead. + // Tablet layout upgrades gate on useTabletLayout() (a size class) instead. isMobile = true; isTablet = false; isDesktop = false; @@ -364,6 +365,87 @@ export function useOrientation(): Orientation { return orientation; } +/** + * Smallest viewport side that earns the tablet layout, following Android's + * long-standing `sw600dp` size class. The SHORT side is what makes this a size + * question rather than a device question: a phone reports ~360-430 whichever + * way it is held, a 7"+ tablet or an unfolded book foldable reports ~600+, and + * a foldable folded shut drops back under it. So a fold is just a resize, and + * nothing here has to know what a foldable is. + */ +const TABLET_LAYOUT_MIN_SHORT_SIDE_PX = 600; +/** + * Width below which the workspace cannot become a side panel: the sessions + * sidebar (~320) plus the panel (~380) plus a chat column that is still worth + * reading. Unfolded book foldables land under this even "landscape", so they + * keep the full-cover drawer in both orientations — which is the whole point, + * their wide side is barely wider than a tablet's narrow one. + */ +const WORKSPACE_PANEL_MIN_WIDTH_PX = 1000; + +export interface TabletLayout { + /** Sessions become a persistent sidebar, dropdowns become anchored popovers. */ + enabled: boolean; + /** There is room for the workspace beside the chat instead of over it. */ + roomyForPanels: boolean; +} + +export const readTabletLayout = (): TabletLayout => { + if (typeof window === 'undefined') return { enabled: false, roomyForPanels: false }; + const width = window.innerWidth; + const height = window.innerHeight; + // iPads answer this on identity too: iPadOS reports odd viewports in Slide + // Over / Split View, and a device we KNOW is a tablet should not flip to the + // phone layout because it was given a narrow slice. + const enabled = isIPadApp() || Math.min(width, height) >= TABLET_LAYOUT_MIN_SHORT_SIDE_PX; + return { + enabled, + roomyForPanels: enabled && width > height && width >= WORKSPACE_PANEL_MIN_WIDTH_PX, + }; +}; + +/** + * The tablet layout decision, live. + * + * Deliberately a hook over a one-shot check: foldables change size class while + * the app runs, and the Android shell keeps the WebView alive across the fold + * (`configChanges` covers screenSize), so every consumer has to re-decide + * rather than remember what it saw at mount. + */ +export function useTabletLayout(): TabletLayout { + const [layout, setLayout] = React.useState(readTabletLayout); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + let frame: number | undefined; + const update = () => { + frame = undefined; + const next = readTabletLayout(); + setLayout((current) => ( + current.enabled === next.enabled && current.roomyForPanels === next.roomyForPanels + ? current + : next + )); + }; + const schedule = () => { + if (frame !== undefined) return; + frame = window.requestAnimationFrame(update); + }; + + update(); + window.addEventListener('resize', schedule); + const orientationQuery = window.matchMedia?.('(orientation: landscape)') ?? null; + const detachOrientation = attachMediaQueryListener(orientationQuery, schedule); + return () => { + window.removeEventListener('resize', schedule); + detachOrientation(); + if (frame !== undefined) window.cancelAnimationFrame(frame); + }; + }, []); + + return layout; +} + export function useDeviceInfo(): DeviceInfo { return React.useSyncExternalStore( subscribeDeviceInfo, diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index bfa787de..1adb1c4d 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -135,6 +135,15 @@ export async function getGitFileDiff( return gitHttp.getGitFileDiff(directory, options); } +export async function getGitRangeDiff( + directory: string, + options: import('./api/types').GetGitRangeDiffOptions +): Promise { + const runtime = getRuntimeGit(); + if (runtime?.getGitRangeDiff) return runtime.getGitRangeDiff(directory, options); + return gitHttp.getGitRangeDiff(directory, options); +} + export async function revertGitFile( directory: string, filePath: string, diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 4d8786c8..24317b24 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -2,6 +2,7 @@ import type { GitStatus, GitDiffResponse, GetGitDiffOptions, + GetGitRangeDiffOptions, GitFileDiffResponse, GetGitFileDiffOptions, GitBranch, @@ -222,6 +223,31 @@ export async function getGitDiff(directory: string, options: GetGitDiffOptions): return response.json(); } +export async function getGitRangeDiff( + directory: string, + options: GetGitRangeDiffOptions +): Promise { + const { base, head, path, contextLines } = options; + if (!base || !head) { + throw new Error('base and head are required to fetch git range diff'); + } + + const response = await runtimeFetch( + buildUrl(`${API_BASE}/range-diff`, directory, { + base, + head, + path: path || undefined, + context: contextLines, + }) + ); + + if (!response.ok) { + throw new Error(`Failed to get git range diff: ${response.statusText}`); + } + + return response.json(); +} + export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise { const { path, staged } = options; if (!path) { diff --git a/packages/ui/src/lib/hardwareKeyboard.ts b/packages/ui/src/lib/hardwareKeyboard.ts new file mode 100644 index 00000000..ca7ba92f --- /dev/null +++ b/packages/ui/src/lib/hardwareKeyboard.ts @@ -0,0 +1,157 @@ +/** + * "Is a hardware keyboard attached?" — the input the mobile layout uses to + * decide whether a soft keyboard will ever eat the screen. + * + * Two sources, in priority order: + * + * 1. The native answer. On iOS the shell reads `GCKeyboard` and stamps + * `window.__OPENCHAMBER_HARDWARE_KEYBOARD__` at document start, then keeps it + * live via `oc:hardware-keyboard` (see BridgeViewController). This is + * authoritative and — crucially — known BEFORE the user focuses anything, so + * the draft screen and composer start in the right shape instead of + * re-laying-out after the first focus. + * 2. Inference, for runtimes with no native answer (Android, hosted mobile). + * A `keyboardWillShow` with a real height means there IS a soft keyboard; a + * tiny height means only iOS' shortcut strip; focus with no event at all + * within a short window means nothing was presented. Inference is ignored + * entirely once the native source has spoken. + * + * Everything else stays `false`, which is the safe default: the layout then + * behaves exactly as it does on a phone. + * + * In memory only — a keyboard can be attached and detached while the app runs, + * and both sources re-answer the question continuously. + */ + +import React from 'react'; + +/** Below this the "keyboard" is only iOS' shortcut bar, not a real keyboard. */ +const SOFTWARE_KEYBOARD_MIN_HEIGHT_PX = 120; +/** iOS starts its keyboard animation well inside this window after focus. */ +const KEYBOARD_EVENT_GRACE_MS = 600; + +declare global { + interface Window { + __OPENCHAMBER_HARDWARE_KEYBOARD__?: boolean; + } +} + +// Read at module init, not just from the bridge effect: the stamp exists from +// document start, and the very first render of the draft screen / composer must +// already see it — otherwise the layout still settles one frame late. +const initialNativeAnswer = typeof window !== 'undefined' + && typeof window.__OPENCHAMBER_HARDWARE_KEYBOARD__ === 'boolean' + ? window.__OPENCHAMBER_HARDWARE_KEYBOARD__ + : null; + +let hardwareKeyboardAttached = initialNativeAnswer === true; +let hasNativeAnswer = initialNativeAnswer !== null; +let focusProbeTimer: number | null = null; +let bridgeStarted = false; +const subscribers = new Set<() => void>(); + +if (hardwareKeyboardAttached && typeof document !== 'undefined') { + document.documentElement.classList.add('oc-hardware-keyboard'); +} + +const clearFocusProbe = (): void => { + if (focusProbeTimer === null) return; + window.clearTimeout(focusProbeTimer); + focusProbeTimer = null; +}; + +const setHardwareKeyboardAttached = (value: boolean): void => { + if (hardwareKeyboardAttached === value) return; + hardwareKeyboardAttached = value; + if (typeof document !== 'undefined') { + document.documentElement.classList.toggle('oc-hardware-keyboard', value); + } + for (const listener of subscribers) listener(); +}; + +/** + * Adopt the native shell's answer and stop inferring. Idempotent; safe to call + * before the shell has stamped anything (then it is a no-op and inference + * stays in charge). + */ +export const startHardwareKeyboardBridge = (): (() => void) => { + if (typeof window === 'undefined') return () => {}; + + const adopt = (value: boolean) => { + hasNativeAnswer = true; + clearFocusProbe(); + setHardwareKeyboardAttached(value); + }; + + if (typeof window.__OPENCHAMBER_HARDWARE_KEYBOARD__ === 'boolean') { + adopt(window.__OPENCHAMBER_HARDWARE_KEYBOARD__); + } + + if (bridgeStarted) return () => {}; + bridgeStarted = true; + + const handleNativeChange = (event: Event) => { + const detail = (event as CustomEvent<{ attached?: boolean }>).detail; + adopt(detail?.attached === true); + }; + window.addEventListener('oc:hardware-keyboard', handleNativeChange); + return () => { + window.removeEventListener('oc:hardware-keyboard', handleNativeChange); + bridgeStarted = false; + }; +}; + +/** + * Feed a native `keyboardWillShow` height in. Called by the Capacitor keyboard + * bridge (see `mobileNativeChrome`) on both platforms. An arriving event always + * settles the question, so it cancels any pending focus probe. + */ +export const observeNativeKeyboardHeight = (heightPx: number): void => { + if (hasNativeAnswer || !Number.isFinite(heightPx)) return; + clearFocusProbe(); + setHardwareKeyboardAttached(heightPx > 0 && heightPx < SOFTWARE_KEYBOARD_MIN_HEIGHT_PX); +}; + +/** + * Report that an editor just took focus. If no keyboard event follows, nothing + * was presented — which means a hardware keyboard is attached. + * + * Deliberately one-directional within the window: only the SILENCE concludes + * "hardware". A real `keyboardWillShow` cancels the probe above, so a slow + * keyboard can never be misread. + */ +export const observeEditorFocus = (): void => { + if (hasNativeAnswer || typeof window === 'undefined' || typeof document === 'undefined') return; + // A soft keyboard already up sends no second `keyboardWillShow` — a refocus + // through it (the overlay-close keyboard restore) would look like silence. + if (document.documentElement.classList.contains('oc-keyboard-open')) return; + clearFocusProbe(); + focusProbeTimer = window.setTimeout(() => { + focusProbeTimer = null; + setHardwareKeyboardAttached(true); + }, KEYBOARD_EVENT_GRACE_MS); +}; + +/** Drop the inferred state when the native bridge tears down. */ +export const resetHardwareKeyboardDetection = (): void => { + clearFocusProbe(); + if (hasNativeAnswer) return; + setHardwareKeyboardAttached(false); +}; + +export const isHardwareKeyboardAttached = (): boolean => hardwareKeyboardAttached; + +export const subscribeHardwareKeyboard = (listener: () => void): (() => void) => { + subscribers.add(listener); + return () => { + subscribers.delete(listener); + }; +}; + +export function useHardwareKeyboard(): boolean { + return React.useSyncExternalStore( + subscribeHardwareKeyboard, + isHardwareKeyboardAttached, + () => false, + ); +} diff --git a/packages/ui/src/lib/i18n/bootstrap.ts b/packages/ui/src/lib/i18n/bootstrap.ts index dc712f63..0daa25c5 100644 --- a/packages/ui/src/lib/i18n/bootstrap.ts +++ b/packages/ui/src/lib/i18n/bootstrap.ts @@ -209,12 +209,32 @@ const JA_MESSAGES: BootstrapMessages = { loadingData: (providersText, agentsText) => `データを読み込み中 (${providersText}, ${agentsText})…`, }; +const DE_MESSAGES: BootstrapMessages = { + startingApi: 'OpenCode API wird gestartet…', + initializing: 'Initialisierung…', + connecting: 'Verbindung wird hergestellt…', + connected: 'Verbunden!', + connectionError: 'Verbindungsfehler', + disconnected: 'Verbindung getrennt', + reconnecting: 'Verbindung wird wiederhergestellt…', + initialDataLoadFailed: 'OpenCode ist verbunden, aber die Anfangsdaten konnten nicht geladen werden.', + cliNotFound: 'OpenCode CLI wurde nicht gefunden. Installieren Sie es zuerst.', + providersReady: '✓ Anbieter', + providersLoading: '… Anbieter', + agentsReady: '✓ Agents', + agentsLoading: '… Agents', + startingDevServer: (hostLabel) => `Webview-Entwicklungsserver wird gestartet (${hostLabel})...`, + waitingDevServer: (hostLabel, attempt) => `Warten auf den Webview-Entwicklungsserver (${hostLabel})... Versuch ${attempt}`, + loadingData: (providersText, agentsText) => `Daten werden geladen (${providersText}, ${agentsText})…`, +}; + export const getBootstrapMessages = (locale: Locale): BootstrapMessages => { return BOOTSTRAP_MESSAGES[locale]; }; const BOOTSTRAP_MESSAGES: Record = { en: EN_MESSAGES, + de: DE_MESSAGES, fr: FR_MESSAGES, 'zh-CN': ZH_CN_MESSAGES, 'zh-TW': ZH_TW_MESSAGES, diff --git a/packages/ui/src/lib/i18n/intl.ts b/packages/ui/src/lib/i18n/intl.ts index b5d7f80b..ab6f4686 100644 --- a/packages/ui/src/lib/i18n/intl.ts +++ b/packages/ui/src/lib/i18n/intl.ts @@ -3,6 +3,7 @@ import type { Locale } from './runtime'; const INTL_LOCALE_BY_LOCALE: Record = { en: 'en-US', + de: 'de-DE', fr: 'fr-FR', 'zh-CN': 'zh-CN', 'zh-TW': 'zh-TW', diff --git a/packages/ui/src/lib/i18n/messages.test.ts b/packages/ui/src/lib/i18n/messages.test.ts index bb4c2b3e..6ea5035f 100644 --- a/packages/ui/src/lib/i18n/messages.test.ts +++ b/packages/ui/src/lib/i18n/messages.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { dict as enDict } from './messages/en'; import { dict as esDict } from './messages/es'; +import { dict as deDict } from './messages/de'; import { dict as frDict } from './messages/fr'; import { dict as jaDict } from './messages/ja'; import { dict as koDict } from './messages/ko'; @@ -13,6 +14,7 @@ import { dict as zhTwDict } from './messages/zh-TW'; const localeDictionaries = { en: enDict, + de: deDict, fr: frDict, es: esDict, ja: jaDict, @@ -35,6 +37,7 @@ describe('i18n dictionaries', () => { test('all locales expose language label keys', () => { for (const [, dictionary] of Object.entries(localeDictionaries)) { + expect(dictionary['common.language.german']).toBeTruthy(); expect(dictionary['common.language.french']).toBeTruthy(); expect(dictionary['common.language.japanese']).toBeTruthy(); } diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts new file mode 100644 index 00000000..8bc4fe64 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -0,0 +1,2043 @@ +export const settingsDict = { + 'settings.providers.page.openCodeGo.title': 'OpenCode Go Nutzungsverfolgung', + 'settings.providers.page.openCodeGo.description': 'Verbinden Sie das OpenCode Go Dashboard, um rollierenden, wöchentlichen und monatlichen Verbrauch anzuzeigen.', + 'settings.providers.page.openCodeGo.workspaceId': 'Workspace-ID', + 'settings.providers.page.openCodeGo.authCookie': 'Auth-Cookie', + 'settings.providers.page.openCodeGo.help': 'Kopieren Sie die Workspace-ID aus der Dashboard-URL und den Auth-Cookie aus den Browser-Entwicklertools. OpenChamber liest niemals den Browser-Cookie-Speicher aus.', + 'settings.providers.page.openCodeGo.save': 'Speichern und validieren', + 'settings.providers.page.openCodeGo.replace': 'Ersetzen', + 'settings.providers.page.openCodeGo.validate': 'Validieren', + 'settings.providers.page.openCodeGo.delete': 'Löschen', + 'settings.providers.page.quotaCredentials.saved': '{provider}-Anmeldedaten gespeichert.', + 'settings.providers.page.openCodeGo.saveFailed': 'OpenCode Go-Anmeldedaten konnten nicht validiert werden.', + 'settings.providers.page.openCodeGo.valid': 'OpenCode Go-Anmeldedaten sind gültig.', + 'settings.providers.page.openCodeGo.invalid': 'OpenCode Go-Anmeldedaten sind ungültig oder abgelaufen.', + 'settings.providers.page.openCodeGo.deleted': 'OpenCode Go-Anmeldedaten gelöscht.', + 'settings.providers.page.openCodeGo.deleteFailed': 'OpenCode Go-Anmeldedaten konnten nicht gelöscht werden.', + 'settings.appearance.language.label': 'Sprache', + 'settings.appearance.language.description': 'Wählen Sie die Oberflächensprache.', + 'settings.appearance.language.select': 'Sprache auswählen', + 'settings.window.description': 'OpenChamber-Einstellungsfenster.', + 'settings.view.home.title': 'Einstellungen', + 'settings.view.home.description': 'Zu häufig verwendeten Seiten springen.', + 'settings.view.home.cards.providers.title': 'Anbieter', + 'settings.view.home.cards.providers.description': 'Modelle + Anmeldedaten verbinden', + 'settings.view.home.cards.agents.title': 'Agenten', + 'settings.view.home.cards.agents.description': 'Prompts, Tools, Berechtigungen', + 'settings.view.home.cards.skillsCatalog.title': 'Skills Catalog', + 'settings.view.home.cards.skillsCatalog.description': 'Skills aus Katalogen installieren', + 'settings.view.home.cards.mcp.title': 'MCP', + 'settings.view.home.cards.mcp.description': 'MCP-Server + Verbindungen konfigurieren', + 'settings.view.home.cards.plugins.title': 'Plugins', + 'settings.view.home.cards.plugins.description': 'OpenCode-Plugins verwalten', + 'settings.view.home.cards.usage.title': 'Nutzung', + 'settings.view.home.cards.usage.description': 'Kontingent + Verbrauchsübersicht', + 'settings.view.unavailable.title': 'Nicht verfügbar', + 'settings.view.unavailable.description': 'Diese Einstellungsseite ist in dieser Laufzeitumgebung nicht verfügbar.', + 'settings.view.badge.beta': 'Beta', + 'settings.view.actions.reloadOpenCode': 'OpenCode neu laden', + 'settings.view.actions.reloadOpenCodeTooltip': 'OpenCode neu starten und Konfiguration neu laden.', + 'settings.view.actions.backToSettings': 'Zurück zu Einstellungen', + 'settings.view.actions.closeSettings': 'Einstellungen schließen', + 'settings.view.actions.openSectionList': 'Abschnittsliste öffnen', + 'settings.view.actions.closeSettingsWithShortcut': 'Einstellungen schließen ({shortcut}+,)', + 'settings.view.actions.back': 'Zurück', + 'settings.view.actions.resizeNavigation': 'Einstellungsnavigation skalieren', + 'settings.view.search.placeholder': 'Einstellungen durchsuchen', + 'settings.view.search.aria': 'Einstellungen durchsuchen', + 'settings.view.search.clear': 'Einstellungsuche löschen', + 'settings.view.search.noResults': 'Keine passenden Einstellungen', + 'settings.page.projects.title': 'Projekte', + 'settings.page.remoteInstances.title': 'Remote-Instanzen', + 'settings.page.providers.title': 'Anbieter', + 'settings.page.usage.title': 'Nutzung', + 'settings.page.agents.title': 'Agenten', + 'settings.page.commands.title': 'Befehle', + 'settings.page.mcp.title': 'MCP', + 'settings.page.plugins.title': 'Plugins', + 'settings.page.skills.title': 'Skills', + 'settings.page.skillsCatalog.title': 'Skills Catalog', + 'settings.page.git.title': 'Git', + 'settings.page.appearance.title': 'Erscheinungsbild', + 'settings.page.chat.title': 'Chat', + 'settings.page.shortcuts.title': 'Tastenkombinationen', + 'settings.page.sessions.title': 'Sitzungen', + 'settings.page.magicPrompts.title': 'Magic Prompts', + 'settings.page.notifications.title': 'Benachrichtigungen', + 'settings.page.voice.title': 'Sprache', + 'settings.page.tunnel.title': 'Remote-Tunnel', + 'settings.page.about.title': 'Über', + 'settings.page.snippets.title': 'Snippets', + 'settings.snippets.sidebar.title': 'Snippets', + 'settings.snippets.sidebar.total': 'Gesamt: {count}', + 'settings.snippets.sidebar.actions.create': 'Snippet erstellen', + 'settings.snippets.sidebar.actions.more': 'Weitere Aktionen für {name}', + 'settings.snippets.sidebar.toast.deleted': 'Snippet gelöscht', + 'settings.snippets.sidebar.toast.deleteFailed': 'Snippet konnte nicht gelöscht werden', + 'settings.snippets.sidebar.dialog.deleteTitle': 'Snippet löschen?', + 'settings.snippets.sidebar.dialog.deleteDescription': 'Dadurch wird #{name} dauerhaft gelöscht.', + 'settings.snippets.page.empty.title': 'Snippet auswählen', + 'settings.snippets.page.empty.description': 'Wählen Sie ein Snippet aus der Seitenleiste, um es zu bearbeiten.', + 'settings.snippets.page.title.new': 'Neues Snippet', + 'settings.snippets.page.field.namePlaceholder': 'snippet-name', + 'settings.snippets.page.field.descriptionPlaceholder': 'Was dieses Snippet tut', + 'settings.snippets.page.field.aliases': 'Aliasse', + 'settings.snippets.page.field.aliasesPlaceholder': 'sicher, vorsichtig', + 'settings.snippets.page.field.content': 'Inhalt', + 'settings.snippets.page.field.contentPlaceholder': 'Snippet-Markdown...', + 'settings.snippets.page.hint': 'Verwenden Sie #name in Prompts. Unterstützt Aliasse und Voranstellen/Anhängen von Blöcken.', + 'settings.snippets.page.toast.nameRequired': 'Snippet-Name ist erforderlich', + 'settings.snippets.page.toast.contentRequired': 'Snippet-Inhalt ist erforderlich', + 'settings.snippets.page.toast.saveFailed': 'Snippet konnte nicht gespeichert werden', + 'settings.snippets.page.toast.saved': 'Snippet gespeichert', + 'settings.page.promptTemplates.title': 'Prompt-Vorlagen', + 'settings.promptTemplates.sidebar.title': 'Prompt-Vorlagen', + 'settings.promptTemplates.sidebar.total': 'Gesamt: {count}', + 'settings.promptTemplates.sidebar.empty.title': 'Keine Prompt-Vorlagen', + 'settings.promptTemplates.sidebar.empty.description': 'Erstellen Sie eine Prompt-Vorlage, um zu beginnen.', + 'settings.promptTemplates.sidebar.toast.deleted': 'Vorlage "{name}" gelöscht', + 'settings.promptTemplates.sidebar.toast.deleteFailed': 'Vorlage konnte nicht gelöscht werden', + 'settings.promptTemplates.sidebar.toast.nameRequired': 'Vorlagenname ist erforderlich', + 'settings.promptTemplates.sidebar.toast.renamed': 'Vorlage umbenannt', + 'settings.promptTemplates.sidebar.toast.renameFailed': 'Vorlage konnte nicht umbenannt werden', + 'settings.promptTemplates.sidebar.dialog.deleteTitle': 'Vorlage löschen?', + 'settings.promptTemplates.sidebar.dialog.deleteDescription': 'Dadurch wird "{name}" dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.', + 'settings.promptTemplates.sidebar.renameDialog.title': 'Vorlage umbenennen', + 'settings.promptTemplates.sidebar.renameDialog.description': 'Geben Sie einen neuen Namen für "{name}" ein.', + 'settings.promptTemplates.sidebar.renameDialog.placeholder': 'Vorlagenname', + 'settings.promptTemplates.sidebar.badge.default': 'Standard', + 'settings.promptTemplates.page.empty.title': 'Vorlage auswählen', + 'settings.promptTemplates.page.empty.description': 'Wählen Sie eine Vorlage aus der Seitenleiste, um sie zu bearbeiten.', + 'settings.promptTemplates.page.title.new': 'Neue Vorlage', + 'settings.promptTemplates.page.subtitle.edit': 'Vorlage bearbeiten', + 'settings.promptTemplates.page.subtitle.new': 'Neue Prompt-Vorlage erstellen', + 'settings.promptTemplates.page.section.identity': 'Identität', + 'settings.promptTemplates.page.field.name': 'Name', + 'settings.promptTemplates.page.field.namePlaceholder': 'Vorlagenname', + 'settings.promptTemplates.page.section.template': 'Vorlage', + 'settings.promptTemplates.page.field.templatePlaceholder': 'Prompt-Vorlagentext eingeben...', + 'settings.promptTemplates.page.templateHint': 'Dieser Text wird dem Prompt des Benutzers vorangestellt, wenn er in einer Multi-Run-Gruppe ausgewählt wird.', + 'settings.promptTemplates.page.toast.nameRequired': 'Vorlagenname ist erforderlich', + 'settings.promptTemplates.page.toast.updated': 'Vorlage aktualisiert', + 'settings.promptTemplates.page.toast.updateFailed': 'Vorlage konnte nicht aktualisiert werden', + 'settings.promptTemplates.page.toast.created': 'Vorlage erstellt', + 'settings.promptTemplates.page.toast.createFailed': 'Vorlage konnte nicht erstellt werden', + 'settings.promptTemplates.page.toast.saveUnexpectedError': 'Beim Speichern ist ein unerwarteter Fehler aufgetreten', + 'settings.openchamber.tunnel.title': 'Remote-Tunnel', + 'settings.openchamber.tunnel.description': 'Sicheren Remote-Zugriff konfigurieren.', + 'settings.openchamber.tunnel.note.serverSideEnforced': 'Sicherer Tunnel-Zugriff wird serverseitig erzwungen.', + 'settings.openchamber.tunnel.note.connectLinksOneTime': 'Verbindungslinks sind einmalig.', + 'settings.openchamber.tunnel.note.cloudflareConnectorTarget': 'Cloudflare-Connector-Ziel:', + 'settings.openchamber.tunnel.note.cloudflareConnectorTargetUse': 'Als Cloudflare-Connector-Ziel verwenden Sie', + 'settings.openchamber.tunnel.note.tokensSavedPerTunnel': 'Tokens werden pro Tunnel gespeichert.', + 'settings.openchamber.tunnel.note.customConfigUsed': 'Eine benutzerdefinierte Konfigurationsdatei wird verwendet.', + 'settings.openchamber.tunnel.note.defaultConfigUsed': 'Wenn leer, verwendet cloudflared seine Standardkonfiguration.', + 'settings.openchamber.tunnel.note.managedRemoteRequiresDomain': 'Verwaltete Remote-Tunnel erfordern eine Domain.', + 'settings.openchamber.tunnel.note.managedLocalUsesConfig': 'Verwaltete lokale Tunnel verwenden Ihre cloudflared-Konfiguration.', + 'settings.openchamber.tunnel.note.startModeAndGenerateLink': 'Einen {mode}-Tunnel starten und einen einmaligen Verbindungslink generieren.', + 'settings.openchamber.tunnel.note.scanQrToConnect': 'Scannen Sie mit Ihrem Smartphone, um eine Verbindung herzustellen.', + 'settings.openchamber.tunnel.section.redeemedAccessLinks': 'Eingelöste Zugriffslinks', + 'settings.openchamber.tunnel.section.savedManagedRemoteTunnels': 'Gespeicherte verwaltete Remote-Tunnel', + 'settings.openchamber.tunnel.notAvailable.cloudflaredNotFound': 'cloudflared nicht gefunden', + 'settings.openchamber.tunnel.notAvailable.dependencyNotFound': '{dependency} wurde nicht gefunden.', + 'settings.openchamber.tunnel.notAvailable.installHint': 'Installieren Sie es, um Remote-Tunnelzugriff zu aktivieren:', + 'settings.openchamber.tunnel.field.provider': 'Anbieter', + 'settings.openchamber.tunnel.field.providerPlaceholder': 'Anbieter auswählen', + 'settings.openchamber.tunnel.field.tunnelType': 'Tunneltyp', + 'settings.openchamber.tunnel.field.connectLinkTtl': 'Verbindungslink-TTL', + 'settings.openchamber.tunnel.field.tunnelSessionTtl': 'Tunnel-Sitzungs-TTL', + 'settings.openchamber.tunnel.field.hostnameLabel': 'Hostname:', + 'settings.openchamber.tunnel.field.savedTokenAvailablePlaceholder': 'Gespeicherter Token verfügbar (optional ersetzen)', + 'settings.openchamber.tunnel.field.pasteTokenPlaceholder': 'Token für diesen Tunnel einfügen', + 'settings.openchamber.tunnel.field.newPresetNamePlaceholder': 'Tunnelname (z. B. Produktion)', + 'settings.openchamber.tunnel.field.newPresetHostnamePlaceholder': 'Hostname (z. B. oc.example.com)', + 'settings.openchamber.tunnel.field.newPresetTokenPlaceholder': 'Token', + 'settings.openchamber.tunnel.field.managedRemoteTokenInfoAria': 'Token-Info für verwalteten Remote-Tunnel', + 'settings.openchamber.tunnel.field.configurationFile': 'Konfigurationsdatei', + 'settings.openchamber.tunnel.field.configurationFilePlaceholder': 'Standard-cloudflared-Konfiguration wird verwendet', + 'settings.openchamber.tunnel.field.managedRemoteTunnelToConnect': 'Verwalteter Remote-Tunnel zur Verbindung', + 'settings.openchamber.tunnel.field.selectSavedTunnelPlaceholder': 'Gespeicherten Tunnel auswählen', + 'settings.openchamber.tunnel.field.publicUrlHint': 'Public URL', + 'settings.openchamber.tunnel.field.connectLink': 'Verbindungslink', + 'settings.openchamber.tunnel.field.expires': 'Läuft ab', + 'settings.openchamber.tunnel.field.connectQrAlt': 'Tunnel-Verbindungs-QR-Code', + 'settings.openchamber.tunnel.actions.removePresetAria': '{name} entfernen', + 'settings.openchamber.tunnel.actions.saveToken': 'Token speichern', + 'settings.openchamber.tunnel.actions.browseConfigFileAria': 'Konfigurationsdatei durchsuchen', + 'settings.openchamber.tunnel.actions.clearConfigFileAria': 'Konfigurationsdatei leeren', + 'settings.openchamber.tunnel.actions.openManagedRemoteDocs': 'Dokumentation aufrufen', + 'settings.openchamber.tunnel.actions.openManagedLocalDocs': 'Dokumentation aufrufen', + 'settings.openchamber.tunnel.actions.startTunnel': 'Tunnel starten', + 'settings.openchamber.tunnel.actions.startingTunnel': 'Tunnel wird gestartet...', + 'settings.openchamber.tunnel.actions.stopTunnel': 'Tunnel stoppen', + 'settings.openchamber.tunnel.actions.stopping': 'Wird gestoppt...', + 'settings.openchamber.tunnel.actions.newConnectLink': 'Neuer Verbindungslink', + 'settings.openchamber.tunnel.actions.copied': 'Kopiert', + 'settings.openchamber.tunnel.actions.retry': 'Wiederholen', + 'settings.openchamber.tunnel.option.moreProvidersSoon': 'Weitere Anbieter folgen bald', + 'settings.openchamber.tunnel.option.mode.quick.label': 'Schnell', + 'settings.openchamber.tunnel.option.mode.quick.tooltip': 'Quick Tunnel ohne Verfügbarkeitsgarantie.', + 'settings.openchamber.tunnel.option.mode.managedRemote.label': 'Verwaltetes Remote', + 'settings.openchamber.tunnel.option.mode.managedRemote.tooltip': 'Verwendet Ihr Cloudflare-Konto für dauerhaften Zugriff.', + 'settings.openchamber.tunnel.option.mode.managedLocal.label': 'Verwaltetes Lokal', + 'settings.openchamber.tunnel.option.mode.managedLocal.tooltip': 'Verwendet Ihre lokale cloudflared-Konfigurationsdatei.', + 'settings.openchamber.tunnel.warning.quickModeReliability': 'Für zuverlässigeren Zugriff wechseln Sie zu "Verwaltetes Remote" oder "Verwaltetes Lokal".', + 'settings.openchamber.tunnel.warning.replacesActiveTunnel': 'Das Starten ersetzt den aktiven Tunnel.', + 'settings.openchamber.tunnel.badge.quick': 'SCHNELL', + 'settings.openchamber.tunnel.badge.remote': 'REMOTE', + 'settings.openchamber.tunnel.badge.local': 'LOKAL', + 'settings.openchamber.tunnel.state.loading': 'Wird geladen', + 'settings.openchamber.tunnel.state.expired': 'Abgelaufen', + 'settings.openchamber.tunnel.state.revoked': 'Widerrufen', + 'settings.openchamber.tunnel.state.inactive': 'Inaktiv', + 'settings.openchamber.tunnel.state.noExpiry': 'Kein Ablaufdatum', + 'settings.openchamber.tunnel.state.never': 'Nie', + 'settings.openchamber.tunnel.state.tunnelReady': 'Tunnel bereit', + 'settings.openchamber.tunnel.session.redeemedAt': 'Eingelöst {time}', + 'settings.openchamber.tunnel.session.expiresIn': 'Läuft ab in {remaining}', + 'settings.openchamber.tunnel.session.inactiveWithReason': 'Inaktiv ({reason})', + 'settings.openchamber.tunnel.error.invalidConfigExtension': 'Konfigurationsdatei muss .yml, .yaml oder .json verwenden.', + 'settings.openchamber.tunnel.tooltip.tokensSavedPath': 'Tokens werden unter ~/.config/openchamber/cloudflare-managed-remote-tunnels.json gespeichert.', + 'settings.openchamber.tunnel.empty.noManagedRemoteTunnels': 'Noch keine verwalteten Remote-Tunnel gespeichert.', + 'settings.openchamber.tunnel.toast.checkAvailabilityFailed': 'Tunnel-Verfügbarkeit konnte nicht geprüft werden', + 'settings.openchamber.tunnel.toast.saveSettingsFailed': 'Tunnel-Einstellungen konnten nicht gespeichert werden', + 'settings.openchamber.tunnel.toast.saveTtlFailed': 'Tunnel-TTL-Einstellungen konnten nicht gespeichert werden', + 'settings.openchamber.tunnel.toast.saveTokenFailed': 'Token konnte nicht gespeichert werden', + 'settings.openchamber.tunnel.toast.selectOrAddManagedRemoteFirst': 'Wählen oder fügen Sie zuerst einen verwalteten Remote-Tunnel hinzu', + 'settings.openchamber.tunnel.toast.managedRemoteTokenRequiredBeforeStarting': 'Token ist vor dem Start erforderlich', + 'settings.openchamber.tunnel.toast.addManagedRemoteTokenBeforeStarting': 'Fügen Sie vor dem Start einen Token hinzu', + 'settings.openchamber.tunnel.toast.startFailed': 'Tunnel konnte nicht gestartet werden', + 'settings.openchamber.tunnel.toast.startedButNoPublicUrl': 'Tunnel gestartet, aber keine Public URL zurückgegeben', + 'settings.openchamber.tunnel.toast.replacedTunnelSingleSingle': 'Vorherigen Tunnel ersetzt: 1 Link widerrufen, 1 Sitzung ungültig.', + 'settings.openchamber.tunnel.toast.replacedTunnelSingleManySessions': 'Vorherigen Tunnel ersetzt: 1 Link widerrufen, {invalidatedSessionCount} Sitzungen ungültig.', + 'settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession': 'Vorherigen Tunnel ersetzt: {revokedBootstrapCount} Links widerrufen, 1 Sitzung ungültig.', + 'settings.openchamber.tunnel.toast.replacedTunnelManyMany': 'Vorherigen Tunnel ersetzt: {revokedBootstrapCount} Links widerrufen, {invalidatedSessionCount} Sitzungen ungültig.', + 'settings.openchamber.tunnel.toast.linkReady': 'Tunnel-Link bereit', + 'settings.openchamber.tunnel.toast.stopped': 'Tunnel gestoppt', + 'settings.openchamber.tunnel.toast.stopFailed': 'Tunnel konnte nicht gestoppt werden', + 'settings.openchamber.tunnel.toast.connectLinkCopied': 'Verbindungslink kopiert', + 'settings.openchamber.tunnel.toast.copyUrlFailed': 'URL konnte nicht kopiert werden', + 'settings.openchamber.tunnel.toast.saveSelectedManagedRemoteFailed': 'Ausgewählter Tunnel konnte nicht gespeichert werden', + 'settings.openchamber.tunnel.toast.tunnelNameRequired': 'Tunnelname ist erforderlich', + 'settings.openchamber.tunnel.toast.managedRemoteHostnameRequired': 'Hostname ist erforderlich', + 'settings.openchamber.tunnel.toast.managedRemoteTokenRequired': 'Token ist erforderlich', + 'settings.openchamber.tunnel.toast.hostnameAlreadyExists': 'Dieser Hostname existiert bereits', + 'settings.openchamber.tunnel.toast.managedRemoteSaved': 'Verwalteter Remote-Tunnel gespeichert', + 'settings.openchamber.tunnel.toast.managedRemoteRemoved': 'Verwalteter Remote-Tunnel entfernt', + 'settings.magicPrompts.sidebar.title': 'Magic Prompts', + 'settings.magicPrompts.sidebar.description': 'Eine Prompt-Vorlage zum Bearbeiten auswählen.', + 'settings.magicPrompts.sidebar.group.git': 'Git', + 'settings.magicPrompts.sidebar.group.github': 'GitHub', + 'settings.magicPrompts.sidebar.group.planning': 'Planung', + 'settings.magicPrompts.sidebar.group.session': 'Sitzung', + 'settings.magicPrompts.sidebar.item.gitCommitGenerate': 'Commit-Generierung', + 'settings.magicPrompts.sidebar.item.gitPrGenerate': 'PR-Generierung', + 'settings.magicPrompts.sidebar.item.gitConflictResolve': 'Merge/Rebase-Konfliktauflösung', + 'settings.magicPrompts.sidebar.item.gitCherrypickConflictResolve': 'Cherry-pick-Konfliktauflösung', + 'settings.magicPrompts.sidebar.item.githubPrReview': 'PR-Review', + 'settings.magicPrompts.sidebar.item.githubIssueReview': 'Issue-Review', + 'settings.magicPrompts.sidebar.item.githubPrFailedChecksReview': 'PR-Review fehlgeschlagener Prüfungen', + 'settings.magicPrompts.sidebar.item.githubPrCommentsReview': 'PR-Kommentar-Review', + 'settings.magicPrompts.sidebar.item.githubSinglePrCommentReview': 'Einzelner PR-Kommentar-Review', + 'settings.magicPrompts.sidebar.item.planTodo': 'Aufgabenplanung', + 'settings.magicPrompts.sidebar.item.planImprove': 'Plan verbessern', + 'settings.magicPrompts.sidebar.item.planImplement': 'Plan umsetzen', + 'settings.magicPrompts.sidebar.item.sessionSummary': 'Sitzungszusammenfassung', + 'settings.magicPrompts.sidebar.item.sessionWorkspaceReview': 'Workspace-Review', + 'settings.magicPrompts.sidebar.item.sessionExplore': 'Codebase-Tour', + 'settings.magicPrompts.sidebar.item.sessionFeaturePlan': 'Feature-Planung', + 'settings.magicPrompts.sidebar.item.sessionCraftGoal': 'Zielformulierung', + 'settings.magicPrompts.page.group.sessionCraftGoal.title': 'Zielformulierung', + 'settings.magicPrompts.page.group.sessionCraftGoal.description': 'Prompts, die vom /craft-goal-Slash-Befehl verwendet werden: sichtbare Benutzermitteilung + versteckte Anweisungen. Wandelt eine Idee oder Aufgabe in ein klares, belegbares Ziel durch geführte Entdeckung um.', + 'settings.magicPrompts.sidebar.item.sessionCatchUp': 'Nachholen', + 'settings.magicPrompts.sidebar.item.sessionDebug': 'Fehlersuche', + 'settings.magicPrompts.sidebar.item.sessionWeigh': 'Gewichtungsoptionen', + 'settings.magicPrompts.sidebar.item.sessionFusion': 'Fusion', + 'settings.remoteInstances.sidebar.title': 'Remote-Instanzen', + 'settings.remoteInstances.sidebar.total': 'Gesamt {count}', + 'settings.remoteInstances.sidebar.newSshInstanceName': 'Neue SSH-Verbindung', + 'settings.remoteInstances.sidebar.actions.addSshInstance': 'SSH-Verbindung hinzufügen', + 'settings.remoteInstances.sidebar.actions.connect': 'Verbinden', + 'settings.remoteInstances.sidebar.actions.disconnect': 'Trennen', + 'settings.remoteInstances.sidebar.actions.retry': 'Wiederholen', + 'settings.remoteInstances.sidebar.actions.remove': 'Entfernen', + 'settings.remoteInstances.sidebar.confirm.localPortInUseRetry': 'Lokaler Port ist belegt. Zufälligen Port wählen und erneut versuchen?', + 'settings.remoteInstances.sidebar.toast.createFailed': 'SSH-Verbindung konnte nicht erstellt werden', + 'settings.remoteInstances.sidebar.toast.retriedWithRandomPort': 'Mit zufälligem lokalem Port erneut versucht', + 'settings.remoteInstances.sidebar.toast.connectFailed': 'Instanz konnte nicht verbunden werden', + 'settings.remoteInstances.sidebar.toast.disconnectFailed': 'Instanz konnte nicht getrennt werden', + 'settings.remoteInstances.sidebar.toast.retryFailed': 'Verbindungswiederholung fehlgeschlagen', + 'settings.remoteInstances.sidebar.toast.removeFailed': 'Instanz konnte nicht entfernt werden', + 'settings.remoteInstances.direct.sidebarTitle': 'Server-Links', + 'settings.remoteInstances.direct.sidebarDescription': 'Mit einem Link oder Token verbinden', + 'settings.remoteInstances.direct.title': 'Andere OpenChamber-Server', + 'settings.remoteInstances.direct.description': 'Server, zu denen diese App wechseln kann. Importiere einen Verbindungslink vom anderen Server oder füge einen per Adresse hinzu.', + 'settings.remoteInstances.direct.addDialog.description': 'Füge einen weiteren OpenChamber-Server per URL hinzu. Verwende dies, wenn der Server bereits läuft und du über einen Verbindungstoken verfügst.', + 'settings.remoteInstances.direct.field.labelPlaceholder': 'Bezeichnung (optional)', + 'settings.remoteInstances.direct.field.urlPlaceholder': 'https://host:port', + 'settings.remoteInstances.direct.field.tokenPlaceholder': 'Verbindungstoken (optional für vertrauenswürdige lokale Server)', + 'settings.remoteInstances.direct.note': 'Verbindungstoken werden auf diesem Gerät gespeichert und nur verwendet, wenn diese App eine Verbindung zu diesem Server herstellt.', + 'settings.remoteInstances.direct.headers.title': 'Zusätzliche Header', + 'settings.remoteInstances.direct.headers.description': 'Optionale HTTP-Header für Desktop-API-Anfragen. Autorisierung ist für den Verbindungstoken reserviert.', + 'settings.remoteInstances.direct.headers.field.namePlaceholder': 'Header-Name', + 'settings.remoteInstances.direct.headers.field.valuePlaceholder': 'Header-Wert', + 'settings.remoteInstances.direct.headers.actions.add': 'Header hinzufügen', + 'settings.remoteInstances.direct.headers.removeAria': 'Header entfernen', + 'settings.remoteInstances.direct.actions.add': 'Server hinzufügen', + 'settings.remoteInstances.direct.import.description': 'Füge einen Verbindungslink von einem anderen OpenChamber-Server ein.', + 'settings.remoteInstances.direct.import.placeholder': 'openchamber://connect?...', + 'settings.remoteInstances.direct.import.action': 'Link importieren', + 'settings.remoteInstances.direct.error.invalidConnectLink': 'Ungültiger OpenChamber-Verbindungslink.', + 'settings.remoteInstances.direct.state.loading': 'Instanzen werden geladen...', + 'settings.remoteInstances.direct.state.empty': 'Noch keine anderen Server hinzugefügt.', + 'settings.remoteInstances.clientAuth.title': 'Mit diesem Server verbinden', + 'settings.remoteInstances.clientAuth.description': 'Erstelle einen sicheren Link oder Token, damit OpenChamber Desktop eine Verbindung zu diesem Server herstellen kann.', + 'settings.remoteInstances.clientAuth.field.labelPlaceholder': 'Gerätename — z. B. Mein iPhone', + 'settings.remoteInstances.clientAuth.actions.create': 'Token erstellen', + 'settings.remoteInstances.clientAuth.actions.pair': 'Link erstellen', + 'settings.remoteInstances.clientAuth.actions.revoke': 'Widerrufen', + 'settings.remoteInstances.clientAuth.actions.clearRevoked': 'Widerrufene löschen', + 'settings.remoteInstances.clientAuth.qrAlt': 'OpenChamber-Verbindungs-QR-Code', + 'settings.remoteInstances.clientAuth.qrEnlarge': 'QR-Code vergrößern', + 'settings.remoteInstances.clientAuth.qrScanHint': 'Scanne diesen mit der OpenChamber-App auf deinem anderen Gerät. Er ist einmalig und läuft ab.', + 'settings.remoteInstances.clientAuth.qrDialogTitle': 'Zum Verbinden scannen', + 'settings.remoteInstances.clientAuth.actions.addDevice': 'Gerät hinzufügen', + 'settings.remoteInstances.clientAuth.actions.copied': 'Kopiert', + 'settings.remoteInstances.clientAuth.addDevice.transportLabel': 'Wo wirst du dieses Gerät verwenden?', + 'settings.remoteInstances.clientAuth.addDevice.subtitle': 'Erstelle einen Einmal-QR-Code, der ein weiteres Gerät mit diesem Server verbindet.', + 'settings.remoteInstances.clientAuth.addDevice.transport.local': 'Nur diesen Computer', + 'settings.remoteInstances.clientAuth.addDevice.transport.localHint': 'Für Apps, die auf derselben Maschine laufen.', + 'settings.remoteInstances.clientAuth.addDevice.transport.lan': 'Nur Heimnetzwerk', + 'settings.remoteInstances.clientAuth.addDevice.transport.lanHint': 'Verbindet direkt über dein Wi-Fi. Funktioniert nicht außerhalb dieses Netzwerks.', + 'settings.remoteInstances.clientAuth.addDevice.transport.relay': 'Überall', + 'settings.remoteInstances.clientAuth.addDevice.transport.relayHint': 'Funktioniert zu Hause und unterwegs. Verkehr außerhalb des Heimnetzwerks geht über OpenChamber Private Relay — einen Ende-zu-Ende-verschlüsselten Tunnel. Keine Einrichtung nötig.', + 'settings.remoteInstances.clientAuth.addDevice.fallback.relay': 'Verschlüsselte Relay-Verbindung auch außerhalb des Heimnetzwerks erlauben', + 'settings.remoteInstances.clientAuth.addDevice.fallback.preferLocal': 'Direkte Heimverbindung vorziehen, wenn verfügbar', + 'settings.remoteInstances.clientAuth.addDevice.create': 'QR-Code erstellen', + 'settings.remoteInstances.clientAuth.addDevice.done': 'Fertig', + 'settings.remoteInstances.clientAuth.addDevice.connectedToast': 'Gerät verbunden.', + 'settings.remoteInstances.clientAuth.pairingUrl': 'Verbindungslink', + 'settings.remoteInstances.clientAuth.createdToken': 'Kopieren Sie jetzt diesen Token. Aus Sicherheitsgründen wird er nicht erneut angezeigt.', + 'settings.remoteInstances.clientAuth.state.loading': 'Tokens werden geladen...', + 'settings.remoteInstances.clientAuth.state.empty': 'Noch keine Geräte verbunden.', + 'settings.remoteInstances.clientAuth.state.revoked': 'Widerrufen', + 'settings.remoteInstances.clientAuth.state.thisDevice': 'Dieses Gerät', + 'settings.remoteInstances.clientAuth.state.pending': 'Warten auf Verbindung…', + 'settings.remoteInstances.clientAuth.state.viaRelay': 'Relay', + 'settings.remoteInstances.clientAuth.state.connectedDirect': 'Verbunden · Lokales Netzwerk', + 'settings.remoteInstances.clientAuth.state.connectedRelay': 'Verbunden · Relay', + 'settings.remoteInstances.clientAuth.lastUsed': 'Zuletzt verwendet {date}', + 'settings.remoteInstances.clientAuth.neverUsed': 'Nie verwendet', + 'settings.remoteInstances.relay.title': 'OpenChamber Relay', + 'settings.remoteInstances.relay.autoHint': 'Schaltet automatisch ein, wenn Sie ein Gerät über das Relay koppeln.', + 'settings.remoteInstances.relay.description': 'Lassen Sie Ihre anderen Geräte von überall aus verbinden, ohne Ports zu öffnen. Der Datenverkehr ist Ende-zu-Ende verschlüsselt – der Relay kann ihn nicht lesen.', + 'settings.remoteInstances.relay.enableHint': 'Es wird nichts geteilt, bis Sie den Relay auf diesem Server aktivieren.', + 'settings.remoteInstances.relay.actions.enable': 'Relay aktivieren', + 'settings.remoteInstances.relay.actions.disable': 'Deaktivieren', + 'settings.remoteInstances.relay.confirm.disable': 'Relay deaktivieren? Geräte, die über ihn verbunden sind, werden sofort getrennt.', + 'settings.remoteInstances.relay.state.loading': 'Relay-Status wird überprüft...', + 'settings.remoteInstances.relay.state.disabled': 'Deaktiviert', + 'settings.remoteInstances.relay.state.connecting': 'Verbindung herstellen', + 'settings.remoteInstances.relay.state.connected': 'Verbunden', + 'settings.remoteInstances.relay.state.reconnecting': 'Erneut verbinden', + 'settings.remoteInstances.relay.state.error': 'Fehler', + 'settings.remoteInstances.relay.status.clientsOne': '{count} Gerät verbunden', + 'settings.remoteInstances.relay.status.clientsMany': '{count} Geräte verbunden', + 'settings.remoteInstances.relay.pair.title': 'Gerät koppeln', + 'settings.remoteInstances.relay.pair.labelPlaceholder': 'Gerätename (optional)', + 'settings.remoteInstances.relay.pair.includeToken': 'Zugriffstoken einbinden (Ein-Scan-Kopplung)', + 'settings.remoteInstances.relay.pair.noTokenHint': 'Ohne Token meldet sich das Gerät mit dem UI-Kennwort dieses Servers an, nachdem es verbunden ist.', + 'settings.remoteInstances.relay.pair.generate': 'Kopplungslink erstellen', + 'settings.remoteInstances.relay.pair.requiresConnected': 'Kopplung wird verfügbar, sobald der Relay verbunden ist.', + 'settings.remoteInstances.relay.pair.linkLabel': 'Kopplungslink', + 'settings.remoteInstances.relay.pair.warning': 'Dieser Link gewährt Zugriff auf diesen Server. Teile ihn nicht.', + 'settings.remoteInstances.relay.pair.qrAlt': 'Relay-Kopplungs-QR-Code', + 'settings.remoteInstances.relay.pair.showQr': 'QR-Code anzeigen', + 'settings.remoteInstances.relay.pair.qrDialogTitle': 'Zum Koppeln scannen', + 'settings.remoteInstances.relay.pair.qrDialogDescription': 'Scanne diesen QR-Code mit der OpenChamber-App auf deinem anderen Gerät.', + 'settings.remoteInstances.relay.pair.manageHint': 'Verwalte oder widerrufe gekoppelte Geräte in der Liste „Mit diesem Server verbinden“ oben.', + 'settings.remoteInstances.relay.toast.enableFailed': 'Aktivierung des Relays fehlgeschlagen', + 'settings.remoteInstances.relay.toast.disableFailed': 'Deaktivierung des Relays fehlgeschlagen', + 'settings.remoteInstances.relay.toast.offerFailed': 'Erstellung des Kopplungslinks fehlgeschlagen', + 'settings.remoteInstances.relay.toast.linkCopied': 'Kopplungslink kopiert', + 'settings.remoteInstances.sidebar.phase.ready': 'Bereit', + 'settings.remoteInstances.sidebar.phase.error': 'Fehler', + 'settings.remoteInstances.sidebar.phase.reconnect': 'Wiederverbinden', + 'settings.remoteInstances.sidebar.phase.installing': 'Wird installiert', + 'settings.remoteInstances.sidebar.phase.updating': 'Wird aktualisiert', + 'settings.remoteInstances.sidebar.phase.forwarding': 'Weiterleitung aktiv', + 'settings.remoteInstances.sidebar.phase.starting': 'Wird gestartet', + 'settings.remoteInstances.sidebar.phase.connecting': 'Verbindung wird hergestellt', + 'settings.remoteInstances.sidebar.phase.idle': 'Leerlauf', + 'settings.remoteInstances.page.section.instance': 'Instanz', + 'settings.remoteInstances.page.section.instanceDescription': 'SSH-Befehl und Anzeigename für diese Verbindung wählen.', + 'settings.remoteInstances.page.field.mode': 'Modus', + 'settings.remoteInstances.page.field.modeHint': 'Wählen Sie, ob OpenChamber den Server starten soll oder ob Sie sich zu einem laufenden verbinden.', + 'settings.remoteInstances.page.field.modePlaceholder': 'Modus auswählen', + 'settings.remoteInstances.page.field.modeManaged': 'Für mich starten', + 'settings.remoteInstances.page.field.modeExternal': 'Läuft bereits', + 'settings.remoteInstances.page.field.preferredRemotePort': 'Bevorzugter Remote-Port', + 'settings.remoteInstances.page.field.preferredRemotePortHint': 'Leer lassen für automatische Auswahl.', + 'settings.remoteInstances.page.field.keepServerRunning': 'Server am Laufen halten', + 'settings.remoteInstances.page.field.keepServerRunningHint': 'OpenChamber auf der Remote-Maschine weiterlaufen lassen nach Verbindungstrennung.', + 'settings.remoteInstances.page.field.bindHost': 'Bind-Host', + 'settings.remoteInstances.page.field.bindHostHint': 'Verwenden Sie 127.0.0.1 oder localhost, es sei denn, Sie benötigen LAN-Zugriff.', + 'settings.remoteInstances.page.field.preferredLocalPort': 'Bevorzugter lokaler Port', + 'settings.remoteInstances.page.field.preferredLocalPortHint': 'Leer lassen für automatische Auswahl.', + 'settings.remoteInstances.page.field.forwardType': 'Weiterleitungstyp', + 'settings.remoteInstances.page.field.localHostPlaceholder': '127.0.0.1', + 'settings.remoteInstances.page.field.remoteHostPlaceholder': '127.0.0.1', + 'settings.remoteInstances.page.field.auto': 'Automatisch', + 'settings.remoteInstances.page.confirm.bindAllInterfaces': 'Das Binden an 0.0.0.0 macht Ports für Ihr lokales Netzwerk sichtbar. Fortfahren?', + 'settings.remoteInstances.page.preview.localSocks5': '(lokales SOCKS5)', + 'settings.remoteInstances.page.preview.local': '(lokal)', + 'settings.remoteInstances.page.preview.remote': '(remote)', + 'settings.remoteInstances.page.toast.openLocalEndpointFailed': 'Lokale Adresse konnte nicht geöffnet werden', + 'settings.remoteInstances.page.toast.localUrlCopied': 'Lokale URL kopiert', + 'settings.remoteInstances.page.actions.copyLocalUrl': 'Lokale URL kopieren', + 'settings.remoteInstances.page.actions.open': 'Öffnen', + 'settings.remoteInstances.page.logsDialog.selectedInstanceFallback': 'Ausgewählte Instanz', + 'settings.remoteInstances.page.patternDialog.descriptionWithHost': '{host} erfordert ein konkretes Ziel.', + 'settings.remoteInstances.page.patternDialog.description': 'Ziel eingeben.', + 'settings.common.actions.cancel': 'Abbrechen', + 'settings.common.actions.create': 'Erstellen', + 'settings.common.actions.delete': 'Löschen', + 'settings.common.actions.reset': 'Zurücksetzen', + 'settings.common.actions.rename': 'Umbenennen', + 'settings.common.actions.duplicate': 'Duplizieren', + 'settings.common.actions.import': 'Importieren', + 'settings.common.actions.copyAll': 'Alles kopieren', + 'settings.common.actions.clear': 'Leeren', + 'settings.common.actions.saving': 'Wird gespeichert...', + 'settings.common.actions.saveChanges': 'Änderungen speichern', + 'settings.common.scope.global': 'Global', + 'settings.common.scope.project': 'Projekt', + 'settings.common.field.description': 'Beschreibung', + 'settings.common.badge.new': 'Neu', + 'settings.common.badge.modified': 'Geändert', + 'settings.common.permission.allow': 'Erlauben', + 'settings.common.permission.ask': 'Fragen', + 'settings.common.permission.deny': 'Ablehnen', + 'settings.common.state.comingSoon': 'Demnächst verfügbar...', + 'settings.projects.actions.title': 'Aktionen', + 'settings.projects.actions.description': 'Projektspezifische Befehle im Header.', + 'settings.projects.actions.validation.fillNameAndCommand': 'Bitte Aktionsname und Befehl ausfüllen.', + 'settings.projects.actions.state.loading': 'Wird geladen...', + 'settings.projects.actions.state.empty': 'Noch keine Aktionen konfiguriert.', + 'settings.projects.actions.state.untitled': 'Unbenannte Aktion', + 'settings.projects.actions.state.noDesktopSshForwards': 'Keine aktivierten lokalen SSH-Weiterleitungen verfügbar.', + 'settings.projects.actions.field.selectIconAria': 'Symbol auswählen', + 'settings.projects.actions.field.iconAria': 'Symbol {icon}', + 'settings.projects.actions.field.actionNamePlaceholder': 'Aktionsname', + 'settings.projects.actions.field.command': 'Befehl', + 'settings.projects.actions.field.commandPlaceholder': 'z. B. bun run lint', + 'settings.projects.actions.field.autoOpenUrl': 'URL automatisch öffnen', + 'settings.projects.actions.field.autoOpenUrlForAria': 'URL für {title} automatisch öffnen', + 'settings.projects.actions.field.autoOpenUrlDescription': 'URL aus der Ausgabe oder benutzerdefinierte URL unten öffnen', + 'settings.projects.actions.field.overrideUrlPlaceholder': 'URL überschreiben (optional)', + 'settings.projects.actions.field.overrideUrlTooltip': 'Wenn dieses Feld ausgefüllt ist, wird die benutzerdefinierte URL verwendet. Wenn leer, öffnet die App die beste URL aus der Ausgabe.', + 'settings.projects.actions.field.desktopSshForward': 'Desktop SSH-Weiterleitung', + 'settings.projects.actions.field.useOutputManualUrl': 'Ausgabe-/manuelle URL verwenden', + 'settings.projects.actions.actions.add': 'Aktion hinzufügen', + 'settings.projects.actions.actions.save': 'Aktionen speichern', + 'settings.projects.actions.toast.saveFailed': 'Aktionen konnten nicht gespeichert werden', + 'settings.projects.actions.toast.saved': 'Projektaktionen gespeichert', + 'settings.openchamber.about.title': 'Über OpenChamber', + 'settings.openchamber.about.field.version': 'Version', + 'settings.openchamber.about.field.openCodeVersion': 'OpenCode-Version', + 'settings.openchamber.about.state.checking': 'Wird geprüft...', + 'settings.openchamber.about.state.upToDate': 'Aktuell', + 'settings.openchamber.about.state.unknown': 'unbekannt', + 'settings.openchamber.about.actions.checkUpdates': 'Updates prüfen', + 'settings.openchamber.about.actions.update': 'Aktualisieren', + 'settings.openchamber.about.actions.updateToVersion': 'Auf {version} aktualisieren', + 'settings.openchamber.about.actions.checkForUpdates': 'Nach Updates suchen', + 'settings.openchamber.about.toast.latestVersion': 'Sie verwenden die neueste Version', + 'settings.agents.sidebar.title': 'Agents', + 'settings.agents.sidebar.total': 'Gesamt {count}', + 'settings.agents.sidebar.empty.title': 'Keine Agents konfiguriert', + 'settings.agents.sidebar.empty.description': 'Verwenden Sie die Schaltfläche + oben, um einen zu erstellen', + 'settings.agents.sidebar.section.builtIn': 'Integrierte Agents', + 'settings.agents.sidebar.section.custom': 'Benutzerdefinierte Agents', + 'settings.agents.sidebar.badge.system': 'system', + 'settings.agents.sidebar.dialog.deleteTitle': 'Agent löschen', + 'settings.agents.sidebar.dialog.resetTitle': 'Agent zurücksetzen', + 'settings.agents.sidebar.dialog.deleteDescription': 'Möchten Sie den Agent "{name}" wirklich löschen?', + 'settings.agents.sidebar.dialog.resetDescription': 'Möchten Sie den Agent "{name}" wirklich auf seine Standardkonfiguration zurücksetzen?', + 'settings.agents.sidebar.renameDialog.title': 'Agent umbenennen', + 'settings.agents.sidebar.renameDialog.description': 'Geben Sie einen neuen Namen für den Agent "@{name}" ein', + 'settings.agents.sidebar.renameDialog.placeholder': 'Neuer Agent-Name...', + 'settings.agents.sidebar.toast.builtInCannotDelete': 'Integrierte Agents können nicht gelöscht werden', + 'settings.agents.sidebar.toast.agentDeleted': 'Agent "{name}" erfolgreich gelöscht', + 'settings.agents.sidebar.toast.agentReset': 'Agent "{name}" auf Standard zurückgesetzt', + 'settings.agents.sidebar.toast.deleteFailed': 'Agent konnte nicht gelöscht werden', + 'settings.agents.sidebar.toast.definitionNotFound': 'Agent-Definition nicht gefunden. Es wurden keine Änderungen vorgenommen.', + 'settings.agents.sidebar.toast.resetFailed': 'Agent konnte nicht zurückgesetzt werden', + 'settings.agents.sidebar.toast.agentNameRequired': 'Agent-Name ist erforderlich', + 'settings.agents.sidebar.toast.agentExists': 'Ein Agent mit diesem Namen existiert bereits', + 'settings.agents.sidebar.toast.removeOldAfterRenameFailed': 'Alter Agent konnte nach Umbenennung nicht entfernt werden', + 'settings.agents.sidebar.toast.renameFailed': 'Agent konnte nicht umbenannt werden', + 'settings.agents.sidebar.toast.agentRenamed': 'Agent umbenannt in "{name}"', + 'settings.agents.page.toast.permissionNameRequired': 'Berechtigungsname ist erforderlich', + 'settings.agents.page.toast.created': 'Agent erfolgreich erstellt', + 'settings.agents.page.toast.updated': 'Agent erfolgreich aktualisiert', + 'settings.agents.page.toast.createFailed': 'Agent konnte nicht erstellt werden', + 'settings.agents.page.toast.updateFailed': 'Agent konnte nicht aktualisiert werden', + 'settings.agents.page.toast.saveUnexpectedError': 'Beim Speichern ist ein Fehler aufgetreten', + 'settings.agents.page.toast.savedManualRestart': 'Auf Datenträger gespeichert. Starten Sie Ihren verbundenen OpenCode-Server neu, um die Änderungen anzuwenden.', + 'settings.agents.page.empty.title': 'Wählen Sie einen Agent aus der Seitenleiste', + 'settings.agents.page.empty.description': 'oder erstellen Sie einen neuen', + 'settings.agents.page.title.new': 'Neuer Agent', + 'settings.agents.page.subtitle.new': 'Neue Assistenten-Persona konfigurieren', + 'settings.agents.page.subtitle.edit': 'Agent-Einstellungen bearbeiten', + 'settings.agents.page.section.identityRole': 'Identität & Rolle', + 'settings.agents.page.section.modelParameters': 'Modell & Parameter', + 'settings.agents.page.section.systemPrompt': 'System-Prompt', + 'settings.agents.page.section.toolPermissions': 'Tool-Berechtigungen', + 'settings.agents.page.field.agentName': 'Agent-Name', + 'settings.agents.page.field.agentNamePlaceholder': 'agent-name', + 'settings.agents.page.field.scopePlaceholder': 'Geltungsbereich', + 'settings.agents.page.field.descriptionPlaceholder': 'Was macht dieser Agent?', + 'settings.agents.page.field.mode': 'Modus', + 'settings.agents.page.field.modeTooltip': 'Sichtbarkeit: Primär vs. Subagent', + 'settings.agents.page.field.overrideModel': 'Modell überschreiben', + 'settings.agents.page.field.temperature': 'Temperatur', + 'settings.agents.page.field.temperatureTooltip': 'Steuert die Zufälligkeit. Höher = kreativ, Niedriger = fokussiert.', + 'settings.agents.page.field.temperatureRange': '0,0 bis 2,0', + 'settings.agents.page.field.clearTemperatureAria': 'Temperatur-Überschreibung zurücksetzen', + 'settings.agents.page.field.topP': 'Top P', + 'settings.agents.page.field.topPTooltip': 'Nucleus-Sampling-Vielfalt. Niedriger = nur wahrscheinliche Token.', + 'settings.agents.page.field.topPRange': '0,0 bis 1,0', + 'settings.agents.page.field.clearTopPAria': 'Top-P-Überschreibung zurücksetzen', + 'settings.agents.page.field.variant': 'Denk-Variante', + 'settings.agents.page.field.variantTooltip': 'Steuert die Denk-/Reasoning-Tiefe für diesen Agent.', + 'settings.agents.page.field.variantHint': 'z. B. high, max, low', + 'settings.agents.page.field.variantPlaceholder': 'Standard', + 'settings.agents.page.field.systemPromptPlaceholder': 'Sie sind ein erfahrener Programmierassistent...', + 'settings.agents.page.mode.primary': 'Primär', + 'settings.agents.page.mode.subagent': 'Subagent', + 'settings.agents.page.mode.all': 'Alle', + 'settings.agents.page.permissions.defaultLabel': 'Standard', + 'settings.agents.page.permissions.hideEditor': 'Editor ausblenden', + 'settings.agents.page.permissions.advancedEditor': 'Erweiterter Editor', + 'settings.agents.page.permissions.globalSummary': 'Global: {summary}', + 'settings.agents.page.permissions.rulesSummary': 'Regeln: {summary}', + 'settings.agents.page.permissions.globalDefault': 'Globaler Standard', + 'settings.agents.page.permissions.pattern': 'Muster', + 'settings.agents.page.permissions.addCustomRule': 'Benutzerdefinierte Regel hinzufügen', + 'settings.agents.page.permissions.permissionPlaceholder': 'Berechtigung...', + 'settings.agents.page.permissions.patternPlaceholder': 'Muster (z. B. *)', + 'settings.page.behavior.title': 'Verhalten', + 'settings.behavior.page.title': 'Verhalten', + 'settings.behavior.page.warning.title': 'Globale Regeln werden mit Projektregeln kombiniert', + 'settings.behavior.page.warning.description': 'Hier vorgenommene Änderungen aktualisieren {path}. OpenCode berücksichtigt auch alle AGENTS.md-Regeln auf Projektebene, sofern vorhanden.', + 'settings.behavior.page.section.systemPrompt': 'Globale AGENTS.md', + 'settings.behavior.page.field.systemPromptPlaceholder': 'Sie sind ein hilfreicher KI-Assistent...\n\nVerwenden Sie diesen Bereich, um absolute Regeln für das Verhalten der KI über alle Sitzungen und Anbieter hinweg festzulegen.', + 'settings.behavior.page.section.responseStyle': 'Antwortstil', + 'settings.behavior.page.responseStyle.tooltip': 'Wenn aktiviert, steuern diese Anweisungen, wie der Assistent in jedem neuen Gespräch antwortet.', + 'settings.behavior.page.responseStyle.enableAria': 'Antwortstil-Anweisungen aktivieren', + 'settings.behavior.page.responseStyle.enable': 'Antwortstil-Anweisungen zu neuen Gesprächen hinzufügen', + 'settings.behavior.page.responseStyle.preset': 'Voreinstellung', + 'settings.behavior.page.responseStyle.option.concise': 'Prägnant', + 'settings.behavior.page.responseStyle.option.detailed': 'Detailliert', + 'settings.behavior.page.responseStyle.option.mentor': 'Mentor', + 'settings.behavior.page.responseStyle.option.pushback': 'Kritisch hinterfragen', + 'settings.behavior.page.responseStyle.option.noFiller': 'Ohne Fülltext', + 'settings.behavior.page.responseStyle.option.matchEnergy': 'Meinem Ton anpassen', + 'settings.behavior.page.responseStyle.option.warmPeer': 'Freundlich & kollegial', + 'settings.behavior.page.responseStyle.option.custom': 'Benutzerdefiniert', + 'settings.behavior.page.responseStyle.customPlaceholder': 'Schreiben Sie die Anweisung, die der ersten Nachricht hinzugefügt werden soll...', + 'settings.behavior.page.toast.saved': 'Verhalten erfolgreich gespeichert', + 'settings.behavior.page.toast.saveFailed': 'Verhalten konnte nicht gespeichert werden', + 'settings.commands.sidebar.title': 'Befehle', + 'settings.commands.sidebar.total': 'Gesamt {count}', + 'settings.commands.sidebar.empty.title': 'Keine Befehle konfiguriert', + 'settings.commands.sidebar.empty.description': 'Verwenden Sie die Schaltfläche + oben, um einen zu erstellen', + 'settings.commands.sidebar.section.builtIn': 'Integrierte Befehle', + 'settings.commands.sidebar.section.custom': 'Benutzerdefinierte Befehle', + 'settings.commands.sidebar.dialog.deleteTitle': 'Befehl löschen', + 'settings.commands.sidebar.dialog.resetTitle': 'Befehl zurücksetzen', + 'settings.commands.sidebar.dialog.deleteDescription': 'Möchten Sie den Befehl "{name}" wirklich löschen?', + 'settings.commands.sidebar.dialog.resetDescription': 'Möchten Sie den Befehl "{name}" wirklich auf seine Standardkonfiguration zurücksetzen?', + 'settings.commands.sidebar.renameDialog.title': 'Befehl umbenennen', + 'settings.commands.sidebar.renameDialog.description': 'Geben Sie einen neuen Namen für den Befehl "/{name}" ein', + 'settings.commands.sidebar.renameDialog.placeholder': 'Neuer Befehlsname...', + 'settings.commands.sidebar.toast.builtInCannotDelete': 'Integrierte Befehle können nicht gelöscht werden', + 'settings.commands.sidebar.toast.commandDeleted': 'Befehl "{name}" erfolgreich gelöscht', + 'settings.commands.sidebar.toast.commandReset': 'Befehl "{name}" auf Standard zurückgesetzt', + 'settings.commands.sidebar.toast.deleteFailed': 'Befehl konnte nicht gelöscht werden', + 'settings.commands.sidebar.toast.resetFailed': 'Befehl konnte nicht zurückgesetzt werden', + 'settings.commands.sidebar.toast.commandNameRequired': 'Befehlsname ist erforderlich', + 'settings.commands.sidebar.toast.commandExists': 'Ein Befehl mit diesem Namen existiert bereits', + 'settings.commands.sidebar.toast.removeOldAfterRenameFailed': 'Alter Befehl konnte nach Umbenennung nicht entfernt werden', + 'settings.commands.sidebar.toast.renameFailed': 'Befehl konnte nicht umbenannt werden', + 'settings.commands.page.toast.templateRequired': 'Befehlsvorlage ist erforderlich', + 'settings.commands.page.toast.created': 'Befehl erfolgreich erstellt', + 'settings.commands.page.toast.updated': 'Befehl erfolgreich aktualisiert', + 'settings.commands.page.toast.createFailed': 'Befehl konnte nicht erstellt werden', + 'settings.commands.page.toast.updateFailed': 'Befehl konnte nicht aktualisiert werden', + 'settings.commands.page.toast.saveUnexpectedError': 'Beim Speichern ist ein Fehler aufgetreten', + 'settings.commands.page.empty.title': 'Wählen Sie einen Befehl aus der Seitenleiste', + 'settings.commands.page.empty.description': 'oder erstellen Sie einen neuen', + 'settings.commands.page.title.new': 'Neuer Befehl', + 'settings.commands.page.subtitle.new': 'Neuen Slash-Befehl konfigurieren', + 'settings.commands.page.subtitle.edit': 'Befehlseinstellungen bearbeiten', + 'settings.commands.page.section.identity': 'Identität', + 'settings.commands.page.section.executionContext': 'Ausführungskontext', + 'settings.commands.page.section.template': 'Befehlsvorlage', + 'settings.commands.page.field.commandName': 'Befehlsname', + 'settings.commands.page.field.commandNamePlaceholder': 'befehlsname', + 'settings.commands.page.field.descriptionPlaceholder': 'Was macht dieser Befehl?', + 'settings.commands.page.field.overrideAgent': 'Agent überschreiben', + 'settings.commands.page.field.templatePlaceholder': 'Ihre Befehlsvorlage hier...', + 'settings.commands.page.templateHint.userInput': 'Benutzereingabe', + 'settings.commands.page.templateHint.shellOutput': 'Shell-Ausgabe', + 'settings.commands.page.templateHint.fileContents': 'Dateiinhalt', + 'settings.commands.agentSelector.title': 'Agent auswählen', + 'settings.commands.agentSelector.notSelected': 'Nicht ausgewählt', + 'settings.commands.agentSelector.selectAgentPlaceholder': 'Agent auswählen...', + 'settings.gitIdentities.page.section.title': 'Identitäten', + 'settings.gitIdentities.page.empty.title': 'Keine Identitäten konfiguriert', + 'settings.gitIdentities.page.empty.description': 'Erstellen Sie eine, um Git-Autoreneinstellungen pro Projekt zu verwalten', + 'settings.gitIdentities.page.discoveredCredentials.title': 'Gefunden in ~/.git-credentials', + 'settings.gitIdentities.page.badge.default': 'Standard', + 'settings.gitIdentities.page.actions.setAsDefault': 'Als Standard festlegen', + 'settings.gitIdentities.page.actions.unsetDefault': 'Standard aufheben', + 'settings.gitIdentities.page.actions.import': 'Importieren', + 'settings.gitIdentities.page.toast.updateDefaultFailed': 'Standard-Identität konnte nicht aktualisiert werden', + 'settings.gitIdentities.page.toast.defaultUpdated': 'Standard-Identität aktualisiert', + 'settings.gitIdentities.page.toast.defaultUnset': 'Standard-Identität aufgehoben', + 'settings.gitIdentities.page.toast.profileDeleted': 'Profil "{name}" gelöscht', + 'settings.gitIdentities.page.toast.deleteProfileFailed': 'Profil konnte nicht gelöscht werden', + 'settings.gitIdentities.page.deleteDialog.title': 'Profil löschen', + 'settings.gitIdentities.page.deleteDialog.description': 'Möchten Sie "{name}" wirklich löschen?', + 'settings.gitIdentities.editor.title.importCredential': 'Anmeldedaten importieren', + 'settings.gitIdentities.editor.title.newIdentity': 'Neue Identität', + 'settings.gitIdentities.editor.title.globalIdentity': 'Globale Identität', + 'settings.gitIdentities.editor.title.editIdentity': 'Identität bearbeiten', + 'settings.gitIdentities.editor.description.globalReadOnly': 'Systemweite Git-Identität (schreibgeschützt)', + 'settings.gitIdentities.editor.description.newProfile': 'Neues Git-Identitätsprofil erstellen', + 'settings.gitIdentities.editor.description.editProfile': 'Identitätsprofil-Einstellungen bearbeiten', + 'settings.gitIdentities.editor.section.commitSigning': 'Commit-Signierung', + 'settings.gitIdentities.editor.field.profileName': 'Profilname', + 'settings.gitIdentities.editor.field.profileNamePlaceholder': 'Arbeitsprofil, Privat usw.', + 'settings.gitIdentities.editor.field.color': 'Farbe', + 'settings.gitIdentities.editor.field.icon': 'Icon', + 'settings.gitIdentities.editor.field.userName': 'Benutzername', + 'settings.gitIdentities.editor.field.userNameTooltip': 'Der Name, der in Git-Commit-Nachrichten angezeigt wird.', + 'settings.gitIdentities.editor.field.userNamePlaceholder': 'Max Mustermann', + 'settings.gitIdentities.editor.field.emailAddress': 'E-Mail-Adresse', + 'settings.gitIdentities.editor.field.emailAddressTooltip': 'Sollte Ihrer E-Mail-Adresse in GitHub/GitLab entsprechen.', + 'settings.gitIdentities.editor.field.emailAddressPlaceholder': 'max@beispiel.de', + 'settings.gitIdentities.editor.field.authMethod': 'Authentifizierungsmethode', + 'settings.gitIdentities.editor.field.authToken': 'Token', + 'settings.gitIdentities.editor.field.sshKeyPath': 'SSH-Schlüsselpfad', + 'settings.gitIdentities.editor.field.sshKeyPathTooltip': 'Optionaler Pfad zum privaten Schlüssel. Z. B. ~/.ssh/id_ed25519', + 'settings.gitIdentities.editor.field.sshKeyPathPlaceholder': '~/.ssh/id_ed25519', + 'settings.gitIdentities.editor.field.signCommits': 'Commits mit dieser Identität signieren', + 'settings.gitIdentities.editor.field.signingKey': 'Signierschlüssel', + 'settings.gitIdentities.editor.field.signingKeyPlaceholder': '~/.ssh/id_ed25519.pub', + 'settings.gitIdentities.editor.field.host': 'Host', + 'settings.gitIdentities.editor.field.hostTooltip': 'Das Token wird für diesen Host aus ~/.git-credentials gelesen.', + 'settings.gitIdentities.editor.field.hostPlaceholder': 'github.com', + 'settings.gitIdentities.editor.actions.close': 'Schließen', + 'settings.gitIdentities.editor.actions.create': 'Erstellen', + 'settings.gitIdentities.editor.actions.save': 'Speichern', + 'settings.gitIdentities.editor.toast.userNameEmailRequired': 'Benutzername und E-Mail sind erforderlich', + 'settings.gitIdentities.editor.toast.hostRequiredForToken': 'Host ist für tokenbasierte Authentifizierung erforderlich', + 'settings.gitIdentities.editor.toast.signingKeyRequired': 'Signierschlüssel ist erforderlich, wenn die Commit-Signierung aktiviert ist', + 'settings.gitIdentities.editor.toast.profileCreated': 'Profil erstellt', + 'settings.gitIdentities.editor.toast.profileUpdated': 'Profil aktualisiert', + 'settings.gitIdentities.editor.toast.createProfileFailed': 'Profil konnte nicht erstellt werden', + 'settings.gitIdentities.editor.toast.updateProfileFailed': 'Profil konnte nicht aktualisiert werden', + 'settings.gitIdentities.editor.toast.saveUnexpectedError': 'Beim Speichern ist ein Fehler aufgetreten', + 'settings.gitIdentities.editor.toast.profileDeleted': 'Profil gelöscht', + 'settings.gitIdentities.editor.toast.deleteProfileFailed': 'Profil konnte nicht gelöscht werden', + 'settings.gitIdentities.editor.toast.deleteUnexpectedError': 'Beim Löschen ist ein Fehler aufgetreten', + 'settings.skills.sidebar.title': 'Skills', + 'settings.skills.sidebar.total': 'Gesamt {count}', + 'settings.skills.sidebar.section.project': 'Projekt-Skills', + 'settings.skills.sidebar.section.user': 'Benutzer-Skills', + 'settings.skills.sidebar.empty.title': 'Keine Skills konfiguriert', + 'settings.skills.sidebar.empty.description': 'Verwenden Sie die Schaltfläche + oben, um einen zu erstellen', + 'settings.skills.sidebar.badge.claude': 'claude', + 'settings.skills.sidebar.badge.agents': 'agents', + 'settings.skills.sidebar.badge.opencode': 'opencode', + 'settings.skills.sidebar.toast.skillDeleted': 'Skill "{name}" erfolgreich gelöscht', + 'settings.skills.sidebar.toast.deleteSkillFailed': 'Skill konnte nicht gelöscht werden', + 'settings.skills.sidebar.toast.duplicateLoadFailed': 'Skill-Details für Duplizierung konnten nicht geladen werden', + 'settings.skills.sidebar.toast.renameLoadFailed': 'Skill-Details konnten nicht geladen werden', + 'settings.skills.sidebar.toast.removeOldAfterRenameFailed': 'Alter Skill konnte nach Umbenennung nicht entfernt werden', + 'settings.skills.sidebar.toast.renameFailed': 'Skill konnte nicht umbenannt werden', + 'settings.skills.sidebar.deleteDialog.title': 'Skill löschen', + 'settings.skills.sidebar.deleteDialog.description': 'Möchten Sie den Skill "{name}" wirklich löschen?', + 'settings.skills.sidebar.renameDialog.title': 'Skill umbenennen', + 'settings.skills.sidebar.renameDialog.description': 'Geben Sie einen neuen Namen für den Skill "{name}" ein', + 'settings.skills.sidebar.renameDialog.placeholder': 'Neuer Skill-Name...', + 'settings.skills.page.title.newSkill': 'Neuer Skill', + 'settings.skills.page.subtitle.newSkill': 'Neuen Skill konfigurieren', + 'settings.skills.page.subtitle.skillLocation': '{location} Skill', + 'settings.skills.page.section.basicInformation': 'Grundlegende Informationen', + 'settings.skills.page.section.instructions': 'Anweisungen', + 'settings.skills.page.section.supportingFiles': 'Unterstützende Dateien', + 'settings.skills.page.field.skillNameLocation': 'Skill-Name & Speicherort', + 'settings.skills.page.field.skillNameHint': 'Kleinbuchstaben, Zahlen, Bindestriche', + 'settings.skills.page.field.skillNamePlaceholder': 'skill-name', + 'settings.skills.page.field.descriptionHint': 'Der Agent verwendet dies, um zu entscheiden, wann der Skill geladen wird', + 'settings.skills.page.field.descriptionPlaceholder': 'Kurze Beschreibung, was dieser Skill tut...', + 'settings.skills.page.field.instructionsPlaceholder': 'Schritt-für-Schritt-Anweisungen, Richtlinien oder Referenzinhalte...', + 'settings.skills.page.badge.claudeCompatible': 'Claude-kompatibel', + 'settings.skills.page.badge.pending': 'Ausstehend', + 'settings.skills.page.actions.addFile': 'Datei hinzufügen', + 'settings.skills.page.actions.createSkill': 'Skill erstellen', + 'settings.skills.page.actions.createFile': 'Datei erstellen', + 'settings.skills.page.supportingFiles.empty': 'Keine unterstützenden Dateien. Verwenden Sie "Datei hinzufügen", um Referenzmaterialien einzubinden.', + 'settings.skills.page.loading.details': 'Skill-Details werden geladen...', + 'settings.skills.page.loading.fileContent': 'Dateiinhalt wird geladen...', + 'settings.skills.page.empty.title': 'Wählen Sie einen Skill aus der Seitenleiste', + 'settings.skills.page.empty.description': 'oder erstellen Sie einen neuen', + 'settings.skills.page.deleteFileDialog.title': 'Unterstützende Datei löschen', + 'settings.skills.page.deleteFileDialog.description': 'Möchten Sie "{path}" wirklich löschen?', + 'settings.skills.page.fileDialog.titleEdit': 'Unterstützende Datei bearbeiten', + 'settings.skills.page.fileDialog.titleAdd': 'Unterstützende Datei hinzufügen', + 'settings.skills.page.fileDialog.descriptionEdit': 'Dateiinhalt bearbeiten', + 'settings.skills.page.fileDialog.descriptionAdd': 'Neue Datei im Skill-Verzeichnis erstellen', + 'settings.skills.page.fileDialog.field.filePath': 'Dateipfad', + 'settings.skills.page.fileDialog.field.filePathPlaceholder': 'beispiel.md oder docs/referenz.txt', + 'settings.skills.page.fileDialog.field.filePathHint': 'Relativer Pfad innerhalb des Skill-Verzeichnisses. Unterverzeichnisse werden automatisch erstellt.', + 'settings.skills.page.fileDialog.field.content': 'Inhalt', + 'settings.skills.page.fileDialog.field.contentPlaceholder': 'Dateiinhalt...', + 'settings.skills.page.toast.skillNameRequired': 'Skill-Name ist erforderlich', + 'settings.skills.page.toast.invalidSkillName': 'Der Skill-Name muss 1–64 alphanumerische Kleinbuchstaben mit Bindestrichen enthalten', + 'settings.skills.page.toast.descriptionRequired': 'Beschreibung ist erforderlich', + 'settings.skills.page.toast.skillExists': 'Ein Skill mit diesem Namen existiert bereits', + 'settings.skills.page.toast.skillCreated': 'Skill erfolgreich erstellt', + 'settings.skills.page.toast.skillUpdated': 'Skill erfolgreich aktualisiert', + 'settings.skills.page.toast.createSkillFailed': 'Skill konnte nicht erstellt werden', + 'settings.skills.page.toast.updateSkillFailed': 'Skill konnte nicht aktualisiert werden', + 'settings.skills.page.toast.saveUnexpectedError': 'Beim Speichern ist ein Fehler aufgetreten', + 'settings.skills.page.toast.loadFileContentFailed': 'Dateiinhalt konnte nicht geladen werden', + 'settings.skills.page.toast.fileNameRequired': 'Dateiname ist erforderlich', + 'settings.skills.page.toast.fileExists': 'Eine Datei mit diesem Namen existiert bereits', + 'settings.skills.page.toast.noSkillSelected': 'Kein Skill ausgewählt', + 'settings.skills.page.toast.fileAdded': 'Datei "{path}" hinzugefügt', + 'settings.skills.page.toast.fileCreated': 'Datei "{path}" erstellt', + 'settings.skills.page.toast.fileUpdated': 'Datei "{path}" aktualisiert', + 'settings.skills.page.toast.fileRemoved': 'Datei "{path}" entfernt', + 'settings.skills.page.toast.fileDeleted': 'Datei "{path}" gelöscht', + 'settings.skills.page.toast.createFileFailed': 'Datei konnte nicht erstellt werden', + 'settings.skills.page.toast.updateFileFailed': 'Datei konnte nicht aktualisiert werden', + 'settings.skills.page.toast.deleteFileFailed': 'Datei konnte nicht gelöscht werden', + 'settings.skills.location.option.userOpencode.label': 'Benutzer / OpenCode', + 'settings.skills.location.option.userOpencode.description': 'Globaler OpenCode-Konfigurationsspeicherort', + 'settings.skills.location.option.projectOpencode.label': 'Projekt / OpenCode', + 'settings.skills.location.option.projectOpencode.description': 'Aktuelles Projekt .opencode-Speicherort', + 'settings.skills.location.option.userClaude.label': 'Benutzer / Claude', + 'settings.skills.location.option.userClaude.description': 'Globaler Claude Skills-Speicherort', + 'settings.skills.location.option.projectClaude.label': 'Projekt / Claude', + 'settings.skills.location.option.projectClaude.description': 'Aktuelles Projekt .claude-Speicherort', + 'settings.skills.location.option.userAgents.label': 'Benutzer / Agents', + 'settings.skills.location.option.userAgents.description': 'Globaler .agents-Kompatibilitätsspeicherort', + 'settings.skills.location.option.projectAgents.label': 'Projekt / Agents', + 'settings.skills.location.option.projectAgents.description': 'Aktuelles Projekt .agents-Kompatibilitätsspeicherort', + 'settings.skills.catalog.shared.field.repository': 'Repository', + 'settings.skills.catalog.shared.field.repositoryPlaceholder': 'owner/repo oder git@github.com:owner/repo.git', + 'settings.skills.catalog.shared.field.optionalSubpath': 'Optionaler Unterpfad', + 'settings.skills.catalog.shared.field.subpathPlaceholder': 'z. B. skills', + 'settings.skills.catalog.shared.field.targetLocation': 'Zielspeicherort', + 'settings.skills.catalog.shared.field.project': 'Projekt', + 'settings.skills.catalog.shared.field.noProjects': 'Keine Projekte verfügbar', + 'settings.skills.catalog.shared.field.chooseProjectPlaceholder': 'Projekt auswählen', + 'settings.skills.catalog.shared.field.searchSkillsPlaceholder': 'Skills suchen…', + 'settings.skills.catalog.shared.toast.repositoryRequired': 'Repository-Quelle ist erforderlich', + 'settings.skills.catalog.shared.toast.privateRepoNotSupportedVsCode': 'Private Repositories werden in VS Code noch nicht unterstützt', + 'settings.skills.catalog.shared.toast.foundSkills': '{count} Skill(s) gefunden', + 'settings.skills.catalog.shared.noDescription': 'Keine Beschreibung angegeben', + 'settings.skills.catalog.shared.actions.scan': 'Scannen', + 'settings.skills.catalog.shared.actions.scanning': 'Wird gescannt...', + 'settings.skills.catalog.shared.actions.install': 'Installieren', + 'settings.skills.catalog.shared.actions.installing': 'Wird installiert...', + 'settings.skills.catalog.shared.auth.title': 'Authentifizierung erforderlich', + 'settings.skills.catalog.shared.auth.description': 'Git-Identität auswählen (SSH-Schlüssel)', + 'settings.skills.catalog.shared.auth.chooseIdentity': 'Identität auswählen', + 'settings.skills.catalog.shared.auth.footerHint': 'Konfigurieren Sie Identitäten in Einstellungen - Git-Identitäten.', + 'settings.skills.catalog.shared.auth.footerHintArrow': 'Konfigurieren Sie Identitäten in Einstellungen → Git-Identitäten.', + 'settings.skills.catalog.conflicts.title': 'Fähigkeiten existieren bereits', + 'settings.skills.catalog.conflicts.description': 'Einige ausgewählte Fähigkeiten sind bereits in diesem Bereich installiert. Wählen Sie aus, ob sie übersprungen oder überschrieben werden sollen.', + 'settings.skills.catalog.conflicts.count': '{count} Konflikt(e)', + 'settings.skills.catalog.conflicts.installedIn': 'Installiert in {scope} / {source}', + 'settings.skills.catalog.conflicts.scope.user': 'Benutzer', + 'settings.skills.catalog.conflicts.scope.project': 'Projekt', + 'settings.skills.catalog.conflicts.source.opencode': 'opencode', + 'settings.skills.catalog.conflicts.source.agents': 'Agents', + 'settings.skills.catalog.conflicts.decision.skip': 'Überspringen', + 'settings.skills.catalog.conflicts.decision.overwrite': 'Überschreiben', + 'settings.skills.catalog.conflicts.actions.skipAll': 'Alle überspringen', + 'settings.skills.catalog.conflicts.actions.overwriteAll': 'Alle überschreiben', + 'settings.skills.catalog.conflicts.actions.continue': 'Weiter', + 'settings.skills.catalog.installSkill.title': 'Fähigkeit installieren', + 'settings.skills.catalog.installSkill.descriptionPrefix': 'Installiere', + 'settings.skills.catalog.installSkill.descriptionSuffix': 'in eine der vier Zielorte.', + 'settings.skills.catalog.installSkill.field.destination': 'Zielort', + 'settings.skills.catalog.installSkill.field.project': 'Projekt', + 'settings.skills.catalog.installSkill.field.noProjects': 'Keine Projekte verfügbar', + 'settings.skills.catalog.installSkill.field.chooseProjectPlaceholder': 'Projekt auswählen', + 'settings.skills.catalog.installSkill.actions.install': 'Installieren', + 'settings.skills.catalog.installSkill.actions.installing': 'Wird installiert...', + 'settings.skills.catalog.installSkill.toast.installed': 'Fertigkeit erfolgreich installiert', + 'settings.skills.catalog.installSkill.toast.authRequired': 'Authentifizierung erforderlich', + 'settings.skills.catalog.installSkill.toast.installFailed': 'Fertigkeit konnte nicht installiert werden', + 'settings.skills.catalog.add.title': 'Fertigkeiten-Katalog hinzufügen', + 'settings.skills.catalog.add.descriptionPrefix': 'Füge ein Git-Repository als neue Katalogquelle hinzu. OpenChamber durchsucht es nach Ordnern, die folgende enthalten', + 'settings.skills.catalog.add.descriptionSuffix': '.', + 'settings.skills.catalog.add.field.catalogName': 'Katalogname', + 'settings.skills.catalog.add.field.catalogNamePlaceholder': 'z. B. Team-Fertigkeiten', + 'settings.skills.catalog.add.field.repository': 'Repository', + 'settings.skills.catalog.add.field.optionalSubpath': 'Optionaler Unterpfad', + 'settings.skills.catalog.add.field.repositoryHint': 'Öffentliche Repos funktionieren überall. Private Repos benötigen SSH-Identität (Desktop/Web only).', + 'settings.skills.catalog.add.scanResult': 'Scan-Ergebnis: {count} Fertigkeit(en) gefunden', + 'settings.skills.catalog.add.duplicateMessage': 'Dieser Katalog wurde bereits hinzugefügt.', + 'settings.skills.catalog.add.actions.addCatalog': 'Katalog hinzufügen', + 'settings.skills.catalog.add.toast.repositoryRequired': 'Repository-Quelle ist erforderlich', + 'settings.skills.catalog.add.toast.authenticationRequiredScan': 'Authentifizierung erforderlich. Wähle eine Git-Identität aus und scanne erneut.', + 'settings.skills.catalog.add.toast.scanFailed': 'Repository konnte nicht gescannt werden', + 'settings.skills.catalog.add.toast.noSkillsFound': 'Keine Fertigkeiten in diesem Repository gefunden', + 'settings.skills.catalog.add.toast.catalogNameRequired': 'Katalogname ist erforderlich', + 'settings.skills.catalog.add.toast.scanBeforeAdd': 'Scanne das Repository, bevor dieser Katalog hinzugefügt wird', + 'settings.skills.catalog.add.toast.catalogAlreadyExists': 'Dieser Katalog existiert bereits', + 'settings.skills.catalog.add.toast.catalogAdded': 'Katalog hinzugefügt', + 'settings.skills.catalog.add.toast.saveFailed': 'Katalog konnte nicht gespeichert werden', + 'settings.skills.catalog.installFromRepo.title': 'Aus Git-Repository installieren', + 'settings.skills.catalog.installFromRepo.descriptionPrefix': 'Durchsuche ein Repository nach Ordnern, die folgende enthalten', + 'settings.skills.catalog.installFromRepo.descriptionSuffix': ', dann ausgewählte Fähigkeiten installieren.', + 'settings.skills.catalog.installFromRepo.repositoryHintPrefix': 'Für GitHub-Kurzform können Sie einen Unterpfad wie', + 'settings.skills.catalog.installFromRepo.authDescription': 'Wählen Sie eine Git-Identität (SSH-Schlüssel) aus, die auf dieses Repository zugreifen kann.', + 'settings.skills.catalog.installFromRepo.empty.noScanResultsTitle': 'Noch keine Scan-Ergebnisse', + 'settings.skills.catalog.installFromRepo.empty.noScanResultsDescription': 'Scannen Sie ein Repository, um Fähigkeiten zu entdecken', + 'settings.skills.catalog.installFromRepo.actions.selectAll': 'Alle auswählen', + 'settings.skills.catalog.installFromRepo.actions.selectNone': 'Nichts auswählen', + 'settings.skills.catalog.installFromRepo.actions.installSelected': 'Ausgewählte installieren', + 'settings.skills.catalog.installFromRepo.badge.installed': 'installiert ({scope}/{source})', + 'settings.skills.catalog.installFromRepo.selectedCount': 'Ausgewählt: {selected} / {total}', + 'settings.skills.catalog.installFromRepo.toast.authenticationRequiredScan': 'Authentifizierung erforderlich. Wählen Sie eine Git-Identität und versuchen Sie erneut zu scannen.', + 'settings.skills.catalog.installFromRepo.toast.authenticationRequiredInstall': 'Authentifizierung erforderlich. Wählen Sie eine Git-Identität und versuchen Sie erneut zu installieren.', + 'settings.skills.catalog.installFromRepo.toast.scanFailed': 'Fehler beim Scannen des Repositorys', + 'settings.skills.catalog.installFromRepo.toast.selectAtLeastOne': 'Wählen Sie mindestens eine Fähigkeit zur Installation aus', + 'settings.skills.catalog.installFromRepo.toast.installedCount': '{count} Fähigkeit(en) installiert', + 'settings.skills.catalog.installFromRepo.toast.installCompleted': 'Installation abgeschlossen', + 'settings.skills.catalog.installFromRepo.toast.installFailed': 'Fehler beim Installieren der Fähigkeiten', + 'settings.skills.catalog.page.mode.manual': 'Manuell', + 'settings.skills.catalog.page.mode.external': 'Extern', + 'settings.skills.catalog.page.title': 'Fähigkeitskatalog', + 'settings.skills.catalog.page.section.sourceRepository': 'Quell-Repository', + 'settings.skills.catalog.page.field.selectSourcePlaceholder': 'Quelle auswählen', + 'settings.skills.catalog.page.actions.refreshTitle': 'Aktualisieren', + 'settings.skills.catalog.page.actions.removeCatalogTitle': 'Katalog entfernen', + 'settings.skills.catalog.page.actions.addCatalog': 'Katalog hinzufügen', + 'settings.skills.catalog.page.actions.removeCatalog': 'Katalog entfernen', + 'settings.skills.catalog.page.actions.loadMoreSkills': 'Weitere Fähigkeiten laden', + 'settings.skills.catalog.page.loading.catalog': 'Wird geladen...', + 'settings.skills.catalog.page.loading.skills': 'Fähigkeiten werden geladen...', + 'settings.skills.catalog.page.loading.more': 'Wird geladen...', + 'settings.skills.catalog.page.foundCount': '{count} Fähigkeit(en) gefunden', + 'settings.skills.catalog.page.error.catalogTitle': 'Katalogfehler', + 'settings.skills.catalog.page.empty.noSkillsTitle': 'Keine Fähigkeiten gefunden', + 'settings.skills.catalog.page.empty.noSkillsDescription': 'Versuchen Sie eine andere Suche oder aktualisieren Sie den Katalog', + 'settings.skills.catalog.page.badge.installed': 'installiert ({scope})', + 'settings.skills.catalog.page.badge.notInstallable': 'nicht installierbar', + 'settings.skills.catalog.page.badge.unknown': 'unbekannt', + 'settings.skills.catalog.page.byOwnerPrefix': 'von', + 'settings.skills.catalog.page.removeDialog.title': 'Katalog entfernen', + 'settings.skills.catalog.page.removeDialog.description': 'Sind Sie sicher, dass Sie diesen Katalog entfernen möchten?', + 'settings.openchamber.passkeys.title': 'Passkeys', + 'settings.openchamber.passkeys.field.currentDevice': 'Aktuelles Gerät', + 'settings.openchamber.passkeys.actions.add': 'Passkey hinzufügen', + 'settings.openchamber.passkeys.actions.cancelSetup': 'Passkey-Einrichtung abbrechen', + 'settings.openchamber.passkeys.actions.signOutEverywhere': 'Überall abmelden', + 'settings.openchamber.passkeys.actions.signingOut': 'Abmeldung läuft…', + 'settings.openchamber.passkeys.actions.removing': 'Entfernung läuft…', + 'settings.openchamber.passkeys.state.uiPasswordRequired': 'Passkeys sind nur verfügbar, wenn die UI-Passwortsperre aktiviert ist.', + 'settings.openchamber.passkeys.state.loading': 'Passkeys werden geladen...', + 'settings.openchamber.passkeys.state.noneSaved': 'Für diesen Host wurden noch keine Passkeys gespeichert.', + 'settings.openchamber.passkeys.item.lastUsed': 'Zuletzt verwendet {time}', + 'settings.openchamber.passkeys.item.added': 'Hinzugefügt {time}', + 'settings.openchamber.passkeys.time.neverUsed': 'Nie verwendet', + 'settings.openchamber.passkeys.toast.loadFailed': 'Konnte Passkeys nicht laden.', + 'settings.openchamber.passkeys.toast.enableUiPasswordFirst': 'Aktiviere zuerst die UI-Passwortsperre, bevor du Passkeys hinzufügst.', + 'settings.openchamber.passkeys.toast.added': 'Passkey hinzugefügt', + 'settings.openchamber.passkeys.toast.setupCanceled': 'Passkey-Einrichtung abgebrochen', + 'settings.openchamber.passkeys.toast.addFailed': 'Konnte Passkey nicht hinzufügen.', + 'settings.openchamber.passkeys.toast.removed': 'Passkey entfernt', + 'settings.openchamber.passkeys.toast.removeFailed': 'Konnte Passkey nicht entfernen.', + 'settings.openchamber.passkeys.toast.clearAuthFailed': 'Konnte gespeicherte Authentifizierung nicht löschen.', + 'settings.openchamber.sessionRetention.title': 'Sitzungsaufbewahrung', + 'settings.openchamber.sessionRetention.tooltip': 'Automatisches Archivieren oder Löschen inaktiver Sitzungen basierend auf letzter Aktivität. Behält die 5 neuesten Sitzungen bei.', + 'settings.openchamber.sessionRetention.field.enableAutoCleanupAria': 'Automatische Bereinigung aktivieren', + 'settings.openchamber.sessionRetention.field.enableAutoCleanup': 'Automatische Bereinigung aktivieren', + 'settings.openchamber.sessionRetention.field.retentionPeriod': 'Aufbewahrungszeitraum', + 'settings.openchamber.sessionRetention.field.retentionPeriodAria': 'Aufbewahrungszeitraum in Tagen', + 'settings.openchamber.sessionRetention.field.days': 'Tage', + 'settings.openchamber.sessionRetention.field.whenSessionsExpire': 'Wenn Sitzungen ablaufen', + 'settings.openchamber.sessionRetention.actions.resetRetentionAria': 'Aufbewahrungszeitraum zurücksetzen', + 'settings.openchamber.sessionRetention.actions.runCleanupNow': 'Bereinigung jetzt ausführen', + 'settings.openchamber.sessionRetention.actions.cleaningUp': 'Bereinigung läuft...', + 'settings.openchamber.sessionRetention.action.archive': 'Archivieren', + 'settings.openchamber.sessionRetention.action.delete': 'Löschen', + 'settings.openchamber.sessionRetention.manualCleanup.title': 'Manuelle Bereinigung', + 'settings.openchamber.sessionRetention.manualCleanup.eligibleArchiveNow': 'Verfügbar für Archivierung jetzt: {count}', + 'settings.openchamber.sessionRetention.manualCleanup.eligibleDeleteNow': 'Verfügbar für Löschung jetzt: {count}', + 'settings.openchamber.sessionRetention.toast.noneEligibleArchive': 'Keine Sitzungen zur Archivierung verfügbar', + 'settings.openchamber.sessionRetention.toast.noneEligibleDelete': 'Keine Sitzungen zur Löschung verfügbar', + 'settings.openchamber.sessionRetention.toast.archivedCount': '{count} Sitzung(en) archiviert', + 'settings.openchamber.sessionRetention.toast.deletedCount': '{count} Sitzung(en) gelöscht', + 'settings.openchamber.sessionRetention.toast.failedArchiveCount': 'Fehler beim Archivieren von {count} Sitzung(en)', + 'settings.openchamber.sessionRetention.toast.failedDeleteCount': 'Fehler beim Löschen von {count} Sitzung(en)', + 'settings.openchamber.desktopNetwork.title': 'Desktop-Netzwerkzugriff', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Position der Fenstersteuerung', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': 'Wählen Sie aus, wo die Schaltflächen zum Minimieren, Maximieren und Schließen angezeigt werden. Auto folgt Ihrem Betriebssystem.', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Position der Fenstersteuerung', + 'settings.openchamber.desktopNetwork.option.windowControlsLeft': 'Links', + 'settings.openchamber.desktopNetwork.option.windowControlsRight': 'Rechts', + 'settings.openchamber.desktopNetwork.field.launchAtLoginAria': 'OpenChamber bei Anmeldung starten', + 'settings.openchamber.desktopNetwork.field.launchAtLogin': 'OpenChamber beim Einloggen starten', + 'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'Startet die App im Hintergrund ohne Öffnen eines Fensters. Verwenden Sie das Desktop-Statussymbol, um sie zu öffnen.', + 'settings.openchamber.desktopNetwork.field.minimizeToTrayAria': 'OpenChamber in die Systemleiste minimieren und schließen', + 'settings.openchamber.desktopNetwork.field.minimizeToTray': 'In die Systemleiste minimieren und schließen', + 'settings.openchamber.desktopNetwork.field.minimizeToTrayDescription': 'Lässt OpenChamber im Windows-Systemtray laufen, wenn das Hauptfenster minimiert oder geschlossen wird.', + 'settings.openchamber.desktopNetwork.field.keepAwakeAria': 'Computer wach halten, während OpenChamber läuft', + 'settings.openchamber.desktopNetwork.field.keepAwake': 'Computer wach halten, während OpenChamber läuft', + 'settings.openchamber.desktopNetwork.field.keepAwakeDescription': 'Verhindert den Ruhezustand des Systems, damit Geräte diese App erreichen können. Der Bildschirm kann weiterhin ausgeschaltet werden.', + 'settings.openchamber.desktopNetwork.field.allowLanAccessAria': 'LAN-Zugriff auf die Desktop-Seite erlauben', + 'settings.openchamber.desktopNetwork.field.allowLanAccess': 'Lassen Sie andere Geräte in Ihrem lokalen Netzwerk diese App öffnen', + 'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': 'Startet die App neu, damit Smartphones, Tablets und andere Computer in Ihrem WLAN sie öffnen können.', + 'settings.openchamber.desktopNetwork.field.warning': 'Warnung: Solange aktiviert, ist die App für jeden im selben lokalen Netzwerk erreichbar.', + 'settings.openchamber.desktopNetwork.field.passwordRequiredWarning': 'LAN-Zugriff erfordert ein Desktop-UI-Kennwort. Bis ein Kennwort festgelegt ist, startet die Desktop-App nur lokal.', + 'settings.openchamber.desktopPassword.field.password': 'Desktop-UI-Kennwort', + 'settings.openchamber.desktopPassword.field.passwordPlaceholder': 'Kein Kennwort erforderlich', + 'settings.openchamber.desktopPassword.field.passwordDescription': 'OpenChamber fragt nach dem Neustart und dann, wenn die Anmeldesitzung abläuft: nach 12 Stunden oder 7 Tagen mit Vertrauen in dieses Gerät. Leer lassen, um die Anmeldung zu deaktivieren.', + 'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Nach dem Neustart von einem anderen Gerät aus öffnen: ', + 'settings.openchamber.desktopNetwork.hint.openNow': 'Von einem anderen Gerät aus öffnen: ', + 'settings.openchamber.desktopNetwork.actions.saveAndRestart': 'Speichern + Neustarten', + 'settings.openchamber.desktopNetwork.error.loadFailed': 'Fehler beim Laden der Desktop-Einstellungen', + 'settings.openchamber.desktopNetwork.error.saveFailed': 'Fehler beim Speichern der Desktop-Einstellungen', + 'settings.openchamber.desktopNetwork.error.savedRestartFailed': 'Gespeichert, aber Fehler beim Neustart der App', + 'settings.openchamber.desktopNetwork.error.launchAtLoginUnsupported': 'Starten bei Anmeldung wird auf diesem System nicht unterstützt', + 'settings.openchamber.desktopNetwork.error.launchAtLoginSaveFailed': 'Fehler beim Aktualisieren der Einstellung für Start bei Anmeldung', + 'settings.openchamber.desktopNetwork.error.minimizeToTrayUnsupported': 'Hintergrundmodus der Systemleiste wird auf diesem System nicht unterstützt', + 'settings.openchamber.desktopNetwork.error.minimizeToTraySaveFailed': 'Fehler beim Aktualisieren der Einstellung für Systemleiste', + 'settings.openchamber.desktopNetwork.error.keepAwakeUnsupported': 'Verhindern des Schlafens wird auf diesem System nicht unterstützt', + 'settings.openchamber.desktopNetwork.error.keepAwakeSaveFailed': 'Fehler beim Aktualisieren der Einstellung für Wachbleiben', + 'settings.openchamber.opencodeCli.title': 'OpenCode CLI', + 'settings.openchamber.opencodeCli.tooltipPrefix': 'Optionaler absoluter Pfad zur', + 'settings.openchamber.opencodeCli.tooltipSuffix': 'Binary.', + 'settings.openchamber.opencodeCli.field.binaryPath': 'OpenCode Binary-Pfad', + 'settings.openchamber.opencodeCli.field.binaryPathPlaceholder': '/Users/you/.bun/bin/opencode', + 'settings.openchamber.opencodeCli.field.showUpdateNotifications': 'OpenCode-Update-Benachrichtigungen anzeigen', + 'settings.openchamber.opencodeCli.field.showUpdateNotificationsAria': 'OpenCode-Update-Benachrichtigungen anzeigen', + 'settings.openchamber.opencodeCli.actions.browseAria': 'Nach OpenCode-Binärpfad suchen', + 'settings.openchamber.opencodeCli.actions.browse': 'Durchsuchen', + 'settings.openchamber.opencodeCli.actions.saveAndReload': 'Speichern + Neuladen', + 'settings.openchamber.opencodeCli.actions.restartingOpenCode': 'OpenCode wird neu gestartet…', + 'settings.openchamber.opencodeCli.dialog.selectBinaryTitle': 'opencode-Binärdatei auswählen', + 'settings.openchamber.opencodeCli.tipPrefix': 'Tipp: Sie können auch', + 'settings.openchamber.opencodeCli.tipMiddle': 'Umgebungsvariable verwenden, aber diese Einstellung bleibt in', + 'settings.mcp.sidebar.title': 'MCP-Server', + 'settings.mcp.sidebar.total': 'Gesamt {count}', + 'settings.mcp.sidebar.actions.refreshStatusAria': 'MCP-Status aktualisieren', + 'settings.mcp.sidebar.actions.refreshStatusTitle': 'MCP-Status aktualisieren', + 'settings.mcp.sidebar.actions.addServerTitle': 'MCP-Server hinzufügen', + 'settings.mcp.sidebar.actions.deleting': 'Wird gelöscht…', + 'settings.mcp.sidebar.empty.title': 'Keine MCP-Server konfiguriert', + 'settings.mcp.sidebar.empty.description': 'Verwenden Sie die + Schaltfläche oben, um einen hinzuzufügen', + 'settings.mcp.sidebar.group.projectServers': 'Projektserver', + 'settings.mcp.sidebar.group.userServers': 'Benutzerserver', + 'settings.mcp.sidebar.serverType.localTitle': 'Lokaler Server', + 'settings.mcp.sidebar.serverType.remoteTitle': 'Remote-Server', + 'settings.mcp.sidebar.deleteDialog.title': 'MCP-Server löschen', + 'settings.mcp.sidebar.deleteDialog.descriptionPrefix': 'Sind Sie sicher, dass Sie "{name}" löschen möchten? Dadurch wird es aus', + 'settings.mcp.sidebar.toast.deleteFailed': 'Fehler beim Löschen des MCP-Servers', + 'settings.mcp.sidebar.toast.serverDeleted': 'MCP-Server "{name}" gelöscht', + 'settings.mcp.sidebar.toast.refreshListIfStale': 'Aktualisieren Sie die MCP-Liste, wenn die Benutzeroberfläche veraltet aussieht.', + 'settings.plugins.sidebar.title': 'Plugins', + 'settings.plugins.sidebar.total': 'Gesamt {count}', + 'settings.plugins.sidebar.actions.addTitle': 'Plugin hinzufügen', + 'settings.plugins.sidebar.actions.deleting': 'Wird gelöscht…', + 'settings.plugins.sidebar.empty.title': 'Keine Plugins konfiguriert', + 'settings.plugins.sidebar.empty.description': 'Verwenden Sie die + Schaltfläche oben, um eines hinzuzufügen', + 'settings.plugins.sidebar.group.userEntries': 'Benutzerkonfiguration', + 'settings.plugins.sidebar.group.userFiles': 'Benutzer-Plugin-Datei', + 'settings.plugins.sidebar.group.projectEntries': 'Projektkonfiguration', + 'settings.plugins.sidebar.group.projectFiles': 'Projekt-Plugin-Datei', + 'settings.plugins.sidebar.kind.npm': 'npm-Paket', + 'settings.plugins.sidebar.kind.path': 'Lokaler Pfad', + 'settings.plugins.sidebar.kind.file': 'Plugin-Datei', + 'settings.plugins.sidebar.deleteDialog.title': 'Plugin löschen', + 'settings.plugins.sidebar.deleteDialog.description': 'Sind Sie sicher, dass Sie „{name}“ löschen möchten?', + 'settings.plugins.sidebar.toast.deleted': 'Plugin „{name}“ gelöscht', + 'settings.plugins.sidebar.toast.deleteFailed': 'Fehler beim Löschen des Plugins', + 'settings.plugins.page.empty.select': 'Wählen Sie ein Plugin aus, um es anzuzeigen oder zu bearbeiten', + 'settings.plugins.page.empty.add': 'Oder klicken Sie auf +, um ein neues Plugin hinzuzufügen', + 'settings.plugins.page.header.entry': 'Installiertes Plugin', + 'settings.plugins.page.header.file': 'Plugin-Datei', + 'settings.plugins.page.field.spec': 'Spezifikation', + 'settings.plugins.page.field.spec.placeholder': 'npm-package@version oder /absolute/pfad', + 'settings.plugins.page.field.options': 'Optionen (JSON)', + 'settings.plugins.page.field.options.invalidJson': 'Ungültiges JSON', + 'settings.plugins.page.field.fileName': 'Dateiname', + 'settings.plugins.page.field.content': 'Inhalt', + 'settings.plugins.page.field.scope': 'Bereich', + 'settings.plugins.scope.user': 'Benutzer', + 'settings.plugins.scope.project': 'Projekt', + 'settings.plugins.page.action.save': 'Speichern', + 'settings.plugins.page.action.discard': 'Verwerfen', + 'settings.plugins.dialog.add.title': 'Plugin hinzufügen', + 'settings.plugins.dialog.add.tab.npm': 'Von npm', + 'settings.plugins.dialog.add.tab.path': 'Von lokalem Pfad', + 'settings.plugins.dialog.add.tab.file': 'Neue Datei', + 'settings.plugins.dialog.add.action.submit': 'Hinzufügen', + 'settings.plugins.dialog.add.action.cancel': 'Abbrechen', + 'settings.plugins.toast.created': 'Plugin hinzugefügt', + 'settings.plugins.toast.updated': 'Plugin aktualisiert', + 'settings.plugins.toast.reloadFailed': 'opencode Neuladen fehlgeschlagen — Neustart erforderlich', + 'settings.plugins.validation.fileName': 'Dateiname muss klein geschrieben sein und auf .js / .ts / .mjs / .cjs enden', + 'settings.plugins.validation.specRequired': 'Spezifikation ist erforderlich', + 'settings.plugins.registry.badge.update.label': '↑ {version}', + 'settings.plugins.registry.badge.update.tooltip': 'Aktualisierung verfügbar: {current} → {latest}', + 'settings.plugins.registry.badge.malformed.tooltip': 'Spezifikation ist fehlerhaft', + 'settings.plugins.registry.badge.missingPackage.tooltip': 'Paket {name} nicht auf npm gefunden', + 'settings.plugins.registry.badge.missingVersion.tooltip': 'Version {version} von {name} nicht veröffentlicht', + 'settings.plugins.registry.badge.pathMissing.tooltip': 'Datei nicht gefunden: {path}', + 'settings.plugins.registry.badge.pathUnreadable.tooltip': 'Datei nicht lesbar: {path}', + 'settings.plugins.registry.badge.network.tooltip': 'Konnte npm-Registry nicht erreichen', + 'settings.plugins.registry.banner.updateAvailable.title': 'Update verfügbar', + 'settings.plugins.registry.banner.updateAvailable.description': '{current} → {latest}', + 'settings.plugins.registry.banner.updateAvailable.action': 'Auf {latest} aktualisieren', + 'settings.plugins.registry.banner.invalid.title': 'Ungültiges Plugin', + 'settings.plugins.registry.banner.invalid.malformed': 'Spec-Syntax ist fehlerhaft', + 'settings.plugins.registry.banner.invalid.missingPackage': 'Paket nicht auf npm gefunden', + 'settings.plugins.registry.banner.invalid.missingVersion': 'Version nicht veröffentlicht', + 'settings.plugins.registry.banner.invalid.pathMissing': 'Datei existiert nicht', + 'settings.plugins.registry.banner.invalid.pathUnreadable': 'Datei ist nicht lesbar', + 'settings.plugins.sidebar.actions.updateToLatest': 'Auf neueste Version aktualisieren', + 'settings.plugins.sidebar.actions.refresh': 'Nach Updates suchen', + 'settings.plugins.sidebar.group.updatesAvailable_one': '{count} Update verfügbar', + 'settings.plugins.sidebar.group.updatesAvailable_other': '{count} Updates verfügbar', + 'settings.plugins.toast.updatedToLatest': 'Plugin auf {version} aktualisiert', + 'settings.plugins.toast.refreshing': 'Suche nach Updates in npm…', + 'settings.plugins.toast.refreshFailed': 'Konnte npm-Registry nicht prüfen', + 'settings.openchamber.keyboardShortcuts.title': 'Tastenkombinationen', + 'settings.openchamber.keyboardShortcuts.actions.resetAll': 'Alle zurücksetzen', + 'settings.openchamber.keyboardShortcuts.actions.overwrite': 'Überschreiben', + 'settings.openchamber.keyboardShortcuts.tooltip': 'Neue Tastenkombination aufnehmen, speichern und Bindings werden sofort aktualisiert.', + 'settings.openchamber.keyboardShortcuts.overwritePrompt': 'Diese Kombination wird bereits von einem anderen Shortcut verwendet. Überschreiben und die andere Zuordnung löschen?', + 'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Tasten drücken...', + 'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Verwende zuerst eine Tastenkombination.', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Diese Tastenkombination kann mit Standard-Tastenkombinationen des Browsers kollidieren. Sie wird dennoch gespeichert.', + 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Gehe zu Zeile (Datei-Editor)', + 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Befehlspalette öffnen', + 'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Eingabe fokussieren', + 'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Einstellungen öffnen', + 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Terminal-Dock umschalten', + 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal erweitert umschalten', + 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Seitenleiste umschalten', + 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte Seitenleiste umschalten', + 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen', + 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Datei-Tab der rechten Seitenleiste öffnen', + 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung', + 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf', + 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster', + 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Tastenkürzel öffnen', + 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Plan-Kontextpanel umschalten', + 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Dienstemenü umschalten', + 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Dienste-Tab durchschalten', + 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Thema wechseln', + 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent wechseln', + 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Favorites Modell vorwärts durchschalten', + 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': 'Favorites Modell rückwärts durchschalten', + 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'Modellauswahl öffnen', + 'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Eingabe erweitern', + 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Konversations-Zeitleiste öffnen', + 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Prompt-Navigator umschalten', + 'settings.projects.sidebar.total': 'Gesamt {count}', + 'settings.projects.sidebar.actions.addProject': 'Projekt hinzufügen', + 'settings.projects.page.empty.noProjects': 'Keine Projekte verfügbar.', + 'settings.projects.page.title.default': 'Projekt-Einstellungen', + 'settings.projects.page.section.worktree': 'Arbeitsbaum', + 'settings.projects.page.field.projectName': 'Projektname', + 'settings.projects.page.field.projectNamePlaceholder': 'Projektname', + 'settings.projects.page.field.defaultModel': 'Standardmodell für neue Chats', + 'settings.projects.page.field.defaultModelDescription': 'Wird verwendet, wenn ein neuer Chat in diesem Projekt gestartet wird. Fallback auf globale Standardeinstellungen, wenn nicht gesetzt.', + 'settings.projects.page.field.accentColor': 'Akzentfarbe', + 'settings.projects.page.field.projectIcon': 'Projekt-Icon', + 'settings.projects.page.field.projectIconBackgroundAria': 'Hintergrundfarbe des Projekt-Icons', + 'settings.projects.page.field.clearIconBackgroundAria': 'Hintergrund des Icons löschen', + 'settings.projects.page.field.clearBackground': 'Hintergrund löschen', + 'settings.projects.page.field.none': 'Keine', + 'settings.projects.page.field.preview': 'Vorschau', + 'settings.projects.page.actions.uploadIcon': 'Icon hochladen', + 'settings.projects.page.actions.uploading': 'Hochladen...', + 'settings.projects.page.actions.discoverFavicon': 'Favicon entdecken', + 'settings.projects.page.actions.discovering': 'Entdecken...', + 'settings.projects.page.actions.removeProjectIcon': 'Projekt-Icon entfernen', + 'settings.projects.page.actions.removing': 'Entfernen...', + 'settings.projects.page.actions.undoRemove': 'Entfernen rückgängig machen', + 'settings.projects.page.toast.uploadIconFailed': 'Fehler beim Hochladen des Projekt-Icons', + 'settings.projects.page.toast.iconUpdated': 'Projekt-Icon aktualisiert', + 'settings.projects.page.toast.removeIconFailed': 'Fehler beim Entfernen des Projekt-Symbols', + 'settings.projects.page.toast.iconRemoved': 'Projekt-Symbol entfernt', + 'settings.projects.page.toast.discoverIconFailed': 'Fehler beim Suchen des Projekt-Symbols', + 'settings.projects.page.toast.customIconAlreadySet': 'Benutzerdefiniertes Symbol bereits für dieses Projekt festgelegt', + 'settings.projects.page.toast.iconDiscovered': 'Projekt-Symbol gefunden', + 'settings.projects.page.toast.saveFailed': 'Fehler beim Speichern der Projekteinstellungen', + 'settings.usage.sidebar.title': 'Nutzung', + 'settings.usage.sidebar.total': 'Gesamt {count}', + 'settings.usage.sidebar.actions.toggleAutoRefreshAria': 'Automatisches Aktualisieren umschalten', + 'settings.usage.sidebar.actions.refreshAria': 'Nutzung aktualisieren', + 'settings.usage.sidebar.actions.refreshTitle': 'Nutzung aktualisieren', + 'settings.usage.sidebar.tooltip.autoRefresh': 'Aktualisiere Nutzungsdaten automatisch in festgelegtem Intervall', + 'settings.usage.sidebar.field.intervalPlaceholder': 'Intervall', + 'settings.usage.sidebar.field.display': 'Anzeige', + 'settings.usage.sidebar.field.displayModePlaceholder': 'Anzeigemodus', + 'settings.usage.sidebar.field.displayModeUsage': 'Nutzung', + 'settings.usage.sidebar.field.displayModeRemaining': 'Verbleibendes Kontingent', + 'settings.usage.sidebar.field.showPredictions': 'Vorhersagen anzeigen', + 'settings.usage.sidebar.status.notSet': 'Nicht festgelegt', + 'settings.usage.page.empty.selectProvider': 'Wählen Sie einen Anbieter aus, um Nutzungsdetails anzuzeigen.', + 'settings.usage.page.header.providerUsage': '{provider} Nutzung', + 'settings.usage.page.header.refreshing': 'Aktualisiere Nutzung...', + 'settings.usage.page.header.lastUpdated': 'Zuletzt aktualisiert: {time}', + 'settings.usage.page.options.showInHeaderAria': 'Im Kopfmenü anzeigen', + 'settings.usage.page.options.showInHeader': 'Im Kopfmenü anzeigen', + 'settings.usage.page.options.showInHeaderTooltip': 'Wenn aktiviert, ist die Nutzung dieses Anbieters im Schnellzugriff-Menü in der App-Kopfzeile sichtbar.', + 'settings.usage.page.state.noData': 'Noch keine Nutzungsdaten verfügbar.', + 'settings.usage.page.state.refreshFailedTitle': 'Aktualisierung der Nutzungsdaten fehlgeschlagen', + 'settings.usage.page.state.providerNotConfiguredTitle': 'Anbieter nicht konfiguriert', + 'settings.usage.page.state.providerNotConfiguredDescription': 'Fügen Sie Anmeldeinformationen auf der Registerkarte \'Anbieter\' hinzu, um die Nutzungsverfolgung zu aktivieren.', + 'settings.usage.page.section.modelQuotas': 'Modell-Kontingente', + 'settings.usage.page.section.otherModels': 'Andere Modelle', + 'settings.usage.page.state.noQuotaWindowsTitle': 'Keine Kontingentfenster berichtet', + 'settings.usage.page.state.noQuotaWindowsDescription': 'Dieser Anbieter meldet derzeit keine Ratenbegrenzungen oder Nutzungsquoten.', + 'settings.usage.pace.status.onTrack': 'Im Plan', + 'settings.usage.pace.status.slightlyFast': 'Leicht zu schnell', + 'settings.usage.pace.status.tooFast': 'Zu schnell', + 'settings.usage.pace.status.usedUp': 'Verbraucht', + 'settings.usage.pace.predictionTooltip': 'Vorhergesagte Nutzung zum Fensterende basierend auf aktuellem Tempo: {prediction}', + 'settings.usage.pace.wait': 'Warten {duration}', + 'settings.usage.pace.prediction': 'Vorh.: {prediction}', + 'settings.usage.pace.rate': 'Tempo: {rate}', + 'settings.usage.pace.waitSeparator': ' · Warten ', + 'settings.usage.pace.predictionLabel': 'Vorh.: ', + 'settings.remoteInstances.page.title': 'Remote-Instanz', + 'settings.remoteInstances.page.description': 'Stellen Sie eine Verbindung zu einem anderen Computer über SSH her und öffnen Sie dort OpenChamber.', + 'settings.remoteInstances.page.empty.selectInstance': 'Wählen Sie eine Instanz aus, um ihre Einstellungen anzuzeigen und zu bearbeiten.', + 'settings.remoteInstances.page.empty.noExtraForwards': 'Keine zusätzlichen Portweiterleitungen konfiguriert.', + 'settings.remoteInstances.page.section.actions': 'Aktionen', + 'settings.remoteInstances.page.section.actionsDescription': 'Verbinden, erneut verbinden, Protokolle anzeigen oder diese Verbindung entfernen.', + 'settings.remoteInstances.page.section.remoteServer': 'OpenChamber auf dem Remote-Rechner', + 'settings.remoteInstances.page.section.remoteServerDescription': 'Wählen Sie aus, wie OpenChamber nach dem SSH-Verbindungsaufbau ausgeführt werden soll.', + 'settings.remoteInstances.page.section.mainTunnel': 'Lokaler Zugriff', + 'settings.remoteInstances.page.section.mainTunnelDescription': 'Wählen Sie die lokale Adresse, die zum Öffnen des Remote-OpenChamber-Servers verwendet wird.', + 'settings.remoteInstances.page.section.authentication': 'Authentifizierung', + 'settings.remoteInstances.page.section.authenticationDescription': 'Optionale Anmeldedaten für SSH und die Remote-OpenChamber-Benutzeroberfläche.', + 'settings.remoteInstances.page.section.portForwards': 'Portweiterleitungen', + 'settings.remoteInstances.page.section.portForwardsDescription': 'Zusätzliche Ports, die über diese SSH-Verbindung verfügbar gemacht werden sollen.', + 'settings.remoteInstances.page.field.sshCommand': 'SSH-Befehl', + 'settings.remoteInstances.page.field.sshCommandPlaceholder': 'ssh user@host', + 'settings.remoteInstances.page.field.nickname': 'Spitzname', + 'settings.remoteInstances.page.field.nicknamePlaceholder': 'Arbeitslaptop', + 'settings.remoteInstances.page.field.connectionTimeoutSeconds': 'Verbindungs-Timeout (Sekunden)', + 'settings.remoteInstances.page.field.installMethod': 'Installationsmethode', + 'settings.remoteInstances.page.field.installMethodHint': 'Wie OpenChamber auf dem Remote-Rechner platziert werden soll, wenn diese Anwendung es für Sie startet.', + 'settings.remoteInstances.page.field.selectInstallMethodPlaceholder': 'Installationsmethode auswählen', + 'settings.remoteInstances.page.field.installMethodDownloadRelease': 'Release herunterladen', + 'settings.remoteInstances.page.field.installMethodUploadBundle': 'Bundle hochladen', + 'settings.remoteInstances.page.field.selectBindHostPlaceholder': 'Bind-Host auswählen', + 'settings.remoteInstances.page.field.sshPasswordOptional': 'SSH-Passwort (optional)', + 'settings.remoteInstances.page.field.sshPasswordPlaceholder': 'SSH-Passwort eingeben', + 'settings.remoteInstances.page.field.uiPasswordOptional': 'UI-Passwort (optional)', + 'settings.remoteInstances.page.field.uiPasswordPlaceholder': 'UI-Passwort eingeben', + 'settings.remoteInstances.page.field.forwardTypeHint': 'Wählen Sie aus, welche Art von Port-Zugriff diese SSH-Verbindung bereitstellen soll.', + 'settings.remoteInstances.page.field.typePlaceholder': 'Typ', + 'settings.remoteInstances.page.forwardType.local': 'Lokal (-L)', + 'settings.remoteInstances.page.forwardType.remote': 'Entfernt (-R)', + 'settings.remoteInstances.page.forwardType.dynamic': 'Dynamisch (-D)', + 'settings.remoteInstances.page.forwardTypeDescription.local': 'Öffnet einen lokalen Port, der eine Verbindung zu etwas auf dem entfernten Rechner herstellt.', + 'settings.remoteInstances.page.forwardTypeDescription.remote': 'Öffnet einen Port auf dem entfernten Rechner, der eine Verbindung zu Ihrem Computer herstellt.', + 'settings.remoteInstances.page.forwardTypeDescription.dynamic': 'Öffnet einen lokalen SOCKS-Proxy über die SSH-Verbindung.', + 'settings.remoteInstances.page.actions.create': 'Erstellen', + 'settings.remoteInstances.page.actions.cancel': 'Abbrechen', + 'settings.remoteInstances.page.actions.connecting': 'Verbinden...', + 'settings.remoteInstances.page.actions.reconnecting': 'Neuverbinden...', + 'settings.remoteInstances.page.actions.reconnectNow': 'Jetzt neu verbinden', + 'settings.remoteInstances.page.actions.logs': 'SSH-Protokolle', + 'settings.remoteInstances.page.actions.pickRandomPort': 'Zufälligen Port wählen', + 'settings.remoteInstances.page.actions.enableForwardAria': 'Weiterleitung aktivieren', + 'settings.remoteInstances.page.actions.openLocal': 'Lokal öffnen', + 'settings.remoteInstances.page.actions.addForward': 'Weiterleitung hinzufügen', + 'settings.remoteInstances.page.import.sectionTitle': 'Gespeicherte SSH-Hosts', + 'settings.remoteInstances.page.import.loading': 'SSH-Hosts werden geladen...', + 'settings.remoteInstances.page.import.noneFound': 'Keine SSH-Hosts gefunden.', + 'settings.remoteInstances.page.import.noneAvailable': 'Keine SSH-Hosts zum Importieren verfügbar.', + 'settings.remoteInstances.page.import.patternSuffix': '(Muster)', + 'settings.remoteInstances.page.status.currentLocalUrl': 'Aktuelle lokale URL:', + 'settings.remoteInstances.page.status.reconnectStale': 'Neuverbindung scheint blockiert zu sein. Sie können es jetzt erneut versuchen.', + 'settings.remoteInstances.page.logsDialog.title': 'SSH-Protokolle', + 'settings.remoteInstances.page.logsDialog.loading': 'Protokolle werden geladen...', + 'settings.remoteInstances.page.logsDialog.empty': 'Noch keine SSH-Protokolle.', + 'settings.remoteInstances.page.patternDialog.title': 'Wählen Sie ein SSH-Ziel', + 'settings.remoteInstances.page.patternDialog.destinationPlaceholder': 'benutzer@rechner', + 'settings.remoteInstances.page.phase.resolvingConfiguration': 'Konfiguration wird aufgelöst', + 'settings.remoteInstances.page.phase.checkingAuth': 'Authentifizierung wird überprüft', + 'settings.remoteInstances.page.phase.establishingSsh': 'SSH-Verbindung wird hergestellt', + 'settings.remoteInstances.page.phase.probingRemote': 'Entfernte Maschine wird überprüft', + 'settings.remoteInstances.page.phase.installingOpenChamber': 'OpenChamber wird installiert', + 'settings.remoteInstances.page.phase.updatingOpenChamber': 'OpenChamber wird aktualisiert', + 'settings.remoteInstances.page.phase.detectingServer': 'Server wird erkannt', + 'settings.remoteInstances.page.phase.startingServer': 'Server wird gestartet', + 'settings.remoteInstances.page.phase.forwardingPorts': 'Ports werden weitergeleitet', + 'settings.remoteInstances.page.phase.reconnecting': 'Verbindung wird wiederhergestellt', + 'settings.remoteInstances.page.confirm.storeSshPasswordPlaintext': 'SSH-Kennwort im Klartext auf Festplatte speichern?', + 'settings.remoteInstances.page.confirm.storeUiPasswordPlaintext': 'UI-Kennwort im Klartext auf Festplatte speichern?', + 'settings.remoteInstances.page.confirm.removeInstance': 'Diese Remote-Instanz entfernen?', + 'settings.remoteInstances.page.toast.sshCommandRequired': 'SSH-Befehl ist erforderlich', + 'settings.remoteInstances.page.toast.saveFailed': 'Fehler beim Speichern der Instanz', + 'settings.remoteInstances.page.toast.instanceSaved': 'Instanz gespeichert', + 'settings.remoteInstances.page.toast.instanceCreated': 'Instanz erstellt', + 'settings.remoteInstances.page.toast.destinationRequired': 'Ziel ist erforderlich', + 'settings.remoteInstances.page.toast.noLogsToCopy': 'Keine Protokolle zum Kopieren', + 'settings.remoteInstances.page.toast.logsCopied': 'Protokolle kopiert', + 'settings.remoteInstances.page.toast.logsCleared': 'Protokolle gelöscht', + 'settings.remoteInstances.page.toast.clearLogsFailed': 'Fehler beim Löschen der Protokolle', + 'settings.remoteInstances.page.toast.instanceUrlUnavailable': 'Instanz-URL ist nicht verfügbar', + 'settings.remoteInstances.page.toast.connectFailed': 'Verbindung zur Instanz fehlgeschlagen', + 'settings.remoteInstances.page.toast.cancelConnectionFailed': 'Abbrechen der Verbindung fehlgeschlagen', + 'settings.remoteInstances.page.toast.disconnectFailed': 'Trennung der Instanz fehlgeschlagen', + 'settings.remoteInstances.page.toast.retryFailed': 'Wiederholung der Verbindung fehlgeschlagen', + 'settings.remoteInstances.page.toast.instanceRemoved': 'Instanz entfernt', + 'settings.remoteInstances.page.toast.removeInstanceFailed': 'Entfernen der Instanz fehlgeschlagen', + 'settings.openchamber.worktrees.state.selectProject': 'Wählen Sie ein Projekt aus, um Worktrees zu verwalten.', + 'settings.openchamber.worktrees.state.gitOnly': 'Worktree-Einstellungen sind nur für Git-Repositories verfügbar.', + 'settings.openchamber.worktrees.setup.title': 'Setup-Befehle', + 'settings.openchamber.worktrees.setup.tooltipPrefix': 'Automatisch im neuen Worktree-Verzeichnis ausführen, wenn ein Worktree erstellt wird. Verwenden Sie', + 'settings.openchamber.worktrees.setup.tooltipSuffix': 'für das Projektstammverzeichnis.', + 'settings.openchamber.worktrees.setup.loading': 'Wird geladen...', + 'settings.openchamber.worktrees.setup.commandPlaceholder': 'z. B. bun install', + 'settings.openchamber.worktrees.setup.removeCommandAria': 'Befehl entfernen', + 'settings.openchamber.worktrees.setup.addCommand': 'Befehl hinzufügen', + 'settings.openchamber.worktrees.setup.waitForCommands': 'Warten auf Setup-Befehle vor dem Erstellen oder Senden einer Sitzung', + 'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Warten auf Worktree-Setup-Befehle vor dem Erstellen oder Senden einer Sitzung', + 'settings.openchamber.worktrees.setup.toast.saveFailed': 'Speichern der Worktree-Setup-Befehle fehlgeschlagen', + 'settings.openchamber.worktrees.list.title': 'Vorhandene Worktrees', + 'settings.openchamber.worktrees.list.tooltip': 'Worktrees befinden sich außerhalb des Repos (von OpenCode verwaltet). Das Löschen eines Worktrees entfernt auch verknüpfte Sitzungen.', + 'settings.openchamber.worktrees.list.loading': 'Worktrees werden geladen...', + 'settings.openchamber.worktrees.list.empty': 'Keine Worktrees für dieses Projekt gefunden', + 'settings.openchamber.worktrees.list.detachedHead': 'Detached HEAD', + 'settings.openchamber.worktrees.list.deleteWorktreeAria': 'Worktree {name} löschen', + 'settings.agents.modelSelector.title': 'Modell auswählen', + 'settings.agents.modelSelector.searchPlaceholder': 'Modelle durchsuchen', + 'settings.agents.modelSelector.selectPlaceholder': 'Modell auswählen...', + 'settings.agents.modelSelector.notSelected': 'Nicht ausgewählt', + 'settings.agents.modelSelector.noModelOptional': 'Kein Modell (optional)', + 'settings.agents.modelSelector.section.favorites': 'Favoriten', + 'settings.agents.modelSelector.section.recents': 'Kürzlich verwendet', + 'settings.agents.modelSelector.section.recent': 'Kürzlich', + 'settings.agents.modelSelector.badge.current': 'Aktuell', + 'settings.agents.modelSelector.actions.favorite': 'Favorit', + 'settings.agents.modelSelector.actions.unfavorite': 'Favorit entfernen', + 'settings.agents.modelSelector.actions.addToFavorites': 'Zu Favoriten hinzufügen', + 'settings.agents.modelSelector.actions.removeFromFavorites': 'Aus Favoriten entfernen', + 'settings.agents.modelSelector.state.noModelsFound': 'Keine Modelle gefunden', + 'settings.agents.modelSelector.keyboardHints': '↑↓ navigieren • Enter auswählen • Esc schließen', + 'settings.providers.sidebar.title': 'Anbieter', + 'settings.providers.sidebar.total': 'Gesamt {count}', + 'settings.providers.sidebar.actions.connectProviderAria': 'Anbieter verbinden', + 'settings.providers.sidebar.actions.connectProviderTitle': 'Anbieter verbinden', + 'settings.providers.sidebar.empty.title': 'Keine Anbieter gefunden', + 'settings.providers.sidebar.empty.description': 'Überprüfen Sie Ihre OpenCode-Konfiguration', + 'settings.providers.sidebar.section.userProviders': 'Benutzer-Anbieter', + 'settings.providers.sidebar.section.projectProviders': 'Projekt-Anbieter', + 'settings.providers.page.state.loading': 'Wird geladen...', + 'settings.providers.page.state.unableToLoadProviderList': 'Anbieterliste konnte nicht geladen werden', + 'settings.providers.page.empty.noProvidersDetected': 'Keine Anbieter erkannt', + 'settings.providers.page.empty.checkOpenCodeConfiguration': 'Überprüfen Sie Ihre OpenCode-Konfiguration', + 'settings.providers.page.empty.selectProviderFromSidebar': 'Wählen Sie einen Anbieter aus der Seitenleiste', + 'settings.providers.page.empty.reviewDetailsAndConfigureAuth': 'Details überprüfen und Authentifizierung konfigurieren', + 'settings.providers.page.connect.title': 'Anbieter verbinden', + 'settings.providers.page.connect.selectProviderTitle': 'Anbieter auswählen', + 'settings.providers.page.connect.providerField': 'Anbieter', + 'settings.providers.page.connect.selectProviderPlaceholder': 'Anbieter auswählen', + 'settings.providers.page.connect.searchProvidersPlaceholder': 'Suche...', + 'settings.providers.page.connect.noProvidersFound': 'Keine Anbieter gefunden', + 'settings.providers.page.connect.allProvidersConnected': 'Alle Anbieter verbunden.', + 'settings.providers.page.auth.title': 'Authentifizierung', + 'settings.providers.page.auth.loadingMethods': 'Lade Authentifizierungsmethoden...', + 'settings.providers.page.auth.apiKeyLabel': 'API-Schlüssel', + 'settings.providers.page.auth.apiKeyTooltip': 'Schlüssel werden direkt an OpenCode gesendet und nie von OpenChamber gespeichert.', + 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', + 'settings.providers.page.auth.oauthMethodFallback': 'OAuth-Methode {index}', + 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Autorisierungscode einfügen', + 'settings.providers.page.auth.connected': 'Verbunden', + 'settings.providers.page.auth.useReconnectHint': '· Verwenden Sie „Erneut verbinden“, um Anmeldedaten zu aktualisieren', + 'settings.providers.page.connectionDetails.title': 'Verbindungsdetails', + 'settings.providers.page.connectionDetails.configuredIn': 'Konfiguriert in:', + 'settings.providers.page.connectionDetails.noActiveSource': 'Keine aktive Konfigurationsquelle', + 'settings.providers.page.connectionDetails.source.authCredentials': 'Anmeldedaten', + 'settings.providers.page.connectionDetails.source.userConfig': 'Benutzerkonfiguration', + 'settings.providers.page.connectionDetails.source.projectConfig': 'Projektkonfiguration', + 'settings.providers.page.connectionDetails.source.customConfig': 'Benutzerkonfiguration', + 'settings.providers.page.models.title': 'Verfügbare Modelle', + 'settings.providers.page.models.filterPlaceholder': 'Modelle filtern...', + 'settings.providers.page.models.noModelsMatchFilter': 'Keine Modelle entsprechen diesem Filter.', + 'settings.providers.page.models.capability.toolCalling': 'Tool-Aufruf', + 'settings.providers.page.models.capability.reasoning': 'Reasoning', + 'settings.providers.page.models.capability.imageInput': 'Bildeingabe', + 'settings.providers.page.models.tokenBadge.context': 'ctx', + 'settings.providers.page.models.tokenBadge.output': 'out', + 'settings.providers.page.models.actions.hideModelFromSelectors': 'Modell aus Selektoren ausblenden', + 'settings.providers.page.models.actions.showModelInSelectors': 'Modell in Selektoren anzeigen', + 'settings.providers.page.models.actions.hideModel': 'Modell ausblenden', + 'settings.providers.page.models.actions.showModel': 'Modell anzeigen', + 'settings.providers.page.actions.saving': 'Speichern...', + 'settings.providers.page.actions.saveKey': 'Schlüssel speichern', + 'settings.providers.page.actions.connect': 'Verbinden', + 'settings.providers.page.actions.copyCode': 'Code kopieren', + 'settings.providers.page.actions.open': 'Öffnen', + 'settings.providers.page.actions.copy': 'Kopieren', + 'settings.providers.page.actions.complete': 'Vervollständigen', + 'settings.providers.page.actions.hide': 'Ausblenden', + 'settings.providers.page.actions.reconnect': 'Erneut verbinden', + 'settings.providers.page.actions.disconnecting': 'Trennen...', + 'settings.providers.page.actions.disconnect': 'Trennen', + 'settings.providers.page.actions.hideAll': 'Alle ausblenden', + 'settings.providers.page.actions.showAll': 'Alle anzeigen', + 'settings.providers.page.toast.authMethodsLoadFailed': 'Fehler beim Laden der Anbieter-Authentifizierungsmethoden', + 'settings.providers.page.toast.providerSourcesLoadFailed': 'Fehler beim Laden der Anbieter-Quellen', + 'settings.providers.page.toast.apiKeyRequired': 'API-Schlüssel ist erforderlich', + 'settings.providers.page.toast.apiKeySaveFailed': 'Fehler beim Speichern des API-Schlüssels', + 'settings.providers.page.toast.apiKeySaved': 'API-Schlüssel gespeichert', + 'settings.providers.page.toast.oauthStartFailed': 'Fehler beim Starten des OAuth-Flows', + 'settings.providers.page.toast.oauthDetailsMissing': 'Keine OAuth-Details zurückgegeben', + 'settings.providers.page.toast.completeOAuthInBrowser': 'Schließen Sie den OAuth-Flow in Ihrem Browser ab', + 'settings.providers.page.toast.oauthCompleteFailed': 'Fehler beim Abschließen des OAuth-Flows', + 'settings.providers.page.toast.oauthCompleted': 'OAuth-Verbindung abgeschlossen', + 'settings.providers.page.toast.oauthLinkCopied': 'OAuth-Link kopiert', + 'settings.providers.page.toast.oauthLinkCopyFailed': 'Fehler beim Kopieren des OAuth-Links', + 'settings.providers.page.toast.deviceCodeCopied': 'Gerätecode kopiert', + 'settings.providers.page.toast.deviceCodeCopyFailed': 'Fehler beim Kopieren des Gerätecodes', + 'settings.providers.page.toast.providerDisconnected': 'Anbieter getrennt', + 'settings.providers.page.toast.providerDisconnectFailed': 'Fehler beim Trennen des Anbieters', + 'settings.mcp.page.empty.selectServer': 'Wählen Sie einen MCP-Server aus der Seitenleiste', + 'settings.mcp.page.empty.addNewOne': 'oder fügen Sie einen neuen hinzu', + 'settings.mcp.page.header.newServer': 'Neuer MCP-Server', + 'settings.mcp.page.header.configureNewServer': 'Konfigurieren Sie einen neuen MCP-Server', + 'settings.mcp.page.header.transport': '{type} Transport', + 'settings.mcp.page.transport.local': 'Lokal · stdio', + 'settings.mcp.page.transport.remote': 'Entfernt · SSE', + 'settings.mcp.page.status.label.connected': 'Verbunden', + 'settings.mcp.page.status.label.failed': 'Fehlgeschlagen', + 'settings.mcp.page.status.label.needsAuth': 'Autorisierung erforderlich', + 'settings.mcp.page.status.label.needsRegistration': 'Registrierung erforderlich', + 'settings.mcp.page.status.description.connected': 'Verbunden und bereit, Tools und Ressourcen zu entdecken.', + 'settings.mcp.page.status.description.failedDefault': 'OpenCode konnte diesen MCP-Server nicht erreichen.', + 'settings.mcp.page.status.description.needsAuth': 'Dieser entfernte MCP-Server erfordert Autorisierung, bevor eine Verbindung hergestellt werden kann.', + 'settings.mcp.page.status.description.needsClientRegistrationDefault': 'Dieser entfernte MCP-Server erfordert Client-Registrierung, bevor die Autorisierung abgeschlossen werden kann.', + 'settings.mcp.page.status.description.disabled': 'Dieser MCP-Server ist in der Konfiguration deaktiviert.', + 'settings.mcp.page.status.description.default': 'Aktualisieren oder Testen Sie die Verbindung, um den Live-Status zu laden.', + 'settings.mcp.page.status.runtimeStatus': 'Laufzeitstatus', + 'settings.mcp.page.status.projectScopedTo': 'Projektbezogen auf {directory}', + 'settings.mcp.page.status.userScoped': 'Benutzerbezogene Konfiguration', + 'settings.mcp.page.status.activeProject': 'das aktive Projekt', + 'settings.mcp.page.actions.connect': 'Verbinden', + 'settings.mcp.page.actions.disconnect': 'Trennen', + 'settings.mcp.page.actions.authorize': 'Autorisieren', + 'settings.mcp.page.actions.reauthorize': 'Erneut autorisieren', + 'settings.mcp.page.actions.clearAuth': 'Autorisierung löschen', + 'settings.mcp.page.actions.test': 'Testen', + 'settings.mcp.page.actions.testConnection': 'Verbindung testen', + 'settings.mcp.page.actions.working': 'Arbeite...', + 'settings.mcp.page.actions.starting': 'Starte...', + 'settings.mcp.page.actions.clearing': 'Lösche...', + 'settings.mcp.page.actions.testing': 'Teste...', + 'settings.mcp.page.actions.completing': 'Abschließen...', + 'settings.mcp.page.actions.completeAuthorization': 'Autorisierung abschließen', + 'settings.mcp.page.actions.openInBrowser': 'Im Browser öffnen', + 'settings.mcp.page.actions.copyLink': 'Link kopieren', + 'settings.mcp.page.actions.deleting': 'Löschen…', + 'settings.mcp.page.auth.authorizationUrl': 'Autorisierungs-URL', + 'settings.mcp.page.auth.manualFallbackTitle': 'Manueller Autorisierungsfallback', + 'settings.mcp.page.auth.manualFallbackDescription': 'Wenn der Browser auf eine andere Maschine zurückkehrt oder eine Callback-URL mit einem Code anzeigt, fügen Sie diese vollständige URL oder den Rohcode hier ein.', + 'settings.mcp.page.auth.callbackInputPlaceholder': 'Fügen Sie die Callback-URL oder den Autorisierungscode ein', + 'settings.mcp.page.auth.waitingForOpenCode': 'Warten auf OpenCode, um den abgeschlossenen Browser-Autorisierungsfluss zu beobachten...', + 'settings.mcp.page.server.title': 'Server', + 'settings.mcp.page.server.name': 'Servername', + 'settings.mcp.page.server.namePlaceholder': 'mein-mcp-server', + 'settings.mcp.page.server.importJson': 'Aus JSON-Snippet importieren', + 'settings.mcp.page.server.importJsonTitle': 'Eine vollständige MCP-Server-Konfiguration aus einem JSON-Snippet importieren', + 'settings.mcp.page.server.enable': 'Server aktivieren', + 'settings.mcp.page.server.enableAria': 'Server aktivieren', + 'settings.mcp.page.server.transportMode': 'Transportmodus', + 'settings.mcp.page.connection.command': 'Befehl', + 'settings.mcp.page.connection.serverUrl': 'Server-URL', + 'settings.mcp.page.connection.serverUrlPlaceholder': 'https://mcp.example.com/mcp', + 'settings.mcp.page.connection.pasteCommand': 'Befehl einfügen', + 'settings.mcp.page.connection.pasteCommandTitle': 'Einen lokalen Befehl aus der Zwischenablage einfügen und automatisch aufteilen', + 'settings.mcp.page.connection.previewArgs': 'Vorschau ({count} Argumente)', + 'settings.mcp.page.advanced.title': 'Erweiterte Remote-Optionen', + 'settings.mcp.page.advanced.configure': 'Erweiterte Optionen konfigurieren', + 'settings.mcp.page.advanced.autoDetect': 'Automatische Erkennung', + 'settings.mcp.page.advanced.custom': 'Benutzerdefiniert', + 'settings.mcp.page.advanced.headers': 'Header', + 'settings.mcp.page.advanced.timeoutMs': 'Timeout (ms)', + 'settings.mcp.page.advanced.timeoutHint': 'Leer lassen, um den Standard-MCP-Timeout von OpenCode zu verwenden.', + 'settings.mcp.page.advanced.requestHeaders': 'Anfrage-Header', + 'settings.mcp.page.advanced.headerNamePlaceholder': 'Header-Name', + 'settings.mcp.page.advanced.pasteHeaders': 'Header einfügen', + 'settings.mcp.page.advanced.pasteHeadersTitle': 'KEY=VALUE-Headerzeilen aus der Zwischenablage einfügen', + 'settings.mcp.page.advanced.oauthAutoDetection': 'OAuth-Auto-Erkennung aktivieren', + 'settings.mcp.page.advanced.oauthAutoDetectionAria': 'OAuth-Auto-Erkennung aktivieren', + 'settings.mcp.page.advanced.oauthClientIdPlaceholder': 'OAuth Client-ID', + 'settings.mcp.page.advanced.oauthClientSecretPlaceholder': 'OAuth Client-Geheimnis', + 'settings.mcp.page.advanced.oauthScopesPlaceholder': 'Bereiche (Leerzeichen-getrennt)', + 'settings.mcp.page.advanced.oauthRedirectUriPlaceholder': 'Weiterleitungs-URI', + 'settings.mcp.page.advanced.oauthHint': 'Lassen Sie diese Felder leer, damit OpenCode die OAuth-Einstellungen vom MCP-Server ableiten kann.', + 'settings.mcp.page.advanced.oauthCallbackHint': 'Browserbasierte MCP-Autorisierung verwendet diese Callback-URL, wenn der Redirect-URI leer ist:', + 'settings.mcp.page.env.title': 'Umgebungsvariablen', + 'settings.mcp.page.env.key': 'Schlüssel', + 'settings.mcp.page.env.value': 'Wert', + 'settings.mcp.page.env.valuePlaceholder': 'Wert', + 'settings.mcp.page.env.hide': 'Verstecken', + 'settings.mcp.page.env.show': 'Anzeigen', + 'settings.mcp.page.env.addVariable': 'Variable hinzufügen', + 'settings.mcp.page.env.addEnvironmentVariable': 'Umgebungsvariable hinzufügen', + 'settings.mcp.page.env.keyPlaceholder': 'API_SCHLÜSSEL', + 'settings.mcp.page.env.pasteEnv': '.env einfügen', + 'settings.mcp.page.env.pasteEnvTitle': 'KEY=VALUE-Zeilen aus der Zwischenablage einfügen', + 'settings.mcp.page.env.plainTextWarning': '⚠ Werte werden als Klartext in opencode.json gespeichert', + 'settings.mcp.page.env.removeVariableAria': 'Variable entfernen', + 'settings.mcp.page.importDialog.title': 'JSON-Schnipsel importieren', + 'settings.mcp.page.importDialog.description': 'Fügen Sie einen vollständigen MCP-JSON-Schnipsel aus Dokumentationen oder einer anderen Konfigurationsdatei ein. Die geparsten Werte werden dieses Formular zur Überprüfung vor dem Speichern ausfüllen.', + 'settings.mcp.page.importDialog.pasteFromClipboard': 'JSON aus der Zwischenablage einfügen', + 'settings.mcp.page.deleteDialog.title': '"{name}" löschen?', + 'settings.mcp.page.deleteDialog.descriptionPrefix': 'Dadurch wird der Server entfernt aus', + 'settings.mcp.page.deleteDialog.descriptionSuffix': 'OpenCode muss neu geladen werden.', + 'settings.mcp.page.toast.clipboardReadFailed': 'Kann Zwischenablage nicht lesen', + 'settings.mcp.page.toast.configImported': 'MCP-Konfiguration importiert', + 'settings.mcp.page.toast.nameRequired': 'Name ist erforderlich', + 'settings.mcp.page.toast.serverNameExists': 'Ein Server mit diesem Namen existiert bereits', + 'settings.mcp.page.toast.localCommandRequired': 'Befehl darf für einen lokalen Server nicht leer sein', + 'settings.mcp.page.toast.remoteUrlRequired': 'URL darf für einen Remote-Server nicht leer sein', + 'settings.mcp.page.toast.serverCreatedReloadFailed': 'MCP-Server erstellt, aber OpenCode-Neuladen fehlgeschlagen.', + 'settings.mcp.page.toast.savedReloadFailed': 'Gespeichert, aber OpenCode-Neuladen fehlgeschlagen.', + 'settings.mcp.page.toast.retryRefreshHint': 'Aktualisierung erneut versuchen oder Einstellungen vor der Autorisierung dieses Servers neu öffnen.', + 'settings.mcp.page.toast.serverCreatedReloading': 'MCP-Server erstellt. OpenCode wird neu geladen…', + 'settings.mcp.page.toast.savedReloading': 'Gespeichert. OpenCode wird neu geladen…', + 'settings.mcp.page.toast.saveFailed': 'Speichern fehlgeschlagen', + 'settings.mcp.page.toast.unexpectedError': 'Ein Fehler ist aufgetreten', + 'settings.mcp.page.toast.serverDeletedReloadFailed': '"{name}" gelöscht, aber OpenCode-Neuladen fehlgeschlagen', + 'settings.mcp.page.toast.refreshListIfStale': 'Aktualisiere die MCP-Liste, falls die Benutzeroberfläche veraltet aussieht.', + 'settings.mcp.page.toast.serverDeleted': '"{name}" gelöscht', + 'settings.mcp.page.toast.deleteFailed': 'Löschen fehlgeschlagen', + 'settings.mcp.page.toast.disconnected': 'Getrennt', + 'settings.mcp.page.toast.connected': 'Verbunden', + 'settings.mcp.page.toast.connectionNeedsAuthorization': 'Verbindung erfordert Autorisierung', + 'settings.mcp.page.toast.connectionNeedsClientRegistration': 'Verbindung erfordert Client-Registrierung', + 'settings.mcp.page.toast.connectionFailed': 'Verbindung fehlgeschlagen', + 'settings.mcp.page.toast.connectionAttemptFinished': 'Verbindungsversuch abgeschlossen. Aktualisiere den Status für Details.', + 'settings.mcp.page.toast.createServerBeforeLiveActions': 'Erstelle den Server, bevor du Live-Aktionen ausführst', + 'settings.mcp.page.toast.saveBeforeLiveActions': 'Speichere Änderungen, bevor du Live-Aktionen ausführst', + 'settings.mcp.page.toast.refreshStatusFailed': 'Aktualisierung des MCP-Status fehlgeschlagen', + 'settings.mcp.page.toast.oauthRedirectUrlBuildFailed': 'Kann die MCP OAuth-Umleitungs-URL nicht erstellen', + 'settings.mcp.page.toast.oauthBrowserCallbackSaveFailed': 'Fehler beim Speichern der Browser-Callback-URL für MCP-Autorisierung', + 'settings.mcp.page.toast.openCodeReloadFailedAfterCallbackSave': 'OpenCode-Neuladen fehlgeschlagen nach dem Speichern der Browser-Callback-URL', + 'settings.mcp.page.toast.completeAuthorizationInBrowserWithPaste': 'Schließe den MCP-Autorisierungsprozess in deinem Browser ab, füge dann den zurückgegebenen Code oder die Callback-URL hier ein', + 'settings.mcp.page.toast.completeAuthorizationInBrowser': 'Schließe den MCP-Autorisierungsprozess in deinem Browser ab', + 'settings.mcp.page.toast.openAuthorizationUrlFailed': 'Konnte die Autorisierungs-URL nicht automatisch öffnen', + 'settings.mcp.page.toast.authorizationStartFailed': 'Fehler beim Starten der Autorisierung', + 'settings.mcp.page.toast.authSessionExpired': 'Autorisierungssitzung abgelaufen oder während des Neuladens gelöscht. Klicke erneut auf Autorisieren.', + 'settings.mcp.page.toast.savedAuthorizationRemoved': 'Gespeicherte MCP-Autorisierung wurde entfernt', + 'settings.mcp.page.toast.clearAuthorizationFailed': 'Fehler beim Löschen der Autorisierung', + 'settings.mcp.page.toast.authorizationUrlCopied': 'Autorisierungs-URL kopiert', + 'settings.mcp.page.toast.authorizationUrlCopyFailed': 'Fehler beim Kopieren der Autorisierungs-URL', + 'settings.mcp.page.toast.pasteCallbackOrCodeFirst': 'Füge zuerst die Callback-URL oder den Autorisierungscode ein', + 'settings.mcp.page.toast.missingServerDetails': 'Fehlende MCP-Serverdetails. Wähle den Server erneut aus oder füge die vollständige Callback-URL ein.', + 'settings.mcp.page.toast.authorizationCompleted': 'MCP-Autorisierung abgeschlossen', + 'settings.mcp.page.toast.authorizationCompletedFor': 'MCP-Autorisierung abgeschlossen für {name}', + 'settings.mcp.page.toast.authorizationCompleteFailed': 'Fehler beim Abschließen der MCP-Autorisierung', + 'settings.mcp.page.toast.enableServerBeforeTest': 'Aktiviere diesen Server, bevor du die Verbindung testest', + 'settings.mcp.page.toast.connectionTestSucceeded': 'Verbindungstest erfolgreich', + 'settings.mcp.page.toast.connectionTestFailed': 'Verbindungstest fehlgeschlagen', + 'settings.mcp.page.toast.connectionTestFinished': 'Verbindungstest abgeschlossen. Aktualisiere den Status für Details.', + 'settings.mcp.page.toast.authorizationFailed': 'Autorisierung fehlgeschlagen', + 'settings.mcp.page.toast.authorizationStillInProgress': 'Autorisierung läuft noch in deinem Browser. Füge die Callback-URL oder den Code ein, falls nötig.', + 'settings.mcp.page.toast.noKeyValuePairsFound': 'Keine KEY=VALUE-Paare im Clipboard gefunden', + 'settings.mcp.page.toast.importedVariablesCount': 'Importierte Variablen: {count}', + 'settings.mcp.page.toast.pastedArgumentsCount': 'Eingefügte Argumente: {count}', + 'settings.shared.projectSelector.fallbackProject': 'Projekt', + 'settings.shared.projectSelector.switchProjectAria': 'Projekt wechseln', + 'settings.shared.projectSelector.switchProjectTitle': 'Projekt wechseln', + 'settings.openchamber.defaults.title': 'Sitzungsstandardwerte', + 'settings.openchamber.defaults.summaryPrefix': 'Neue Sitzungen starten mit:', + 'settings.openchamber.defaults.summaryOpenCodeDefault': 'OpenCode-Agent-Standard', + 'settings.openchamber.defaults.field.defaultModel': 'Standardmodell', + 'settings.openchamber.defaults.field.defaultThinking': 'Standard-Denken', + 'settings.openchamber.defaults.field.thinkingPlaceholder': 'Denken', + 'settings.openchamber.defaults.field.defaultAgent': 'Standard-Agent', + 'settings.openchamber.defaults.field.showDeletionDialogAria': 'Löschdialog anzeigen', + 'settings.openchamber.defaults.field.showDeletionDialog': 'Löschdialog anzeigen', + 'settings.openchamber.defaults.smallModel.title': 'Kleines Modell', + 'settings.openchamber.defaults.smallModel.description': 'Ein günstiges Modell für schnelle Dienstaufgaben wie kurze Zusammenfassungen und Faziten.', + 'settings.openchamber.defaults.smallModel.useDefault': 'Standardmodell verwenden', + 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Standardmodell verwenden', + 'settings.openchamber.defaults.smallModel.overrideModel': 'Modell überschreiben', + 'settings.openchamber.defaults.field.openFilesPreviewAria': 'Vorschau-fähige Dateien im Vorschaumodus öffnen', + 'settings.openchamber.defaults.field.openFilesPreview': 'Vorschau-fähige Dateien im Vorschaumodus öffnen', + 'settings.openchamber.defaults.option.default': 'Standard', + 'settings.openchamber.defaults.option.defaultLowercase': 'Standard', + 'settings.openchamber.git.title': 'Git-Einstellungen', + 'settings.openchamber.git.changesViewTitle': 'Änderungsansicht', + 'settings.openchamber.git.changesViewAria': 'Git-Änderungsansichtsmodus', + 'settings.openchamber.git.option.flatList': 'Flache Liste', + 'settings.openchamber.git.option.treeView': 'Baumansicht', + 'settings.openchamber.git.optionAria': 'Git-Änderungsansichtsmodus: {option}', + 'settings.openchamber.git.enableGitmojiAria': 'Gitmoji-Auswähler aktivieren', + 'settings.openchamber.git.enableGitmoji': 'Gitmoji-Auswähler aktivieren', + 'settings.openchamber.git.showGitignoredAria': 'Git-ignorierte Dateien anzeigen', + 'settings.openchamber.git.showGitignored': 'Git-ignorierte Dateien anzeigen', + 'settings.github.page.tooltip.connectAccount': 'Verbinden Sie ein GitHub-Konto für PR- und Issue-Workflows innerhalb der App.', + 'settings.github.page.avatarAlt.withLogin': '{login} Avatar', + 'settings.github.page.avatarAlt.fallback': 'GitHub-Avatar', + 'settings.github.page.label.unknownUser': 'unbekannt', + 'settings.github.page.label.scopes': 'Bereiche: {value}', + 'settings.github.page.label.otherAccounts': 'Andere Konten', + 'settings.github.page.accountSource.oauth': 'OAuth', + 'settings.github.page.accountSource.cli': 'CLI', + 'settings.github.page.actions.disconnect': 'Trennen', + 'settings.github.page.actions.connect': 'GitHub verbinden', + 'settings.github.page.actions.switchTo': 'Wechseln zu', + 'settings.github.page.actions.addAccount': 'Konto hinzufügen', + 'settings.github.page.actions.openGithub': 'GitHub öffnen', + 'settings.github.page.status.notConnected': 'Nicht verbunden', + 'settings.github.page.status.active': 'Aktiv', + 'settings.github.page.flow.title': 'OpenChamber autorisieren', + 'settings.github.page.flow.description': 'Geben Sie in GitHub den folgenden Code ein, um dieses Gerät zu autorisieren:', + 'settings.github.page.flow.waiting': 'Warten auf Genehmigung… (Auto-Refresh)', + 'settings.github.page.toast.startConnectFailed': 'Fehler beim Starten der GitHub-Verbindung', + 'settings.github.page.toast.connected': 'GitHub verbunden', + 'settings.github.page.toast.authorizationFailed': 'GitHub-Autorisierung fehlgeschlagen', + 'settings.github.page.toast.disconnected': 'GitHub getrennt', + 'settings.github.page.toast.disconnectFailed': 'Fehler beim Trennen von GitHub', + 'settings.github.page.toast.accountSwitched': 'GitHub-Konto gewechselt', + 'settings.github.page.toast.accountSwitchFailed': 'Fehler beim Wechseln des GitHub-Kontos', + 'settings.github.page.oauth.title': 'GitHub OAuth-Token', + 'settings.github.page.ghCli.title': 'GitHub CLI-Token', + 'settings.github.page.ghCli.activeDescription': 'Authentifiziert über die gh CLI', + 'settings.github.page.ghCli.fallbackDescription': 'Verfügbar als Fallback, falls OpenChamber-Auth entfernt wird', + 'settings.github.page.ghCli.disabledDescription': 'Installiert, aber nicht als Fallback verwendet', + 'settings.github.page.ghCli.actions.disable': 'Deaktivieren', + 'settings.github.page.ghCli.actions.enable': 'Aktivieren', + 'settings.github.page.toast.ghCliEnabled': 'gh CLI Fallback aktiviert', + 'settings.github.page.toast.ghCliDisabled': 'gh CLI Fallback deaktiviert', + 'settings.github.page.toast.ghCliUpdateFailed': 'Fehler beim Aktualisieren der gh CLI Einstellung', + 'settings.notifications.page.delivery.title': 'Benachrichtigungsübermittlung', + 'settings.notifications.page.delivery.enableAria': 'Benachrichtigungen aktivieren', + 'settings.notifications.page.delivery.enableLabel': 'Benachrichtigungen aktivieren', + 'settings.notifications.page.delivery.focusedAria': 'Benachrichtigen, wenn App im Vordergrund ist', + 'settings.notifications.page.delivery.focusedLabel': 'Benachrichtigen, wenn App im Vordergrund ist', + 'settings.notifications.page.delivery.testAction': 'Testbenachrichtigung senden', + 'settings.notifications.page.delivery.browserPermissionHint': 'Ihr Browser könnte die Berechtigung beim ersten Mal abfragen.', + 'settings.notifications.page.delivery.permissionDenied': 'Benachrichtigungsberechtigung verweigert. Aktivieren Sie sie in Ihren Browsereinstellungen.', + 'settings.notifications.page.delivery.permissionGrantedButDisabled': 'Berechtigung erteilt, aber Benachrichtigungen sind deaktiviert.', + 'settings.notifications.page.delivery.vscodeHint': 'Wenn aktiviert, werden Benachrichtigungen über VS Codes native Benachrichtigungen zugestellt.', + 'settings.notifications.page.events.title': 'Benachrichtigungsereignisse', + 'settings.notifications.page.events.completionAria': 'Agentenabschluss', + 'settings.notifications.page.events.completionLabel': 'Agentenabschluss', + 'settings.notifications.page.events.subtaskAria': 'Unteragentenabschluss', + 'settings.notifications.page.events.subtaskLabel': 'Unteragentenabschluss', + 'settings.notifications.page.events.errorAria': 'Agentenfehler', + 'settings.notifications.page.events.errorLabel': 'Agentenfehler', + 'settings.notifications.page.events.questionAria': 'Agentenfragen', + 'settings.notifications.page.events.questionLabel': 'Agentenfragen', + 'settings.notifications.page.template.title': 'Benachrichtigungsvorlagen', + 'settings.notifications.page.template.variablesLabel': 'Variablen:', + 'settings.notifications.page.template.event.completion': 'Abschluss', + 'settings.notifications.page.template.event.subtask': 'Teilaufgabe abgeschlossen', + 'settings.notifications.page.template.event.error': 'Fehler', + 'settings.notifications.page.template.event.question': 'Frage', + 'settings.notifications.page.template.field.title': 'Titel', + 'settings.notifications.page.template.field.message': 'Nachricht', + 'settings.notifications.page.template.defaults.completion.title': '{agent_name} ist bereit', + 'settings.notifications.page.template.defaults.completion.message': '{model_name} hat die Aufgabe abgeschlossen', + 'settings.notifications.page.template.defaults.error.title': 'Werkzeugfehler', + 'settings.notifications.page.template.defaults.error.message': '{last_message}', + 'settings.notifications.page.template.defaults.question.title': 'Eingabe erforderlich', + 'settings.notifications.page.template.defaults.question.message': '{last_message}', + 'settings.notifications.page.template.defaults.subtask.title': '{agent_name} ist bereit', + 'settings.notifications.page.template.defaults.subtask.message': '{model_name} hat die Aufgabe abgeschlossen', + 'settings.notifications.page.summary.title': 'KI-Zusammenfassung', + 'settings.notifications.page.summary.toggleAria': 'Letzte Nachricht zusammenfassen', + 'settings.notifications.page.summary.toggleLabel': 'Letzte Nachricht zusammenfassen', + 'settings.notifications.page.summary.requiresTemplateVariable': 'Erfordert', + 'settings.notifications.page.summary.modelLabel': 'Zusammenfassungsmodell', + 'settings.notifications.page.summary.modelTooltip': 'Wird für Benachrichtigungs- und Sprachzusammenfassungen verwendet.', + 'settings.notifications.page.summary.notSelected': 'Nicht ausgewählt', + 'settings.notifications.page.summary.thresholdLabel': 'Schwellenwert', + 'settings.notifications.page.summary.thresholdHint': 'Nachrichten, die länger sind als dieser Wert, werden zusammengefasst', + 'settings.notifications.page.summary.resetThresholdAria': 'Schwellenwert zurücksetzen', + 'settings.notifications.page.summary.lengthLabel': 'Länge', + 'settings.notifications.page.summary.lengthHint': 'Zielzeichenlänge der Zusammenfassung', + 'settings.notifications.page.summary.resetLengthAria': 'Zusammenfassungslänge zurücksetzen', + 'settings.notifications.page.summary.maxLengthLabel': 'Maximale Länge', + 'settings.notifications.page.summary.maxLengthHint': '{last_message} auf diese Länge kürzen', + 'settings.notifications.page.summary.resetMaxLengthAria': 'Maximale Nachrichtenlänge zurücksetzen', + 'settings.notifications.page.push.title': 'Hintergrund-Push-Benachrichtigungen', + 'settings.notifications.page.push.enableAria': 'Push-Benachrichtigungen aktivieren', + 'settings.notifications.page.push.enableLabel': 'Push-Benachrichtigungen aktivieren', + 'settings.notifications.page.push.unsupportedHint': 'Push wird nicht unterstützt. Desktop Chrome/Edge und Android unterstützen Push. iOS erfordert eine installierte PWA.', + 'settings.notifications.page.push.supportedHint': 'Empfange Benachrichtigungen über den Hintergrunddienst deines Betriebssystems', + 'settings.notifications.page.push.loadingAria': 'Wird geladen', + 'settings.notifications.page.toast.permissionDenied.title': 'Benachrichtigungsberechtigung verweigert', + 'settings.notifications.page.toast.permissionDenied.description': 'Bitte aktiviere Benachrichtigungen in deinen Browser-Einstellungen.', + 'settings.notifications.page.toast.permissionDenied.enableInBrowser': 'Aktiviere Benachrichtigungen in deinen Browser-Einstellungen.', + 'settings.notifications.page.toast.requestPermissionFailed': 'Fehler beim Anfordern der Benachrichtigungsberechtigung', + 'settings.notifications.page.toast.notificationsApiUnavailable': 'Benachrichtigungs-API nicht verfügbar', + 'settings.notifications.page.toast.testNotificationSent': 'Testbenachrichtigung erfolgreich gesendet', + 'settings.notifications.page.toast.testNotificationFailed': 'Fehler beim Senden der Testbenachrichtigung', + 'settings.notifications.page.toast.pushUnsupported': 'Push-Benachrichtigungen werden nicht unterstützt', + 'settings.notifications.page.toast.pushApiUnavailable': 'Push-API nicht verfügbar', + 'settings.notifications.page.toast.pushKeyLoadFailed': 'Fehler beim Laden des Push-Schlüssels', + 'settings.notifications.page.toast.enableBackgroundFailed': 'Fehler beim Aktivieren von Hintergrundbenachrichtigungen', + 'settings.notifications.page.toast.backgroundEnabled': 'Hintergrundbenachrichtigungen aktiviert', + 'settings.notifications.page.toast.backgroundDisabled': 'Hintergrundbenachrichtigungen deaktiviert', + 'settings.notifications.page.testNotification.title': 'Testbenachrichtigung', + 'settings.notifications.page.testNotification.body': 'Dies ist eine Testbenachrichtigung von OpenChamber.', + 'settings.voice.page.section.speechRecognition': 'Spracherkennung', + 'settings.voice.page.field.enableVoiceInput': 'Spracheingabe aktivieren', + 'settings.voice.page.field.enableVoiceInputAria': 'Spracheingabe aktivieren (Diktat)', + 'settings.voice.page.section.playbackAndSummary': 'Wiedergabe', + 'settings.voice.page.field.provider': 'Anbieter', + 'settings.voice.page.provider.browser': 'Browser', + 'settings.voice.page.provider.custom': 'Benutzerdefiniert', + 'settings.voice.page.provider.say': 'Sagen', + 'settings.voice.page.provider.server': 'Server', + 'settings.voice.page.provider.local': 'Lokal', + 'settings.voice.page.tooltip.sttLocal': 'On-device Transkription auf dem OpenChamber-Server. Modelle werden automatisch heruntergeladen; kein API-Schlüssel erforderlich.', + 'settings.voice.page.tooltip.localTts': 'On-device Synthese auf dem OpenChamber-Server (Kokoro, Englisch). Das Modell wird automatisch heruntergeladen; kein API-Schlüssel erforderlich.', + 'settings.voice.page.stt.model.parakeetV2': 'Parakeet v2 (Englisch)', + 'settings.voice.page.stt.model.parakeetV3': 'Parakeet v3 (25 europäische Sprachen)', + 'settings.voice.page.stt.model.whisperBase': 'Whisper base (mehrsprachig)', + 'settings.voice.page.stt.model.whisperTiny': 'Whisper tiny (mehrsprachig)', + 'settings.voice.page.stt.badge.bestForEnglish': 'Am besten für Englisch', + 'settings.voice.page.stt.badge.bestForMultilingual': 'Am besten für mehrsprachig', + 'settings.voice.page.stt.meta.accuracy': 'Genauigkeit', + 'settings.voice.page.stt.meta.speed': 'Geschwindigkeit', + 'settings.voice.page.stt.modelInstalled': 'Modell installiert', + 'settings.voice.page.stt.modelDownloading': 'Modell wird heruntergeladen...', + 'settings.voice.page.stt.modelDownloadingProgress': 'Modell wird heruntergeladen... {percent}%', + 'settings.voice.page.stt.modelNotInstalled': 'Modell nicht heruntergeladen', + 'settings.voice.page.stt.modelDownload': 'Herunterladen', + 'settings.voice.page.stt.modelDelete': 'Modell löschen', + 'settings.voice.page.stt.modelRetry': 'Wiederholen', + 'settings.voice.page.provider.wasm': 'Lokal', + 'settings.voice.page.stt.wasmModel': 'Whisper-Modell', + 'settings.voice.page.stt.wasmLoaded': 'Modell geladen, bereit', + 'settings.voice.page.stt.wasmDownloading': 'Modell wird heruntergeladen...', + 'settings.voice.page.stt.wasmLoading': 'Modell wird geladen...', + 'settings.voice.page.stt.wasmNotLoaded': 'Modell wird beim ersten Sprachgebrauch geladen', + 'settings.voice.page.stt.wasmDownload': 'Laden', + 'settings.voice.page.stt.wasmRetry': 'Wiederholen', + 'settings.voice.page.tooltip.browser': 'Kostenlos, offline, begrenzte mobile Unterstützung.', + 'settings.voice.page.tooltip.openai': 'Hohe Qualität, mobil bereit, benötigt API-Schlüssel.', + 'settings.voice.page.tooltip.custom': 'OpenAI-kompatibler Server (z. B. Kokoro).', + 'settings.voice.page.tooltip.say': 'Nativ auf macOS. Schnell, kostenlos, offline.', + 'settings.voice.page.tooltip.sttBrowser': 'Web Speech API (Chrome/Edge). Kostenlos, keine Einrichtung.', + 'settings.voice.page.tooltip.sttServer': 'OpenAI-kompatibler Whisper-Server. Bessere Genauigkeit, beliebige Sprache.', + 'settings.voice.page.field.apiKey': 'API-Schlüssel', + 'settings.voice.page.field.apiKeyHintUsingConfig': 'Verwendeter Schlüssel aus Konfiguration', + 'settings.voice.page.field.apiKeyHintRequired': 'OpenAI TTS benötigt einen API-Schlüssel', + 'settings.voice.page.field.apiKeyHintProvide': 'Geben Sie Ihren OpenAI-Schlüssel ein', + 'settings.voice.page.field.serverUrl': 'Server-URL', + 'settings.voice.page.field.serverUrlHint': 'Basis-URL des OpenAI-kompatiblen TTS-Servers', + 'settings.voice.page.field.model': 'Modell', + 'settings.voice.page.field.voice': 'Stimme', + 'settings.voice.page.field.voiceIdentifierHint': 'Vom Server unterstützter Sprachidentifier', + 'settings.voice.page.field.configuredAbove': 'Oben konfiguriert', + 'settings.voice.page.actions.preview': 'Vorschau', + 'settings.voice.page.field.selectVoicePlaceholder': 'Sprache auswählen', + 'settings.voice.page.field.auto': 'Automatisch', + 'settings.voice.page.field.speechRate': 'Sprachgeschwindigkeit', + 'settings.voice.page.field.speechPitch': 'Sprachpitch', + 'settings.voice.page.field.speechVolume': 'Sprachlautstärke', + 'settings.voice.page.field.language': 'Sprache', + 'settings.voice.page.field.selectLanguagePlaceholder': 'Sprache auswählen', + 'settings.voice.page.field.sttBrowserSupportError': 'MediaRecorder oder AudioContext ist in diesem Browser nicht verfügbar. Server-STT funktioniert möglicherweise nicht.', + 'settings.voice.page.field.sttServerUrlHint': 'Basis-URL des Whisper-kompatiblen Servers', + 'settings.voice.page.field.sttLanguageHint': 'BCP-47-Code (z.B. en, fr). Leer lassen für automatische Erkennung.', + 'settings.voice.page.field.silenceThreshold': 'Schwellenwert für Stille', + 'settings.voice.page.field.silenceHold': 'Halten bei Stille', + 'settings.voice.page.field.transcribeOnStopAria': 'Transkribieren beim Beenden der Aufnahme', + 'settings.voice.page.field.transcribeOnStop': 'Beim Stoppen transkribieren', + 'settings.voice.page.field.millisecondsUnit': 'ms', + 'settings.voice.page.field.messageReadAloudButtonAria': 'Schaltfläche für Vorlesen der Nachricht', + 'settings.voice.page.field.messageReadAloudButton': 'Nachricht vorlesen-Taste', + 'settings.voice.page.field.summarizeBeforePlaybackAria': 'Zusammenfassen vor der Wiedergabe', + 'settings.voice.page.field.summarizeBeforePlayback': 'Vor der Wiedergabe zusammenfassen', + 'settings.voice.page.field.summarizeVoiceModeResponsesAria': 'Antworten im Sprachmodus zusammenfassen', + 'settings.voice.page.field.summarizeVoiceModeResponses': 'Antworten im Sprachmodus zusammenfassen', + 'settings.voice.page.field.summarizationThreshold': 'Zusammenfassungsschwellenwert', + 'settings.voice.page.field.summaryMaxLength': 'Maximale Länge der Zusammenfassung', + 'settings.voice.page.hint.shiftClickPrefix': 'Drücken Sie', + 'settings.voice.page.hint.shiftClickSuffix': 'auf die Mikrofon-Schaltfläche, um den kontinuierlichen Modus umzuschalten', + 'settings.voice.page.preview.browserVoiceFallback': 'Ihre Browserstimme', + 'settings.voice.page.preview.voiceLine': 'Hallo! Ich bin {voiceName}. So klinge ich.', + 'settings.voice.page.preview.customServerLine': 'Hallo! Das ist eine Vorschau des benutzerdefinierten TTS-Servers.', + 'settings.voice.page.field.ttsInputMode': 'TTS-Eingabemodus', + 'settings.voice.page.field.ttsInputModeSanitized': 'Gereinigt', + 'settings.voice.page.field.ttsInputModeRaw': 'Rohes Markdown', + 'settings.voice.page.field.ttsInputModeSummarized': 'zusammengefasst', + 'settings.openchamber.visual.section.colorMode': 'Farbmodus', + 'settings.openchamber.visual.section.mobileLayout': 'Mobiles Layout', + 'settings.openchamber.visual.option.mobileLayout.default': 'Alt', + 'settings.openchamber.visual.option.mobileLayout.new': 'Neu', + 'settings.openchamber.visual.section.localization': 'Lokalisierung', + 'settings.openchamber.visual.section.spacingAndLayout': 'Abstand & Layout', + 'settings.openchamber.visual.section.navigation': 'Navigation', + 'settings.openchamber.visual.section.chatRenderMode': 'Chat-Darstellungsmodus', + 'settings.openchamber.visual.section.chatRenderModeAria': 'Chat-Darstellungsmodus', + 'settings.openchamber.visual.section.messageStreamTransport': 'Nachrichtenstrom-Transport', + 'settings.openchamber.visual.section.activityDefault': 'Aktivitätsstandard', + 'settings.openchamber.visual.section.activityDefaultAria': 'Aktivitätsstandardmodus', + 'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Werkzeuge standardmäßig geöffnet anzeigen:', + 'settings.openchamber.visual.section.sessionAssistance': 'Sitzungshilfe', + 'settings.openchamber.visual.section.reasoning': 'Reasoning', + 'settings.openchamber.visual.section.messageAppearance': 'Nachrichten-Erscheinungsbild', + 'settings.openchamber.visual.section.toolsAndFiles': 'Werkzeuge & Dateien', + 'settings.openchamber.visual.section.composer': 'Komponist', + 'settings.openchamber.visual.section.userMessageRendering': 'Benutzer-Nachrichtendarstellung', + 'settings.openchamber.visual.section.userMessageRenderingAria': 'Modus der Benutzer-Nachrichtendarstellung', + 'settings.openchamber.visual.section.mermaidRendering': 'Mermaid-Darstellung', + 'settings.openchamber.visual.section.mermaidRenderingAria': 'Mermaid-Darstellungsmodus', + 'settings.openchamber.visual.section.diffLayout': 'Diff-Layout', + 'settings.openchamber.visual.section.diffLayoutAria': 'Diff-Layout', + 'settings.openchamber.visual.section.privacy': 'Datenschutz', + 'settings.openchamber.visual.field.lightTheme': 'Helles Design', + 'settings.openchamber.visual.field.darkTheme': 'Dunkles Design', + 'settings.openchamber.visual.field.selectLightThemeAria': 'Helles Design auswählen', + 'settings.openchamber.visual.field.selectDarkThemeAria': 'Dunkles Design auswählen', + 'settings.openchamber.visual.field.selectThemePlaceholder': 'Design auswählen', + 'settings.openchamber.visual.field.timeFormat': 'Zeitformat', + 'settings.openchamber.visual.field.weekStartsOn': 'Woche beginnt am', + 'settings.openchamber.visual.field.selectTimeFormatAria': 'Zeitformat auswählen', + 'settings.openchamber.visual.field.selectWeekStartAria': 'Wochenstart auswählen', + 'settings.openchamber.visual.actions.reloadThemes': 'Designs neu laden', + 'settings.openchamber.visual.actions.reloadingThemes': 'Designs werden neu geladen...', + 'settings.openchamber.visual.field.macVibrancy': 'Fenstertransparenz', + 'settings.openchamber.visual.field.macVibrancyHint': 'Verwenden Sie den nativen macOS-Blur (Vibrancy) hinter der Seitenleiste. Ausschalten für vollständig solide, undurchsichtige Oberflächen.', + 'settings.openchamber.visual.field.dockBadge': 'Dock-Badge', + 'settings.openchamber.visual.field.dockBadgeHint': 'Zeigt eine Anzahl an Chats mit nicht gesehenem Aktivität auf dem macOS-Dock-Icon an.', + 'settings.openchamber.visual.actions.saveAndRestart': 'Speichern & Neustart', + 'settings.openchamber.visual.actions.restarting': 'Neustarten…', + 'settings.openchamber.visual.field.themeImportInfoAria': 'Informationen zum Importieren von Designs', + 'settings.openchamber.visual.field.themeImportInfoTooltip': 'Benutzerdefinierte Designs aus ~/.config/openchamber/themes/ importieren', + 'settings.openchamber.visual.field.installAppName': 'App-Name installieren', + 'settings.openchamber.visual.field.installAppNameHint': 'Wird vom PWA-Installationsprozess verwendet.', + 'settings.openchamber.visual.field.pwaInstallAppNameAria': 'PWA-Installations-App-Name', + 'settings.openchamber.visual.actions.resetInstallAppNameAria': 'Installations-App-Namen zurücksetzen', + 'settings.openchamber.visual.field.installOrientation': 'Ausrichtung installieren', + 'settings.openchamber.visual.field.installOrientationHint': 'Wird von der installierten Web-App verwendet. PWA neu installieren nach Änderung.', + 'settings.openchamber.visual.field.pwaInstallOrientationAria': 'PWA-Installationsorientierung', + 'settings.openchamber.visual.field.selectOrientationPlaceholder': 'Ausrichtung auswählen', + 'settings.openchamber.visual.actions.resetInstallOrientationAria': 'Installationsorientierung zurücksetzen', + 'settings.openchamber.visual.field.mobileKeyboardMode': 'Verhalten der mobilen Tastatur', + 'settings.openchamber.visual.field.mobileKeyboardModeHint': 'Das Standard-Verhalten des Browsers ist am sichersten. Das Größenändern des Inhalts bittet unterstützte Browser dazu, die App zu verkleinern, wenn die On-Screen-Tastatur geöffnet wird.', + 'settings.openchamber.visual.field.mobileKeyboardModeAria': 'Verhalten der mobilen Tastatur', + 'settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder': 'Tastaturverhalten auswählen', + 'settings.openchamber.visual.actions.resetMobileKeyboardModeAria': 'Tastaturverhalten zurücksetzen', + 'settings.openchamber.visual.field.interfaceFontSize': 'Schriftgröße der Benutzeroberfläche', + 'settings.openchamber.visual.field.interfaceFont': 'Schriftart der Benutzeroberfläche', + 'settings.openchamber.visual.field.selectInterfaceFontAria': 'Schriftart der Benutzeroberfläche auswählen', + 'settings.openchamber.visual.actions.resetInterfaceFontAria': 'Schriftart der Benutzeroberfläche zurücksetzen', + 'settings.openchamber.visual.field.fontSizePercentageAria': 'Prozentsatz der Schriftgröße', + 'settings.openchamber.visual.actions.resetFontSizeAria': 'Schriftgröße zurücksetzen', + 'settings.openchamber.visual.field.terminalFontSize': 'Schriftgröße des Terminals', + 'settings.openchamber.visual.field.editorFontSize': 'Schriftgröße des Editors', + 'settings.openchamber.visual.field.codeFont': 'Schriftart für Code', + 'settings.openchamber.visual.field.selectCodeFontAria': 'Schriftart für Code auswählen', + 'settings.openchamber.visual.actions.resetCodeFontAria': 'Schriftart für Code zurücksetzen', + 'settings.openchamber.visual.actions.resetTerminalFontSizeAria': 'Schriftgröße des Terminals zurücksetzen', + 'settings.openchamber.visual.actions.resetEditorFontSizeAria': 'Schriftgröße des Editors zurücksetzen', + 'settings.openchamber.visual.field.spacingDensity': 'Abstandsdichte', + 'settings.openchamber.visual.actions.resetSpacingAria': 'Abstände zurücksetzen', + 'settings.openchamber.visual.field.inputBarOffset': 'Versatz der Eingabeleiste', + 'settings.openchamber.visual.field.inputBarOffsetTooltip': 'Eingabeleiste anheben, um Bildschirmverdeckungen auf Betriebssystemebene wie Home-Bars zu vermeiden.', + 'settings.openchamber.visual.actions.resetInputBarOffsetAria': 'Versatz der Eingabeleiste zurücksetzen', + 'settings.openchamber.visual.field.terminalQuickKeysAria': 'Schnelltasten des Terminals', + 'settings.openchamber.visual.field.terminalQuickKeys': 'Schnelltasten des Terminals', + 'settings.openchamber.visual.field.terminalQuickKeysTooltip': 'Esc, Strg, Pfeiltasten in der Terminalansicht anzeigen', + 'settings.openchamber.visual.field.fileEditorKeymap': 'Tastaturlayout für Datei-Editor', + 'settings.openchamber.visual.option.fileEditorKeymap.default': 'Standard', + 'settings.openchamber.visual.option.fileEditorKeymap.vim': 'Vim', + 'settings.openchamber.visual.field.activityDefaultModeAria': 'Aktivitäts-Standardmodus: {option}', + 'settings.openchamber.visual.field.showExpandedBashToolsAria': 'Erweiterte Bash-Tools anzeigen', + 'settings.openchamber.visual.field.showExpandedEditToolsAria': 'Erweiterte Bearbeitungstools anzeigen', + 'settings.openchamber.visual.field.bash': 'Bash', + 'settings.openchamber.visual.field.editTools': 'Bearbeitungstools', + 'settings.openchamber.visual.field.userMessageRenderingAria': 'Benutzernachrichten-Rendering: {option}', + 'settings.openchamber.visual.field.mermaidRenderingAria': 'Mermaid-Rendering: {option}', + 'settings.openchamber.visual.field.diffLayoutAria': 'Diff-Layout: {option}', + 'settings.openchamber.visual.field.sessionRecap': 'Sitzungs-Zusammenfassung generieren', + 'settings.openchamber.visual.field.sessionRecapAria': 'Eine Zusammenfassung nach Abschluss des Agents generieren', + 'settings.openchamber.visual.field.sessionSuggestion': 'Nächste Benutzernachricht vorschlagen', + 'settings.openchamber.visual.field.sessionSuggestionAria': 'Eine vorgeschlagene nächste Benutzernachricht nach Abschluss des Agents generieren', + 'settings.openchamber.visual.field.sessionGoal': 'Sitzungsziele aktivieren', + 'settings.openchamber.visual.field.sessionGoalAria': 'Die Sitzung automatisch auf ein Ziel hin arbeiten lassen', + 'settings.openchamber.visual.goal.sectionTitle': 'Ziel', + 'settings.openchamber.visual.goal.budgetLabel': 'Standard-Token-Budget', + 'settings.openchamber.visual.goal.budgetAria': 'Ein Standard-Token-Budget für neue Ziele anwenden', + 'settings.openchamber.visual.goal.description': 'Aktiviere die Ziel-Schaltfläche im Composer. Die nächste Nachricht wird zu einem Ziel: Der Agent arbeitet automatisch daran, auch wenn du weg bist, kontrolliert vom kleinen Modell.', + 'settings.openchamber.visual.field.showReasoningTracesAria': 'Denkspuren anzeigen', + 'settings.openchamber.visual.field.showReasoningTraces': 'Denkspuren anzeigen', + 'settings.openchamber.visual.field.collapsibleThinkingBlocksAria': 'Zusammenklappbare Denkblöcke aktivieren', + 'settings.openchamber.visual.field.collapsibleThinkingBlocks': 'Zusammenklappbare Denkblöcke aktivieren', + 'settings.openchamber.visual.field.collapsibleUserMessagesAria': 'Lange Benutzernachrichten einklappen', + 'settings.openchamber.visual.field.collapsibleUserMessages': 'Lange Benutzernachrichten einklappen', + 'settings.openchamber.visual.field.stickyUserHeaderAria': 'Fixierter Benutzerheader', + 'settings.openchamber.visual.field.stickyUserHeader': 'Fixierter Benutzerkopf', + 'settings.openchamber.visual.field.promptNavigatorEnabledAria': 'Prompt-Navigator', + 'settings.openchamber.visual.field.promptNavigatorEnabled': 'Prompt-Navigator', + 'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Editor-Werkzeugleiste immer anzeigen', + 'settings.openchamber.visual.field.expandedEditorToolbar': 'Editor-Werkzeugleiste immer anzeigen (unter den Datei-Reitern angeheftet)', + 'settings.openchamber.visual.field.wideChatLayoutAria': 'Breites Chat-Layout', + 'settings.openchamber.visual.field.wideChatLayout': 'Breites Chat-Layout', + 'settings.openchamber.visual.field.codeBlockLineWrapAria': 'Codeblock-Zeilen umbrechen', + 'settings.openchamber.visual.field.codeBlockLineWrap': 'Codeblock-Zeilen umbrechen', + 'settings.openchamber.visual.field.showSplitAssistantMessageActionsAria': 'Inline-Assistant-Aktionen', + 'settings.openchamber.visual.field.showSplitAssistantMessageActions': 'Inline-Assistant-Aktionen', + 'settings.openchamber.visual.field.showSplitAssistantMessageActionsTooltip': 'Kopieren Sie Antwort, Speichern als Bild und Vorlesen in Assistant-Textblöcken anzeigen, die vor späteren Tool-Aufrufen in derselben Antwort erscheinen.', + 'settings.openchamber.visual.field.allowPromptingSubagentSessionsAria': 'Erlaube Prompting von Subagent-Sitzungen', + 'settings.openchamber.visual.field.allowPromptingSubagentSessions': 'Erlaube Prompting von Subagent-Sitzungen', + 'settings.openchamber.visual.field.showToolFileIconsAria': 'Tool-Datei-Symbole anzeigen', + 'settings.openchamber.visual.field.showToolFileIcons': 'Tool-Datei-Symbole anzeigen', + 'settings.openchamber.visual.field.showTurnChangedFilesAria': 'Geänderte Dateien für abgeschlossene Züge anzeigen', + 'settings.openchamber.visual.field.showTurnChangedFiles': 'Geänderte Dateien für abgeschlossene Züge anzeigen', + 'settings.openchamber.visual.field.showDotfilesAria': 'Dotfiles anzeigen', + 'settings.openchamber.visual.field.showDotfiles': 'Dotfiles anzeigen', + 'settings.openchamber.visual.field.queueMessagesByDefaultAria': 'Nachrichten standardmäßig in die Warteschlange stellen', + 'settings.openchamber.visual.field.queueMessagesByDefault': 'Nachrichten standardmäßig in die Warteschlange stellen', + 'settings.openchamber.visual.field.queueMessagesByDefaultTooltip': 'Wenn aktiviert, stellt Enter Nachrichten in die Warteschlange. Verwenden Sie {modifier}+Enter zum Senden.', + 'settings.openchamber.visual.field.persistDraftMessagesAria': 'Entwurfsnachrichten speichern', + 'settings.openchamber.visual.field.persistDraftMessages': 'Entwurfsnachrichten speichern', + 'settings.openchamber.visual.field.enableSpellcheckInTextInputsAria': 'Rechtschreibprüfung in Texteingaben aktivieren', + 'settings.openchamber.visual.field.enableSpellcheckInTextInputs': 'Rechtschreibprüfung in Texteingaben aktivieren', + 'settings.openchamber.visual.field.sendAnonymousUsageReportsAria': 'Anonyme Nutzungsberichte senden', + 'settings.openchamber.visual.field.sendAnonymousUsageReports': 'Anonyme Nutzungsberichte senden', + 'settings.openchamber.visual.field.sendAnonymousUsageReportsHint': 'Hilft uns zu verstehen, welche App-Versionen aktiv genutzt werden, damit wir Verbesserungen priorisieren können. Es werden nur die App-Version, Plattform und Laufzeit gesammelt - keine persönlichen Daten oder Code.', + 'settings.openchamber.visual.option.themeMode.system': 'System', + 'settings.openchamber.visual.option.themeMode.light': 'Hell', + 'settings.openchamber.visual.option.themeMode.dark': 'Dunkel', + 'settings.openchamber.visual.option.diffLayout.dynamic.label': 'Dynamisch', + 'settings.openchamber.visual.option.diffLayout.dynamic.description': 'Neu inline, geändert nebeneinander.', + 'settings.openchamber.visual.option.diffLayout.inline.label': 'Immer inline', + 'settings.openchamber.visual.option.diffLayout.inline.description': 'Als einheitliche Ansicht anzeigen.', + 'settings.openchamber.visual.option.diffLayout.sideBySide.label': 'Immer nebeneinander', + 'settings.openchamber.visual.option.diffLayout.sideBySide.description': 'Vergleiche Original- und geänderte Dateien.', + 'settings.openchamber.visual.option.mermaidRendering.svg.label': 'SVG', + 'settings.openchamber.visual.option.mermaidRendering.svg.description': 'Diagramme als skalierbare Grafiken rendern.', + 'settings.openchamber.visual.option.mermaidRendering.ascii.label': 'ASCII', + 'settings.openchamber.visual.option.mermaidRendering.ascii.description': 'Diagramme als Textblöcke rendern.', + 'settings.openchamber.visual.option.pwaOrientation.system.label': 'System folgen', + 'settings.openchamber.visual.option.pwaOrientation.system.description': 'Die Geräteorientierungseinstellung respektieren.', + 'settings.openchamber.visual.option.pwaOrientation.portrait.label': 'Hochformat sperren', + 'settings.openchamber.visual.option.pwaOrientation.portrait.description': 'App installieren, gesperrt im Hochformat.', + 'settings.openchamber.visual.option.pwaOrientation.landscape.label': 'Querformat sperren', + 'settings.openchamber.visual.option.pwaOrientation.landscape.description': 'App installieren, gesperrt im Querformat.', + 'settings.openchamber.visual.option.mobileKeyboardMode.native.label': 'Browser folgen', + 'settings.openchamber.visual.option.mobileKeyboardMode.native.description': 'Die Browser-Vorgabe für Panning und Viewport-Änderungen bei geöffneter Tastatur verwenden.', + 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.label': 'Inhalt skalieren', + 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.description': 'Fordert unterstützte Browser auf, das App-Layout zu verkleinern, anstatt nur auf Panning zu setzen.', + 'settings.openchamber.visual.option.userMessageRendering.markdown.label': 'Markdown', + 'settings.openchamber.visual.option.userMessageRendering.markdown.description': 'Benutzer-Text mit Markdown-Formatierung darstellen.', + 'settings.openchamber.visual.option.userMessageRendering.plain.label': 'Klartext', + 'settings.openchamber.visual.option.userMessageRendering.plain.description': 'Benutzer-Text mit erhaltenen Leerzeichen und Links darstellen.', + 'settings.openchamber.visual.option.chatRenderMode.sorted.label': 'Sortiert', + 'settings.openchamber.visual.option.chatRenderMode.sorted.description': 'Beendet Nachrichten des Assistenten ohne Live-Streaming darstellen.', + 'settings.openchamber.visual.option.chatRenderMode.live.label': 'Live', + 'settings.openchamber.visual.option.chatRenderMode.live.description': 'Streamt den Text und Werkzeuge des Assistenten, sobald sie eintreffen.', + 'settings.openchamber.visual.option.messageTransport.auto.label': 'Auto', + 'settings.openchamber.visual.option.messageTransport.auto.description': 'Bevorzugt WebSocket und wechselt bei Bedarf zu SSE.', + 'settings.openchamber.visual.option.messageTransport.ws.label': 'WebSocket', + 'settings.openchamber.visual.option.messageTransport.ws.description': 'Verwendet WebSocket für das Streamen von Nachrichten.', + 'settings.openchamber.visual.option.messageTransport.sse.label': 'SSE', + 'settings.openchamber.visual.option.messageTransport.sse.description': 'Verwendet Server-Sent Events für das Streamen von Nachrichten.', + 'settings.openchamber.visual.option.activityRenderMode.collapsed.label': 'Einklappen', + 'settings.openchamber.visual.option.activityRenderMode.collapsed.description': 'Aktivität standardmäßig einklappen.', + 'settings.openchamber.visual.option.activityRenderMode.summary.label': 'Erweitert', + 'settings.openchamber.visual.option.activityRenderMode.summary.description': 'Aktivität standardmäßig erweitern.', + 'settings.openchamber.visual.option.timeFormat.auto.label': 'Auto', + 'settings.openchamber.visual.option.timeFormat.auto.description': 'Verwendet die System-Lokalisierungseinstellung.', + 'settings.openchamber.visual.option.timeFormat.24h.label': '24-Stunden', + 'settings.openchamber.visual.option.timeFormat.24h.description': 'Zeigt die Zeit als 14:15 an.', + 'settings.openchamber.visual.option.timeFormat.12h.label': '12-Stunden', + 'settings.openchamber.visual.option.timeFormat.12h.description': 'Zeit als 02:15 PM anzeigen.', + 'settings.openchamber.visual.option.weekStart.auto.label': 'Automatisch', + 'settings.openchamber.visual.option.weekStart.auto.description': 'Lokale Wochenbeginn verwenden.', + 'settings.openchamber.visual.option.weekStart.monday.label': 'Montag', + 'settings.openchamber.visual.option.weekStart.sunday.label': 'Sonntag', + 'settings.magicPrompts.page.loading.aria': 'Wird geladen', + 'settings.magicPrompts.page.loading.text': 'Magic Prompts werden geladen...', + 'settings.magicPrompts.page.block.visiblePrompt': 'Sichtbare Eingabeaufforderung', + 'settings.magicPrompts.page.block.instructions': 'Anweisungen', + 'settings.magicPrompts.page.group.gitCommitGenerate.title': 'Commit-Erstellung', + 'settings.magicPrompts.page.group.gitCommitGenerate.description': 'Eingabeaufforderungen zur Commit-Nachrichtenerstellung: Sichtbare Benutzernachricht + versteckte Anweisungen.', + 'settings.magicPrompts.page.group.gitPrGenerate.title': 'PR-Erstellung', + 'settings.magicPrompts.page.group.gitPrGenerate.description': 'Eingabeaufforderungen zur PR-Titel-/Inhalts-Erstellung: Sichtbare Benutzernachricht + versteckte Anweisungen.', + 'settings.magicPrompts.page.group.githubPrReview.title': 'PR-Überprüfung', + 'settings.magicPrompts.page.group.githubPrReview.description': 'Eingabeaufforderungen für den PR-Überprüfungsprozess: Sichtbare Benutzernachricht + versteckte Anweisungspayload.', + 'settings.magicPrompts.page.group.githubIssueReview.title': 'Problem-Überprüfung', + 'settings.magicPrompts.page.group.githubIssueReview.description': 'Eingabeaufforderungen für den Problem-Überprüfungsprozess: Sichtbare Benutzernachricht + versteckte Anweisungspayload.', + 'settings.magicPrompts.page.group.githubPrFailedChecksReview.title': 'PR-Fehlgeschlagene Checks Überprüfung', + 'settings.magicPrompts.page.group.githubPrFailedChecksReview.description': 'Eingabeaufforderungen zur Analyse fehlgeschlagener PR-Checks.', + 'settings.magicPrompts.page.group.githubPrCommentsReview.title': 'PR-Kommentar-Überprüfung', + 'settings.magicPrompts.page.group.githubPrCommentsReview.description': 'Eingabeaufforderungen zur Analyse von PR-Kommentaren.', + 'settings.magicPrompts.page.group.githubSinglePrCommentReview.title': 'Einzelner PR-Kommentar-Überprüfung', + 'settings.magicPrompts.page.group.githubSinglePrCommentReview.description': 'Eingabeaufforderungen zur Analyse eines einzelnen PR-Kommentars.', + 'settings.magicPrompts.page.group.gitConflictResolve.title': 'Merge/Rebase Konfliktlösung', + 'settings.magicPrompts.page.group.gitConflictResolve.description': 'Eingabeaufforderungen beim Auflösen von Merge-/Rebase-Konflikten mit KI.', + 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.title': 'Cherry-pick-Konfliktlösung', + 'settings.magicPrompts.page.group.gitCherrypickConflictResolve.description': 'Prompts, die bei der Auflösung von Cherry-pick-Konflikten im Integrierungsfluss verwendet werden.', + 'settings.magicPrompts.page.group.planImprove.title': 'Plan verbessern', + 'settings.magicPrompts.page.group.planImprove.description': 'Versteckter Prompt, der beim Senden eines gespeicherten Plans in einen Verbesserungsfluss verwendet wird.', + 'settings.magicPrompts.page.group.planTodo.title': 'Todo-Planung', + 'settings.magicPrompts.page.group.planTodo.description': 'Versteckter Prompt, der beim Senden einer Todo in eine neue Planungssitzung verwendet wird.', + 'settings.magicPrompts.page.group.planImplement.title': 'Plan umsetzen', + 'settings.magicPrompts.page.group.planImplement.description': 'Versteckter Prompt, der beim Senden eines gespeicherten Plans in einen Umsetzungsfluss verwendet wird.', + 'settings.magicPrompts.page.group.sessionSummary.title': 'Sitzungszusammenfassung', + 'settings.magicPrompts.page.group.sessionSummary.description': 'Prompts, die vom /summary-Slash-Befehl verwendet werden: sichtbare Benutzermitteilung + versteckte Anweisungen. Nicht-destructiv - komprimiert nicht die Sitzungshistorie.', + 'settings.magicPrompts.page.group.sessionWorkspaceReview.title': 'Arbeitsbereichsüberprüfung', + 'settings.magicPrompts.page.group.sessionExplore.title': 'Codebase-Tour', + 'settings.magicPrompts.page.group.sessionExplore.description': 'Von dem /explore-Slash-Befehl verwendete Prompts: sichtbare Benutzer-Nachricht + versteckte Anweisungen. Untersucht das Repository und gibt eine strukturierte Orientierung – den Überblick, Hauptmodule, wie sie miteinander verbunden sind und wo man beginnen sollte – anstelle einer Datei-für-Datei-Ausgabe.', + 'settings.magicPrompts.page.group.sessionWorkspaceReview.description': 'Prompts, die vom /workspace-review-Slash-Befehl verwendet werden: sichtbare Benutzermitteilung + versteckte Anweisungen. Überprüft die Arbeitsbereichsdifferenz, ob sie ihr Ziel erreicht und korrekt sowie angemessen ist, klassifiziert Ergebnisse nach Schweregrad.', + 'settings.magicPrompts.page.group.sessionFeaturePlan.title': 'Feature-Planung', + 'settings.magicPrompts.page.group.sessionFeaturePlan.description': 'Von dem /plan-feature-Slash-Befehl verwendete Prompts: sichtbare Benutzer-Nachricht + versteckte Anweisungen. Führt einen geführten Dialog durch, der die Codebasis erforscht und klärende Fragen in kleinen Mengen stellt, bevor ein Implementierungsplan erstellt wird.', + 'settings.magicPrompts.page.group.sessionCatchUp.title': 'Nachholen', + 'settings.magicPrompts.page.group.sessionCatchUp.description': 'Von dem /catch-up-Slash-Befehl verwendete Prompts: sichtbare Benutzer-Nachricht + versteckte Anweisungen. Erstellt branch-awareen Kontext – die Commits des Branches, seinen PR und uncommittede Arbeit zusammen – um zu verstehen, was du getan hast, dann gibt es eine übersichtliche Zusammenfassung und einen produktorientierten nächsten Schritt.', + 'settings.magicPrompts.page.group.sessionDebug.title': 'Fehlersuche', + 'settings.magicPrompts.page.group.sessionDebug.description': 'Von dem /debug-Slash-Befehl verwendete Prompts: sichtbare Benutzer-Nachricht + versteckte Anweisungen. Führt eine geführte Ursachenforschung durch – erfasst das Symptom, bildet Hypothesen, prüft diese gegen den Code und bestätigt die Ursache, bevor eine Lösung vorgeschlagen wird.', + 'settings.magicPrompts.page.group.sessionWeigh.title': 'Optionen bewerten', + 'settings.magicPrompts.page.group.sessionWeigh.description': 'Von dem /weigh-Slash-Befehl verwendete Prompts: sichtbare Benutzer-Nachricht + versteckte Anweisungen. Untersucht den Code, legt dann 2-3 verschiedene Ansätze mit Kompromissen und einer Empfehlung dar – ohne einen Plan oder Code zu schreiben.', + 'settings.magicPrompts.page.group.sessionFusion.title': 'Fusion', + 'settings.magicPrompts.page.group.sessionFusion.description': 'Prompts, die verwendet werden, wenn mehrere Lauf-Ergebnisse in eine endgültige Antwort zusammengeführt werden: sichtbare Benutzer-Nachricht + versteckte Anweisungen vor den Quell-Ergebnissen.', + 'settings.magicPrompts.page.actions.resetting': 'Zurücksetzen...', + 'settings.magicPrompts.page.actions.resetAllOverrides': 'Alle Überschreibungen zurücksetzen', + 'settings.magicPrompts.page.actions.resetToDefault': 'Auf Standard zurücksetzen', + 'settings.magicPrompts.page.actions.save': 'Speichern', + 'settings.magicPrompts.page.placeholdersLabel': 'Platzhalter:', + 'settings.magicPrompts.page.validation.visiblePromptRequired': 'Sichtbarer Prompt darf nicht leer sein.', + 'settings.magicPrompts.page.status.unsavedChanges': 'Nicht gespeicherte Änderungen', + 'settings.magicPrompts.page.status.usingSavedOverride': 'Gespeicherte Überschreibung wird verwendet', + 'settings.magicPrompts.page.status.usingBuiltinDefault': 'Eingebaute Standardeinstellung wird verwendet', + 'settings.magicPrompts.page.toast.loadFailed': 'Fehler beim Laden der Magic Prompts', + 'settings.magicPrompts.page.toast.visiblePromptRequired': 'Sichtbarer Prompt darf nicht leer sein', + 'settings.magicPrompts.page.toast.saved': 'Magic Prompt gespeichert', + 'settings.magicPrompts.page.toast.saveFailed': 'Fehler beim Speichern des Magic Prompts', + 'settings.magicPrompts.page.toast.resetSuccess': 'Prompt auf Standard zurückgesetzt', + 'settings.magicPrompts.page.toast.resetFailed': 'Fehler beim Zurücksetzen des Prompts', + 'settings.magicPrompts.page.toast.resetAllSuccess': 'Alle Prompt-Überschreibungen zurückgesetzt', + 'settings.magicPrompts.page.toast.resetAllFailed': 'Fehler beim Zurücksetzen aller Prompts', + 'settings.openchamber.visual.section.followUpBehavior': 'Follow-up-Verhalten', + 'settings.openchamber.visual.section.followUpBehaviorAria': 'Follow-up-Verhalten', + 'settings.openchamber.visual.field.followUpBehaviorAria': 'Follow-up-Verhalten: {option}', + 'settings.openchamber.visual.field.followUpBehaviorDescription': 'Wählen Sie aus, was passiert, wenn Sie Enter auf einer Follow-up-Nachricht drücken, während der Agent noch antwortet.', + 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steuerung', + 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Warteschlange', + + 'settings.providers.page.quotaCredentials.accessToken': 'Zugriffstoken', + 'settings.providers.page.quotaCredentials.refreshToken': 'Aktualisierungstoken', + 'settings.providers.page.quotaCredentials.tokenPlaceholder': 'Token einfügen', + 'settings.view.nav.group.general': 'OpenChamber', + 'settings.view.nav.group.projects': 'Arbeitsbereich', + 'settings.view.nav.group.opencode': 'OpenCode', + 'settings.view.nav.group.content': 'Bibliothek', + 'settings.page.appearance.description': 'Passe an, wie OpenChamber aussieht und sich anfühlt.', + 'settings.page.chat.description': 'Konfiguriere, wie Nachrichten und Werkzeuge angezeigt werden.', + 'settings.page.shortcuts.description': 'Tastenkürzel anpassen.', + 'settings.page.general.title': 'Allgemein', + 'settings.page.general.description': 'App-Start, Sicherheit, Verbindung und Datenschutz.', + 'settings.page.sessions.description': 'Standardwerte und Aufbewahrung für Sitzungen festlegen.', + 'settings.page.notifications.description': 'Wähle, wann und wie du benachrichtigt wirst.', + 'settings.page.voice.description': 'Spracheingabe und Diktat konfigurieren.', + 'settings.page.tunnel.description': 'Diese Instanz über einen Remote-Tunnel bereitstellen.', + 'settings.common.infoAria': 'Weitere Informationen', + 'settings.common.status.saved': 'Gespeichert', + 'settings.common.status.saveFailed': 'Speichern fehlgeschlagen', + 'settings.agents.page.permissionsEditor.sectionInfo': 'Bearbeitet die eigenen Berechtigungsregeln dieses Agenten genau so, wie sie in seiner Konfiguration gespeichert sind. „Vererben“ lässt einen Schlüssel unset, damit globale Konfiguration und Standardwerte gelten.', + 'settings.agents.page.permissionsEditor.defaultLabel': 'Standard für alle Werkzeuge', + 'settings.agents.page.permissionsEditor.defaultAria': 'Standardberechtigung für alle Werkzeuge', + 'settings.agents.page.permissionsEditor.defaultInfo': 'Der agentenweite Fallback (der *-Schlüssel). Gilt für jedes Werkzeug ohne eigene Regel.', + 'settings.agents.page.permissionsEditor.effectiveHint': '→ {action}', + 'settings.agents.page.permissionsEditor.ruleCount': '{count} Regeln', + 'settings.agents.page.permissionsEditor.keyAria': 'Berechtigung für {key}', + 'settings.agents.page.permissionsEditor.patternPlaceholder': 'Muster, z. B. rm -rf * oder /pfad/**', + 'settings.agents.page.permissionsEditor.patternActionAria': 'Aktion für das Muster {key}', + 'settings.agents.page.permissionsEditor.customKeyPlaceholder': 'Benutzerdefinierter Berechtigungsschlüssel…', + 'settings.agents.page.permissionsEditor.sessionRulesTitle': 'In Sitzung gewährt (nicht gespeichert)', + 'settings.agents.page.permissionsEditor.sessionRulesInfo': 'Regeln, die während aktiver Sitzungen gewährt wurden (z. B. Antworten wie „immer erlauben“). Sie gelten sofort, sind aber kein Teil der gespeicherten Konfiguration und verschwinden beim Neustart.', + 'settings.agents.page.permissionsEditor.action.default': 'Standard', + 'settings.agents.page.permissionsEditor.action.inherit': 'Vererben', + 'settings.agents.page.permissionsEditor.action.allow': 'Erlauben', + 'settings.agents.page.permissionsEditor.action.ask': 'Nachfragen', + 'settings.agents.page.permissionsEditor.action.deny': 'Verweigern', + 'settings.agents.page.permissionsEditor.actions.save': 'Berechtigungen speichern', + 'settings.agents.page.permissionsEditor.actions.discard': 'Verwerfen', + 'settings.agents.page.permissionsEditor.actions.addRule': 'Regel hinzufügen', + 'settings.agents.page.permissionsEditor.actions.addKey': 'Schlüssel hinzufügen', + 'settings.agents.page.permissionsEditor.actions.removeRuleAria': 'Regel entfernen', + 'settings.agents.page.permissionsEditor.actions.retry': 'Erneut versuchen', + 'settings.agents.page.permissionsEditor.state.loadFailed': 'Agenten-Berechtigungskonfiguration konnte nicht geladen werden.', + 'settings.agents.page.permissionsEditor.toast.saved': 'Berechtigungen gespeichert', + 'settings.agents.page.permissionsEditor.toast.savedRestartRequired': 'Berechtigungen gespeichert — OpenCode-Server neu starten, um sie anzuwenden', + 'settings.agents.page.permissionsEditor.toast.saveFailed': 'Berechtigungen konnten nicht gespeichert werden', + 'settings.page.behavior.description': 'Lege fest, wie der Agent reagiert.', + 'settings.behavior.page.section.systemPromptOptimization': 'System-Prompt-Optimierung', + 'settings.behavior.page.systemPromptOptimization.enable': 'Größe des System-Prompts optimieren', + 'settings.behavior.page.systemPromptOptimization.enableAria': 'Größe des OpenCode-System-Prompts optimieren', + 'settings.behavior.page.systemPromptOptimization.info': 'Reduziert den System-Prompt für die Build- und Plan-Agenten schätzungsweise um 40 %. Andere Agenten bleiben unverändert. Dadurch können benutzerdefinierte Definitionen entfernt werden, die Build oder Plan überschreiben, daher nicht für Workflows aktivieren, die diese Agenten anpassen. Ein Neustart von OpenCode wendet die Änderung an.', + 'settings.behavior.page.systemPromptOptimization.restarting': 'OpenCode wird neu gestartet, um die System-Prompt-Optimierung anzuwenden…', + 'settings.openchamber.desktopNetwork.field.windowControls': 'Fenstersteuerungen', + 'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'Stil', + 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': 'Stil der Fenstersteuerungen', + 'settings.openchamber.desktopNetwork.option.windowControlsClassic': 'Klassisch', + 'settings.openchamber.desktopNetwork.option.windowControlsTrafficLights': 'Ampeln', + 'settings.openchamber.desktopNetwork.field.macMenuBarAria': 'OpenChamber in der macOS-Menüleiste anzeigen', + 'settings.openchamber.desktopNetwork.field.macMenuBar': 'OpenChamber in der Menüleiste anzeigen', + 'settings.openchamber.desktopNetwork.field.macMenuBarDescription': 'Erfordert einen Neustart der App. Wenn deaktiviert, erstellt OpenChamber weder den Menüleisten-Eintrag noch führt es dessen Sitzungs-, Genehmigungs- und Nutzungsaktualisierungen aus.', + 'settings.openchamber.desktopPassword.actions.showPassword': 'Passwort anzeigen', + 'settings.openchamber.desktopPassword.actions.hidePassword': 'Passwort verbergen', + 'settings.openchamber.opencodeCli.field.agentControlTool': 'Agenten-Steuerungswerkzeug', + 'settings.openchamber.opencodeCli.field.agentControlToolAria': 'Das Agenten-Steuerungswerkzeug aktivieren', + 'settings.openchamber.opencodeCli.field.agentControlToolInfo': 'Lässt Agenten deine Arbeit per Chat orchestrieren: Sitzungen und Arbeitsbereiche starten, Prompts an andere Agenten delegieren und geplante Aufgaben verwalten. Fügt jeder Sitzung eine kleine Werkzeugbeschreibung hinzu. Gilt nach Speichern + Neuladen.', + 'settings.openchamber.defaults.walkthroughModel.title': 'Walkthrough-Modell ändern', + 'settings.openchamber.defaults.walkthroughModel.description': 'Die KI-Prüfung deiner Änderungen benötigt strukturierten Output und Platz für einen ganzen Diff, den ein günstiges kleines Modell oft nicht liefern kann. Modelle, die der Katalog als nicht in der Lage zu strukturiertem Output meldet, werden in diesem Auswahlfeld ausgeblendet. Lasse es leer, dann wird das kleine Modell verwendet.', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Walkthrough-Modell', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Kleines Modell', + 'settings.voice.page.provider.openai': 'OpenAI', + 'settings.openchamber.visual.section.colorModeAndTheme': 'Farbmodus & Theme', + 'settings.openchamber.visual.section.densityAndType': 'Dichte & Typografie', + 'settings.openchamber.visual.section.appInstall': 'App-Installation', + 'settings.openchamber.visual.section.chatDisplay': 'Anzeige', + 'settings.openchamber.visual.section.chatMessageOptions': 'Nachrichtenoptionen', + 'settings.openchamber.visual.section.chatFeatures': 'Funktionen', + 'settings.openchamber.visual.field.terminalShell': 'Terminal-Shell', + 'settings.openchamber.visual.field.terminalShellAria': 'Terminal-Shell auswählen', + 'settings.openchamber.visual.field.terminalShellHint': 'Terminal neu starten, um diese Änderung auf die aktuelle Sitzung anzuwenden.', + 'settings.openchamber.visual.field.terminalLoginShell': 'Als Login-Shell starten', + 'settings.openchamber.visual.option.terminalShell.auto': 'Automatisch', + 'settings.openchamber.visual.field.autoSaveEnabledAria': 'Dateien automatisch speichern', + 'settings.openchamber.visual.field.autoSaveEnabled': 'Dateien automatisch speichern', + 'settings.openchamber.visual.field.autoSaveEnabledInfo': 'Dateiänderungen automatisch speichern, nachdem du mit dem Tippen aufgehört hast. Deaktivieren, um manuelles Speichern zu verlangen.', + 'settings.openchamber.visual.field.draftStartersVisible': 'Starter auf dem Bildschirm für neue Sitzungen anzeigen', + 'settings.openchamber.visual.field.draftStartersVisibleAria': 'Starter auf dem Bildschirm für neue Sitzungen anzeigen', + 'settings.openchamber.visual.option.themeMode.system.description': 'Systemeinstellung übernehmen', + 'settings.openchamber.visual.option.themeMode.light.description': 'Immer helles Erscheinungsbild verwenden', + 'settings.openchamber.visual.option.themeMode.dark.description': 'Immer dunkles Erscheinungsbild verwenden', + 'chat.message.userText.collapseAria': 'Benutzernachricht einklappen', +}; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts new file mode 100644 index 00000000..f9d325b9 --- /dev/null +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -0,0 +1,2908 @@ +import { settingsDict } from './de.settings'; + +export const dict = { + ...settingsDict, + 'common.language.german': 'Deutsch', + 'common.loading': 'Wird geladen...', + 'common.unavailable': 'Nicht verfügbar', + 'common.language.english': 'Englisch', + 'common.language.french': 'Französisch', + 'common.language.simplifiedChinese': 'Chinesisch (Vereinfacht)', + 'common.language.traditionalChinese': 'Chinesisch (Traditionell)', + 'common.language.ukrainian': 'Ukrainisch', + 'common.language.spanish': 'Spanisch', + 'common.language.brazilianPortuguese': 'Brasilianisches Portugiesisch', + 'common.language.korean': 'Koreanisch', + 'common.language.polish': 'Polnisch', + 'common.language.japanese': 'Japanisch', + 'common.revealPath.finder': 'Im Finder anzeigen', + 'common.revealPath.fileExplorer': 'In Datei-Explorer öffnen', + 'common.revealPath.fileManager': 'In Dateimanager öffnen', + 'pwa.installPrompt.description': 'Installiere OpenChamber für schnelleren Zugriff', + 'pwa.installPrompt.action': 'Installieren', + 'pwa.installPrompt.dismiss': 'Schließen', + 'pwa.installPrompt.started': 'Installation gestartet', + 'pwa.installPrompt.installed': 'OpenChamber installiert', + 'layout.mainTab.chat': 'Chat', + 'layout.mainTab.plan': 'Plan', + 'layout.mainTab.diff': 'Diff', + 'layout.mainTab.diagram': 'Diagramm', + 'layout.mainTab.files': 'Dateien', + 'layout.mainTab.terminal': 'Terminal', + 'layout.mainTab.context': 'Kontext', + 'mobile.nav.aria': 'Mobile Navigation', + 'mobile.connect.welcome.title': 'Mit OpenChamber verbinden', + 'mobile.connect.welcome.description': 'Füge eine Server-URL hinzu oder scanne einen Pairing-QR-Code, um die mobile App zu nutzen.', + 'mobile.connect.url.label': 'Server-URL', + 'mobile.connect.url.placeholder': 'http://192.168.1.74:2606', + 'mobile.connect.token.label': 'Client-Token', + 'mobile.connect.token.placeholder': 'Zugriffstoken einfügen', + 'mobile.connect.token.hint': 'Nur erforderlich, wenn Ihr Server ein Token anstelle eines Passworts erfordert.', + 'mobile.connect.password.label': 'Passwort', + 'mobile.connect.password.placeholder': 'OpenChamber-Passwort', + 'mobile.connect.connectButton': 'Verbinden', + 'mobile.connect.unlockButton': 'Entsperren und verbinden', + 'mobile.connect.cancelPassword': 'Anderen Server verwenden', + 'mobile.connect.connecting': 'Verbindung herstellen...', + 'mobile.connect.scanQr': 'QR-Code scannen', + 'mobile.connect.welcome.scanHint': 'Öffnen Sie auf Ihrem Computer «Gerät hinzufügen», um einen QR-Code anzuzeigen, und scannen Sie ihn dann hier.', + 'mobile.connect.advanced': 'Erweitert', + 'mobile.connect.manual.toggle': 'Verbindung über Adresse', + 'mobile.connect.scan.permissionDenied': 'Kamerazugriff ist ausgeschaltet. Aktivieren Sie ihn in den Einstellungen, um einen QR-Code zu scannen.', + 'mobile.connect.scan.failed': 'Konnte diesen QR-Code nicht scannen. Versuchen Sie es erneut oder geben Sie die URL manuell ein.', + 'mobile.connect.scan.invalid': 'Dieser QR-Code ist kein OpenChamber-Verbindungscode.', + 'mobile.connect.scan.unsupported': 'QR-Scanning ist nur in der installierten mobilen App verfügbar.', + 'mobile.connect.saved.title': 'Gespeicherte Verbindungen', + 'mobile.connect.saved.empty': 'Noch keine gespeicherten Verbindungen.', + 'mobile.connect.relay.badge': 'über OpenChamber Relay', + 'mobile.connect.error.urlRequired': 'Geben Sie eine Server-URL ein.', + 'mobile.connect.splash.connectingTo': 'Verbindung zum Gerät:', + 'mobile.connect.error.invalidUrl': 'Diese Server-URL ist ungültig.', + 'mobile.connect.error.unreachable': 'Konnte den OpenChamber-Server nicht erreichen.', + 'mobile.connect.error.authRequired': 'Dieser Server benötigt ein Passwort oder ein Client-Token.', + 'mobile.connect.error.passwordFailed': 'Konnte diesen Server nicht entsperren. Überprüfen Sie das Passwort.', + 'mobile.instances.addTitle': 'Instanz hinzufügen', + 'mobile.instances.addManual': 'Nach Adresse hinzufügen', + 'mobile.instances.editTitle': 'Instanz bearbeiten', + 'mobile.instances.edit': 'Bearbeiten', + 'mobile.instances.delete': 'Löschen', + 'mobile.instances.deleteAria': 'Lösche {label}', + 'mobile.instances.confirmDeleteAria': 'Löschen bestätigen {label}', + 'mobile.instances.cancelDeleteAria': '{label} behalten', + 'mobile.instances.cancelEdit': 'Abbrechen', + 'mobile.instances.label.label': 'Name', + 'mobile.instances.label.placeholder': 'Optionaler Anzeigename', + 'mobile.instances.saveNew': 'Instanz speichern', + 'mobile.instances.status.connectedDirect': 'Verbunden · Lokales Netzwerk', + 'mobile.instances.status.connectedRelay': 'Verbunden · Privater Relay', + 'mobile.instances.saveEdit': 'Änderungen speichern', + 'mobile.nav.changes': 'Änderungen', + 'mobile.nav.settings': 'Einstellungen', + 'mobile.surface.closeAria': 'Schließen', + 'mobile.header.openMetadataAria': 'Sitzungs-Metadaten öffnen', + 'mobile.header.metadata.context': 'Kontext', + 'mobile.header.metadata.usage': 'Nutzung', + 'mobile.menu.titleAria': 'Arbeitsbereichstools', + 'mobile.menu.files': 'Dateien', + 'mobile.menu.changes': 'Änderungen', + 'mobile.menu.mcp': 'MCP', + 'mobile.menu.instances': 'Instanzen', + 'mobile.menu.update': 'Aktualisieren', + 'mobile.menu.settings': 'Einstellungen', + 'mobile.sessions.newChatCta': 'Neuer Chat in {project}', + 'mobile.sessions.dateGroup.today': 'Heute', + 'mobile.sessions.dateGroup.yesterday': 'Gestern', + 'mobile.sessions.dateGroup.thisWeek': 'Früher diese Woche', + 'mobile.sessions.dateGroup.older': 'Älter', + 'mobile.sessions.section.worktrees': 'Worktrees', + 'mobile.sessions.section.otherProjects': 'Projekt wechseln', + 'mobile.sessions.section.projects': 'Projekte', + 'mobile.sessions.empty.noProjectsTitle': 'Noch keine Projekte', + 'mobile.sessions.empty.noProjectsDescription': 'Füge ein Projekt hinzu, um mit deinem Code zu chatten.', + 'mobile.sessions.empty.noSessionsTitle': 'Noch keine Sitzungen', + 'mobile.sessions.empty.noSessionsDescription': 'Starte deinen ersten Chat, um ihn hier zu sehen.', + 'mobile.sessions.empty.searchTitle': 'Keine Treffer', + 'mobile.sessions.empty.searchDescription': 'Versuche einen anderen Suchbegriff.', + 'mobile.sessions.showArchived': 'Archivierte anzeigen ({count})', + 'mobile.sessions.hideArchived': 'Archivierte ausblenden', + 'mobile.sessions.activeWorktreeAria': 'Aktives Worktree', + 'mobile.sessions.activeProjectAria': 'Aktives Projekt', + 'mobile.sessions.startNewChat': 'Neuen Chat starten', + 'mobile.sessions.newChat': 'Neuer Chat', + 'mobile.sessions.editOrder': 'Projekte neu anordnen', + 'mobile.sessions.doneEditing': 'Fertig', + 'mobile.sessions.editOrderHint': 'Ziehe den Griff, um die Projekte neu anzuordnen. Tippe auf das Häkchen, um zu beenden.', + 'mobile.sessions.editProjectAria': '{label} bearbeiten', + 'mobile.sessions.dragHandleAria': '{label} ziehen, um neu anzuordnen', + 'mobile.sessions.moveUpAria': '{label} nach oben verschieben', + 'mobile.sessions.moveDownAria': '{label} nach unten verschieben', + 'mobile.sessions.removeProjectAria': '{label} entfernen', + 'mobile.sessions.confirmRemoveProjectAria': 'Entfernen von {label} bestätigen', + 'mobile.sessions.toast.projectRemoved': '{label} entfernt', + 'mobile.sessions.archiveSessionAria': '{title} archivieren', + 'mobile.sessions.cancelArchiveAria': 'Archivierung von {title} abbrechen', + 'mobile.projectEdit.worktreesTitle': 'Worktrees', + 'mobile.projectEdit.worktreesEmpty': 'Noch keine Worktrees in diesem Projekt.', + 'mobile.projectEdit.reorderHint': 'Ziehen, um Worktrees neu anzuordnen.', + 'mobile.projectEdit.dragWorktreeAria': '{label} ziehen, um neu anzuordnen', + 'mobile.projectEdit.deleteWorktreeAria': 'Worktree {label} entfernen', + 'mobile.projectEdit.deleteWorktreeTitle': 'Worktree entfernen', + 'mobile.projectEdit.deleteWorktreeConfirmButton': 'Entfernen', + 'mobile.projectEdit.deleteWorktreeConfirm': 'Worktree „{name}“ entfernen? Dies kann nicht rückgängig gemacht werden.', + 'mobile.projectEdit.deleteWorktreeDirty': 'Dieser Worktree hat uncommittete Änderungen, die verloren gehen werden.', + 'mobile.projectEdit.deleteWorktreeArchiveNote': '{count} angehängte Sitzung(en) werden archiviert.', + 'mobile.projectEdit.deleteLocalBranch': 'Lokale Branch auch löschen', + 'mobile.projectEdit.deleteRemoteBranch': 'Entferne Remote-Branch', + 'mobile.sessions.showMore': '{count} weitere anzeigen', + 'mobile.sessions.search.section.sessions': 'Sitzungen', + 'mobile.sessions.search.section.archived': 'Archiviert', + 'mobile.sessions.search.section.projects': 'Projekte', + 'mobile.sessions.clearSearchAria': 'Suche löschen', + 'mobile.header.noProject': 'Projekt auswählen', + 'mobile.header.activeSession': 'Aktive Sitzung', + 'mobile.header.noSession': 'Keine aktive Sitzung', + 'mobile.sessions.openSheetAria': 'Sitzungen und Projekte öffnen', + 'mobile.sessions.closeSheetAria': 'Sitzungen und Projekte schließen', + 'mobile.sessions.sheet.title': 'Sitzungen', + 'mobile.sessions.sheet.description': 'Projekte wechseln, Sitzungen öffnen oder einen neuen Chat starten.', + 'mobile.sessions.search.placeholder': 'Sitzungen durchsuchen', + 'mobile.sessions.empty': 'Keine Sitzungen gefunden.', + 'mobile.sessions.unassignedProject': 'Andere Sitzungen', + 'mobile.sessions.newSessionAria': 'Neue Sitzung in diesem Projekt starten', + 'mobile.sessions.untitled': 'Unbenannte Sitzung', + 'mobile.sessions.project.sessionsSingle': '1 Sitzung', + 'mobile.sessions.project.sessionsPlural': '{count} Sitzungen', + 'mobile.files.refreshAria': 'Dateien aktualisieren', + 'mobile.files.backToParentAria': 'Zurück zu {name}', + 'mobile.files.rootDirectory': 'Projektdateien', + 'mobile.files.search.placeholder': 'Dateien durchsuchen', + 'mobile.files.search.empty': 'Keine Dateien gefunden.', + 'mobile.files.parentDirectory': 'Übergeordnetes Verzeichnis', + 'mobile.files.empty.noDirectory': 'Wählen Sie ein Projekt aus, um Dateien zu durchsuchen.', + 'mobile.files.empty.directory': 'Dieses Verzeichnis ist leer.', + 'mobile.files.error.listFailed': 'Fehler beim Laden der Dateien', + 'mobile.files.error.readUnavailable': 'Dateivorschau ist in dieser Laufzeit nicht verfügbar.', + 'mobile.files.file.truncated': 'Dateivorschau für mobile Geräte gekürzt.', + 'mobile.files.copyPathAria': 'Dateipfad kopieren', + 'mobile.files.copyContent': 'Inhalt kopieren', + 'mobile.files.copyContentAria': 'Dateiinhalt kopieren', + 'mobile.files.toast.pathCopied': 'Pfad kopiert', + 'mobile.files.toast.contentCopied': 'Inhalt kopiert', + 'mobile.files.toast.copyFailed': 'Kopieren fehlgeschlagen', + 'mobile.changes.placeholder.title': 'Änderungen', + 'mobile.changes.placeholder.description': 'Arbeitsbaum-Überprüfung, Synchronisierung und Commit-Aktionen werden hier angezeigt.', + 'mobile.changes.branchLabel': 'Branch: {branch}', + 'mobile.changes.noRemote': 'Kein Remote verfügbar', + 'mobile.changes.cleanDescription': 'Es gibt keine geänderten Dateien in dieser Arbeitsumgebung.', + 'mobile.changes.diffDetail.subtitle': 'Read-only Diff', + 'mobile.changes.diffDetail.loadFailed': 'Fehler beim Laden des Diffs', + 'mobile.changes.diffDetail.missingTitle': 'Datei wurde nicht mehr geändert', + 'mobile.changes.diffDetail.missingDescription': 'Zurück zu Änderungen gehen und Liste aktualisieren.', + 'mobile.changes.diffDetail.imageUnavailable': 'Bild-Diffs sind in mobilen Änderungen noch nicht verfügbar.', + 'mobile.settings.placeholder.title': 'Einstellungen', + 'mobile.settings.placeholder.description': 'Fokussierte mobile Verbindungs- und App-Einstellungen werden hier angezeigt.', + 'layout.rightSidebar.git': 'Git', + 'layout.rightSidebar.files': 'Dateien', + 'layout.rightSidebar.context': 'Kontext', + 'layout.services.instance': 'Instanz', + 'layout.services.usage': 'Nutzung', + 'sessions.sidebar.header.actions.closeSessions': 'Sitzungen schließen', + 'sessions.sidebar.header.actions.addProject': 'Projekt hinzufügen', + 'sessions.sidebar.header.actions.newSession': 'Neue Sitzung', + 'sessions.sidebar.header.actions.newMultiRun': 'Neuer Multi-Run', + 'sessions.sidebar.header.actions.scheduledTasks': 'Geplante Aufgaben', + 'sessions.scheduledTasks.dialog.title': 'Geplante Aufgaben', + 'sessions.scheduledTasks.dialog.description': 'Serverseitige Aufgaben, die eine neue Sitzung erstellen und einen konfigurierten Prompt senden.', + 'sessions.scheduledTasks.dialog.project.label': 'Projekt', + 'sessions.scheduledTasks.dialog.project.placeholder': 'Projekt auswählen', + 'sessions.scheduledTasks.dialog.project.empty': 'Keine Projekte', + 'sessions.scheduledTasks.dialog.actions.newTask': 'Neue Aufgabe', + 'sessions.scheduledTasks.dialog.actions.runNow': 'Jetzt ausführen', + 'sessions.scheduledTasks.dialog.actions.edit': 'Bearbeiten', + 'sessions.scheduledTasks.dialog.actions.editAria': 'Bearbeite {taskName}', + 'sessions.scheduledTasks.dialog.actions.deleteAria': 'Lösche {taskName}', + 'sessions.scheduledTasks.dialog.loading': 'Aufgaben werden geladen...', + 'sessions.scheduledTasks.dialog.empty.noTasks': 'Noch keine geplanten Aufgaben.', + 'sessions.scheduledTasks.dialog.empty.selectProject': 'Wählen Sie ein Projekt aus, um geplante Aufgaben zu verwalten.', + 'sessions.scheduledTasks.dialog.error.chooseProjectFirst': 'Wählen Sie zuerst ein Projekt', + 'sessions.scheduledTasks.dialog.toast.loadFailed': 'Fehler beim Laden der geplanten Aufgaben', + 'sessions.scheduledTasks.dialog.toast.saved': 'Geplante Aufgabe gespeichert', + 'sessions.scheduledTasks.dialog.toast.updateFailed': 'Fehler beim Aktualisieren der Aufgabe', + 'sessions.scheduledTasks.dialog.toast.deleted': 'Geplante Aufgabe gelöscht', + 'sessions.scheduledTasks.dialog.toast.deleteFailed': 'Fehler beim Löschen der Aufgabe', + 'sessions.scheduledTasks.dialog.toast.started': 'Aufgabe gestartet', + 'sessions.scheduledTasks.dialog.toast.runFailed': 'Fehler beim Ausführen der Aufgabe', + 'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Geplante Aufgabe "{taskName}" löschen?', + 'sessions.scheduledTasks.dialog.schedule.daily': 'Täglich {time}', + 'sessions.scheduledTasks.dialog.schedule.dailyWithTimezone': 'Täglich {time} ({timezone})', + 'sessions.scheduledTasks.dialog.schedule.weekly': 'Wöchentlich {days} {time}', + 'sessions.scheduledTasks.dialog.schedule.weeklyWithTimezone': 'Wöchentlich {days} {time} ({timezone})', + 'sessions.scheduledTasks.dialog.schedule.once': 'Einmalig {date} {time}', + 'sessions.scheduledTasks.dialog.schedule.onceWithTimezone': 'Einmalig {date} {time} ({timezone})', + 'sessions.scheduledTasks.dialog.schedule.cron': 'Cron: {cron}', + 'sessions.scheduledTasks.dialog.schedule.cronWithTimezone': 'Cron: {cron} ({timezone})', + 'sessions.scheduledTasks.dialog.schedule.unknownDate': 'Unbekanntes Datum', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.sun': 'So', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.mon': 'Mo', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.tue': 'Di', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.wed': 'Mi', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.thu': 'Do', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.fri': 'Fr', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.sat': 'Sa', + 'sessions.scheduledTasks.dialog.schedule.weekdayShort.unknown': '?', + 'sessions.scheduledTasks.dialog.relativeTime.inLessThanOneMinute': 'in <1m', + 'sessions.scheduledTasks.dialog.relativeTime.justNow': 'gerade jetzt', + 'sessions.scheduledTasks.dialog.relativeTime.inMinutes': 'in {count}m', + 'sessions.scheduledTasks.dialog.relativeTime.minutesAgo': '{count}m her', + 'sessions.scheduledTasks.dialog.relativeTime.inDuration': 'in {duration}', + 'sessions.scheduledTasks.dialog.relativeTime.durationAgo': '{duration} her', + 'sessions.scheduledTasks.dialog.status.success': 'Erfolg', + 'sessions.scheduledTasks.dialog.status.error': 'Fehler', + 'sessions.scheduledTasks.dialog.status.running': 'Läuft', + 'sessions.scheduledTasks.dialog.status.idle': 'Leerlauf', + 'sessions.scheduledTasks.dialog.nextRun.label': 'Nächste', + 'sessions.scheduledTasks.dialog.lastRun.label': 'Letzte Ausführung', + 'sessions.scheduledTasks.dialog.lastRun.runningNow': 'läuft gerade', + 'sessions.scheduledTasks.dialog.lastRun.never': 'nie', + 'sessions.scheduledTasks.dialog.taskToggle.enableAria': '{taskName} aktivieren', + 'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} pausieren', + 'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Aktiviert', + 'sessions.scheduledTasks.dialog.taskToggle.paused': 'Pausiert', + 'sessions.scheduledTasks.editor.title.edit': 'Geplante Aufgabe bearbeiten', + 'sessions.scheduledTasks.editor.title.new': 'Neue geplante Aufgabe', + 'sessions.scheduledTasks.editor.description': 'Konfigurieren Sie eine serverseitige Aufgabe, die eine neue Sitzung erstellt und eine Eingabeaufforderung sendet.', + 'sessions.scheduledTasks.editor.taskName.label': 'Aufgabenname', + 'sessions.scheduledTasks.editor.taskName.placeholder': 'Tägliche Synchronisierung', + 'sessions.scheduledTasks.editor.scheduleType.label': 'Zeitplan-Typ', + 'sessions.scheduledTasks.editor.scheduleType.daily': 'Täglich', + 'sessions.scheduledTasks.editor.scheduleType.weekly': 'Wöchentlich', + 'sessions.scheduledTasks.editor.scheduleType.once': 'Einmalig', + 'sessions.scheduledTasks.editor.scheduleType.cron': 'Cron', + 'sessions.scheduledTasks.editor.date.label': 'Datum', + 'sessions.scheduledTasks.editor.date.placeholder': 'Datum auswählen', + 'sessions.scheduledTasks.editor.date.previousMonth': 'Vorheriger Monat', + 'sessions.scheduledTasks.editor.date.nextMonth': 'Nächster Monat', + 'sessions.scheduledTasks.editor.date.jumpToToday': 'Zu heute springen', + 'sessions.scheduledTasks.editor.date.today': 'Heute', + 'sessions.scheduledTasks.editor.time.label': 'Uhrzeit', + 'sessions.scheduledTasks.editor.time.hourAria': 'Stunden', + 'sessions.scheduledTasks.editor.time.minuteAria': 'Minuten', + 'sessions.scheduledTasks.editor.time.periodAria': 'Zeitperiode', + 'sessions.scheduledTasks.editor.time.period.am': 'AM', + 'sessions.scheduledTasks.editor.time.period.pm': 'PM', + 'sessions.scheduledTasks.editor.times.label': 'Zeiten', + 'sessions.scheduledTasks.editor.times.add': 'Zeit hinzufügen', + 'sessions.scheduledTasks.editor.times.removeAria': 'Zeit entfernen', + 'sessions.scheduledTasks.editor.timezone.label': 'Zeitzone', + 'sessions.scheduledTasks.editor.weekdays.label': 'Wochentage', + 'sessions.scheduledTasks.editor.model.label': 'Modell', + 'sessions.scheduledTasks.editor.thinkingLevel.label': 'Denklevel', + 'sessions.scheduledTasks.editor.thinkingLevel.default': 'Standard', + 'sessions.scheduledTasks.editor.agent.label': 'Agent', + 'sessions.scheduledTasks.editor.prompt.label': 'Prompt', + 'sessions.scheduledTasks.editor.prompt.placeholder': 'Zusammenfassung offener Aufgaben und Vorschläge für nächste Schritte', + 'sessions.scheduledTasks.editor.enabled.aria': 'Aufgabe aktivieren', + 'sessions.scheduledTasks.editor.enabled.label': 'Aktiviert', + 'sessions.scheduledTasks.editor.goal.label': 'Als Ziel ausführen', + 'sessions.scheduledTasks.editor.goal.aria': 'Führe diese Aufgabe als Ziel aus, das der Agent bis zum Abschluss verfolgt', + 'sessions.scheduledTasks.editor.goal.budgetLabel': 'Token-Budget', + 'sessions.scheduledTasks.editor.goal.budgetAria': 'Begrenze das Ziel auf ein Token-Budget', + 'sessions.scheduledTasks.editor.actions.closeAria': 'Schließen', + 'sessions.scheduledTasks.editor.actions.cancel': 'Abbrechen', + 'sessions.scheduledTasks.editor.actions.save': 'Speichern', + 'sessions.scheduledTasks.editor.actions.saving': 'Speichern...', + 'sessions.scheduledTasks.editor.toast.saveFailed': 'Fehler beim Speichern der Aufgabe', + 'sessions.scheduledTasks.editor.validation.taskNameRequired': 'Aufgabenname ist erforderlich', + 'sessions.scheduledTasks.editor.validation.promptRequired': 'Prompt ist erforderlich', + 'sessions.scheduledTasks.editor.validation.modelRequired': 'Modell ist erforderlich', + 'sessions.scheduledTasks.editor.validation.dateFormat': 'Datum muss im Format YYYY-MM-DD sein', + 'sessions.scheduledTasks.editor.validation.timeFormat': 'Zeit muss im Format HH:mm sein', + 'sessions.scheduledTasks.editor.validation.atLeastOneTime': 'Fügen Sie mindestens eine gültige Zeit hinzu', + 'sessions.scheduledTasks.editor.validation.atLeastOneWeekday': 'Wählen Sie mindestens einen Wochentag aus', + 'sessions.scheduledTasks.editor.validation.timezoneRequired': 'Zeitzone ist erforderlich', + 'sessions.scheduledTasks.editor.validation.cronRequired': 'Cron-Ausdruck ist erforderlich', + 'sessions.scheduledTasks.editor.validation.cronInvalid': 'Ungültiger Cron-Ausdruck', + 'sessions.scheduledTasks.editor.cronExpression.label': 'Cron-Ausdruck', + 'sessions.scheduledTasks.editor.cronExpression.placeholder': '*/5 * * * *', + 'sessions.scheduledTasks.editor.cronExpression.nextRuns': 'Nächste Ausführungen', + 'sessions.scheduledTasks.editor.cronExpression.examples': 'Beispiele', + 'sessions.scheduledTasks.editor.cronExpression.examples.every5min': 'Alle 5 Minuten', + 'sessions.scheduledTasks.editor.cronExpression.examples.everyHour': 'Stündlich', + 'sessions.scheduledTasks.editor.cronExpression.examples.monday9am': 'Jeden Montag um 9 Uhr', + 'sessions.scheduledTasks.editor.cronExpression.examples.9am5pm': 'Täglich 9 Uhr und 17 Uhr', + 'sessions.scheduledTasks.editor.cronExpression.examples.firstOfMonth': 'Erster Tag jeden Monats', + 'multirun.launcher.title': 'Neuer Multi-Run', + 'multirun.launcher.actions.closeEsc': 'Schließen (Esc)', + 'multirun.launcher.actions.cancel': 'Abbrechen', + 'multirun.launcher.actions.creating': 'Erstellen...', + 'multirun.launcher.actions.startWithModelCount': 'Start ({count} Modelle)', + 'multirun.launcher.project.label': 'Projekt', + 'multirun.launcher.project.placeholder': 'Projekt auswählen', + 'multirun.launcher.project.empty': 'Fügen Sie zunächst ein Projekt hinzu.', + 'multirun.launcher.project.gitRequired': 'Keine Worktree-Isolation: Laufende Prozesse verwenden dasselbe Verzeichnis.', + 'multirun.launcher.groupName.label': 'Gruppenname', + 'multirun.launcher.groupName.info': 'Wird für Worktree-Verzeichnis und Branch-Namen verwendet', + 'multirun.launcher.groupName.placeholder': 'feature-auth, bugfix-login', + 'multirun.launcher.baseBranch.label': 'Basis-Branch', + 'multirun.launcher.baseBranch.info': 'Neuer Branch wird von diesem Basis-Branch pro Modell erstellt', + 'multirun.launcher.isolateRuns.label': 'Lauf isolation', + 'multirun.launcher.agent.label': 'Agent', + 'multirun.launcher.agent.info': 'Agent, der für alle Laufenden verwendet wird. Standardmäßig Ihr konfigurierter Agent.', + 'multirun.launcher.setupCommands.label': 'Setup-Befehle', + 'multirun.launcher.setupCommands.loading': 'Laden...', + 'multirun.launcher.setupCommands.commandPlaceholder': 'bun install', + 'multirun.launcher.setupCommands.removeCommandAria': 'Befehl entfernen', + 'multirun.launcher.setupCommands.addCommand': 'Befehl hinzufügen', + 'multirun.launcher.prompt.label': 'Eingabeaufforderung', + 'multirun.launcher.prompt.placeholder': 'Geben Sie die Eingabeaufforderung ein, die an alle Modelle gesendet werden soll...', + 'multirun.launcher.attachments.attach': 'Anhängen', + 'multirun.launcher.attachments.tooltip': 'Denselben Dateien an alle Durchläufe senden', + 'multirun.launcher.models.label': 'Modelle', + 'multirun.launcher.models.info': 'Wählen Sie 2-{max} Modelle. Das gleiche Modell kann mehrfach hinzugefügt werden.', + 'multirun.launcher.toast.fileTooLarge': 'Datei "{fileName}" ist zu groß (max. 10MB)', + 'multirun.launcher.toast.attachFailed': 'Fehler beim Anhängen von "{fileName}"', + 'multirun.launcher.toast.attachedSingle': '{count} Datei angehängt', + 'multirun.launcher.toast.attachedPlural': '{count} Dateien angehängt', + 'multirun.modelMultiSelect.actions.addModel': 'Modell hinzufügen', + 'multirun.modelMultiSelect.search.placeholder': 'Modelle durchsuchen', + 'multirun.modelMultiSelect.search.noResults': 'Keine Modelle gefunden', + 'multirun.modelMultiSelect.sections.favorites': 'Favoriten', + 'multirun.modelMultiSelect.sections.recent': 'Kürzlich', + 'multirun.modelMultiSelect.variant.placeholder': 'Wird nachgedacht', + 'multirun.modelMultiSelect.variant.default': 'Standard', + 'multirun.modelMultiSelect.keyboard.hint': '↑↓ navigieren • Enter auswählen • Esc schließen', + 'multirun.modelMultiSelect.validation.minOnly': 'Wählen Sie mindestens {min} Modelle.', + 'multirun.modelMultiSelect.validation.minToMax': 'Wählen Sie zwischen {min} und {max} Modellen.', + 'multirun.agentSelector.placeholder': 'Einen Agenten auswählen', + 'multirun.fusion.title': 'Fusion starten', + 'multirun.fusion.description': 'Kombinieren Sie die ausgewählten Multi-Run-Ergebnisse zu einer stärkeren Antwort.', + 'multirun.fusion.provider.placeholder': 'Anbieter', + 'multirun.fusion.model.placeholder': 'Modell', + 'multirun.fusion.sources.label': 'Quellen ({count})', + 'multirun.fusion.actions.cancel': 'Abbrechen', + 'multirun.fusion.actions.start': 'Fusion starten', + 'multirun.fusion.actions.starting': 'Starte...', + 'multirun.fusion.toast.noOutputs': 'Keine Assistentenausgaben zum Zusammenführen gefunden.', + 'multirun.fusion.toast.failed': 'Start des Zusammenführens fehlgeschlagen.', + 'multirun.launcher.attachments.label': 'Dateien', + 'multirun.launcher.actions.startWithRunCount': 'Start ({count} Durchläufe)', + 'multirun.launcher.groups.addGroup': 'Durchlaufgruppe hinzufügen', + 'multirun.launcher.groups.removeGroup': 'Gruppe entfernen', + 'multirun.launcher.groups.groupLabel': 'Durchlaufgruppe {index}', + 'multirun.launcher.groups.template.label': 'Vorlage', + 'multirun.launcher.groups.template.placeholder': 'Eine Vorlage auswählen...', + 'multirun.launcher.groups.template.custom': 'Benutzerdefinierter Prompt', + 'multirun.launcher.groups.prompt.label': 'Prompt', + 'multirun.launcher.groups.prompt.placeholder': 'Prompt für diese Gruppe eingeben...', + 'sessions.sidebar.header.actions.searchSessions': 'Sitzungen durchsuchen', + 'sessions.sidebar.header.actions.exitSelection': 'Auswahl beenden', + 'sessions.sidebar.header.actions.selectSessions': 'Sitzungen auswählen', + 'sessions.sidebar.header.actions.sortProjects': 'Projekte sortieren', + 'sessions.sidebar.header.actions.sessionDisplayMode': 'Sitzungs-Anzeigemodus', + 'sessions.sidebar.header.displayMode.label': 'Anzeigemodus', + 'sessions.sidebar.header.displayMode.default': 'Standard', + 'sessions.sidebar.header.displayMode.minimal': 'Minimal', + 'sessions.sidebar.header.displayMode.showRecent': 'Letzten Abschnitt anzeigen', + 'sessions.sidebar.header.displayMode.showArchived': 'Archivierte Sitzungen anzeigen', + 'sessions.sidebar.header.displayMode.collapseAll': 'Alle einklappen', + 'sessions.sidebar.header.displayMode.expandAll': 'Alle ausklappen', + 'sessions.sidebar.header.projectSort.manual': 'Manuell', + 'sessions.sidebar.header.projectSort.aToZ': 'A → Z', + 'sessions.sidebar.header.projectSort.zToA': 'Z → A', + 'sessions.sidebar.header.projectSort.dateAdded': 'Neueste', + 'sessions.sidebar.header.projectSort.recent': 'Kürzlich', + 'sessions.sidebar.header.search.matchCountSingle': '{count} Treffer', + 'sessions.sidebar.header.search.matchCountPlural': '{count} Treffer', + 'sessions.sidebar.header.search.escapeHint': 'Esc zum Löschen', + 'sessions.sidebar.header.search.placeholder': 'Sitzungen durchsuchen...', + 'sessions.sidebar.header.search.clear': 'Suche löschen', + 'sessions.sidebar.footer.actions.settings': 'Einstellungen', + 'sessions.sidebar.footer.actions.shortcuts': 'Tastenkürzel', + 'sessions.sidebar.footer.actions.aboutOpenChamber': 'Über OpenChamber', + 'sessions.sidebar.footer.actions.update': 'Aktualisieren', + 'sessions.sidebar.empty.noSessions.title': 'Noch keine Sitzungen', + 'sessions.sidebar.empty.noSessions.description': 'Erstellen Sie Ihre erste Sitzung, um zu coden.', + 'sessions.sidebar.empty.noMatches.title': 'Keine passenden Sitzungen', + 'sessions.sidebar.empty.noMatches.description': 'Versuchen Sie einen anderen Titel, Branch, Ordner oder Pfad.', + 'sessions.sidebar.activity.recentTitle': 'kürzlich', + 'sessions.switcher.openAria': 'Sitzungswechsler öffnen', + 'sessions.switcher.empty': 'Keine kürzlichen Sitzungen', + 'sessions.switcher.draftTitle': 'Neue Sitzung', + 'sessions.sidebar.updateCheck.errorTitle': 'Fehler beim Prüfen auf Aktualisierungen', + 'sessions.sidebar.updateCheck.latestVersion': 'Sie verwenden die neueste Version', + 'sessions.sidebar.directory.errorAddProjectTitle': 'Fehler beim Hinzufügen des Projekts', + 'sessions.sidebar.directory.errorAddProjectDescription': 'Bitte wählen Sie ein gültiges Verzeichnis aus.', + 'sessions.sidebar.directory.errorSelectDirectoryTitle': 'Fehler beim Auswählen des Verzeichnisses', + 'sessions.sidebar.folder.newFolderName': 'Neuer Ordner', + 'sessions.sidebar.bulkActions.delete': 'Löschen', + 'sessions.sidebar.bulkActions.archive': 'Archivieren', + 'sessions.sidebar.bulkActions.selectedCount': '{count} ausgewählt', + 'sessions.sidebar.bulkActions.moveToFolder': 'In Ordner verschieben', + 'sessions.sidebar.bulkActions.deletedSingle': '{count} Sitzung gelöscht', + 'sessions.sidebar.bulkActions.deletedPlural': '{count} Sitzungen gelöscht', + 'sessions.sidebar.bulkActions.failedDeleteSingle': 'Fehler beim Löschen von {count} Sitzung', + 'sessions.sidebar.bulkActions.failedDeletePlural': 'Fehler beim Löschen von {count} Sitzungen', + 'sessions.sidebar.bulkActions.archivedSingle': '{count} Sitzung archiviert', + 'sessions.sidebar.bulkActions.archivedPlural': '{count} Sitzungen archiviert', + 'sessions.sidebar.bulkActions.failedArchiveSingle': 'Fehler beim Archivieren von {count} Sitzung', + 'sessions.sidebar.bulkActions.failedArchivePlural': 'Fehler beim Archivieren von {count} Sitzungen', + 'sessions.sidebar.folders.none': 'Noch keine Ordner', + 'sessions.sidebar.folders.newFolderEllipsis': 'Neuer Ordner...', + 'sessions.sidebar.folders.removeFromFolder': 'Aus Ordner entfernen', + 'sessions.sidebar.folders.moveToFolder': 'In Ordner verschieben', + 'sessions.sidebar.project.actions.newWorktree': 'Neuer Worktree', + 'sessions.sidebar.project.actions.newWorktreeEllipsis': 'Neuer Worktree...', + 'sessions.sidebar.project.actions.projectMenu': 'Projektmenü', + 'sessions.sidebar.project.actions.newSession': 'Neue Sitzung', + 'sessions.sidebar.project.actions.edit': 'Bearbeiten', + 'sessions.sidebar.project.actions.closeProject': 'Projekt schließen', + 'sessions.sidebar.project.actions.newDraftSession': 'Neue Entwurfs-Sitzung', + 'sessions.sidebar.session.menu.rename': 'Umbenennen', + 'sessions.sidebar.session.rename.save': 'Sitzungsnamen speichern', + 'sessions.sidebar.session.rename.cancel': 'Umbenennung abbrechen', + 'sessions.sidebar.session.menu.unpin': 'Sitzung lösen', + 'sessions.sidebar.session.menu.pin': 'Sitzung anheften', + 'sessions.sidebar.session.menu.share': 'Teilen', + 'sessions.sidebar.session.menu.copied': 'Kopiert', + 'sessions.sidebar.session.menu.copyLink': 'Link kopieren', + 'sessions.sidebar.session.menu.unshare': 'Freigabe aufheben', + 'sessions.sidebar.session.menu.exportMarkdown': 'Als Markdown exportieren', + 'sessions.sidebar.session.menu.runFusion': 'Fusion ausführen', + 'sessions.sidebar.session.menu.openInSidePanel': 'In Seitenleiste öffnen', + 'sessions.sidebar.session.actions.openInEditor': 'Im Editor öffnen', + 'sessions.sidebar.session.menu.betaBadge': 'Beta', + 'sessions.sidebar.session.menu.label': 'Sitzungsmenü', + 'sessions.sidebar.session.untitled': 'Unbenannte Sitzung', + 'sessions.sidebar.session.export.nothingToExport': 'Nichts zum Exportieren', + 'sessions.sidebar.session.export.success': 'Sitzung exportiert', + 'sessions.sidebar.session.export.failedRevealPath': 'Pfad konnte nicht angezeigt werden', + 'sessions.sidebar.session.export.untitledSubagent': 'Unbenanntes Sub-Agent', + 'sessions.sidebar.session.export.skippedSubtaskSingle': 'Sitzung exportiert, aber {count} Unteraufgabe des Sub-Agents wurde übersprungen, da sie nicht geladen werden konnte.', + 'sessions.sidebar.session.export.skippedSubtaskMany': 'Sitzung exportiert, aber {count} Unteraufgaben des Sub-Agents wurden übersprungen, da sie nicht geladen werden konnten.', + 'sessions.sidebar.session.export.dialog.title': 'Markdown exportieren', + 'sessions.sidebar.session.export.dialog.descriptionSingle': 'Diese Sitzung hat {count} Unteraufgabe des Sub-Agents. Soll diese in den Export inkludiert werden?', + 'sessions.sidebar.session.export.dialog.descriptionMany': 'Diese Sitzung hat {count} Unteraufgaben des Sub-Agents. Sollen diese in den Export inkludiert werden?', + 'sessions.sidebar.session.export.dialog.includeSubtasks': 'Unteraufgaben des Sub-Agents einbeziehen', + 'sessions.sidebar.session.export.dialog.confirm': 'Exportieren', + 'sessions.sidebar.session.status.active': 'Sitzung aktiv', + 'sessions.sidebar.session.status.unread': 'Ungelesene Updates', + 'sessions.sidebar.session.status.pinned': 'Angeheftete Sitzung', + 'sessions.sidebar.session.status.permissionRequired': 'Berechtigung erforderlich', + 'sessions.sidebar.session.subsessions.collapse': 'Untersitzungen einklappen', + 'sessions.sidebar.session.subsessions.expand': 'Untersitzungen ausklappen', + 'sessions.sidebar.dialogs.deleteSession.title': 'Sitzung löschen?', + 'sessions.sidebar.dialogs.archiveSession.title': 'Sitzung archivieren?', + 'sessions.sidebar.dialogs.deleteSession.withOneSubtask': '"{sessionTitle}" und seine {count} Unteraufgabe werden dauerhaft gelöscht.', + 'sessions.sidebar.dialogs.deleteSession.withManySubtasks': '"{sessionTitle}" und seine {count} Unteraufgaben werden dauerhaft gelöscht.', + 'sessions.sidebar.dialogs.archiveSession.withOneSubtask': '"{sessionTitle}" und seine {count} Unteraufgabe werden archiviert.', + 'sessions.sidebar.dialogs.archiveSession.withManySubtasks': '"{sessionTitle}" und seine {count} Unteraufgaben werden archiviert.', + 'sessions.sidebar.dialogs.deleteSession.single': '"{sessionTitle}" wird dauerhaft gelöscht.', + 'sessions.sidebar.dialogs.archiveSession.single': '"{sessionTitle}" wird archiviert.', + 'sessions.sidebar.dialogs.neverAsk': 'Nicht mehr fragen', + 'sessions.sidebar.dialogs.cancel': 'Abbrechen', + 'sessions.sidebar.dialogs.deleteSession.titleAction': 'Sitzung löschen', + 'sessions.sidebar.dialogs.deleteSessions.titleAction': 'Sitzungen löschen', + 'sessions.sidebar.dialogs.deleteSessions.title': 'Sitzungen löschen?', + 'sessions.sidebar.dialogs.archiveSessions.title': 'Sitzungen archivieren?', + 'sessions.sidebar.dialogs.deleteSessions.singleDescription': '{count} Sitzung wird dauerhaft gelöscht.', + 'sessions.sidebar.dialogs.deleteSessions.pluralDescription': '{count} Sitzungen werden dauerhaft gelöscht.', + 'sessions.sidebar.dialogs.archiveSessions.singleDescription': '{count} Sitzung wird archiviert.', + 'sessions.sidebar.dialogs.archiveSessions.pluralDescription': '{count} Sitzungen werden archiviert.', + 'sessions.sidebar.dialogs.deleteFolder.title': 'Ordner löschen?', + 'sessions.sidebar.dialogs.deleteFolder.withOneSubfolder': '"{folderName}" wird zusammen mit {count} Unterordner gelöscht. Sitzungen darin werden nicht gelöscht.', + 'sessions.sidebar.dialogs.deleteFolder.withManySubfolders': '"{folderName}" wird zusammen mit {count} Unterordnern gelöscht. Sitzungen darin werden nicht gelöscht.', + 'sessions.sidebar.dialogs.deleteFolder.withContentsNoSubfolders': '"{folderName}" wird gelöscht. Sitzungen darin werden nicht gelöscht.', + 'sessions.sidebar.dialogs.deleteFolder.single': '"{folderName}" wird dauerhaft gelöscht.', + 'sessions.sidebar.dialogs.deleteResult.singleFailedToDelete': '{count} Sitzung konnte nicht gelöscht werden.', + 'sessions.sidebar.dialogs.deleteResult.manyFailedToDelete': '{count} Sitzungen konnten nicht gelöscht werden.', + 'sessions.sidebar.dialogs.deleteResult.singleFailedToArchive': '{count} Sitzung konnte nicht archiviert werden.', + 'sessions.sidebar.dialogs.deleteResult.manyFailedToArchive': '{count} Sitzungen konnten nicht archiviert werden.', + 'sessions.sidebar.dialogs.deleteResult.removedFromDate': 'Alle Sitzungen vom {dateLabel} entfernt.', + 'sessions.sidebar.dialogs.deleteResult.tryAgain': 'Bitte versuchen Sie es in einem Moment erneut.', + 'sessions.sidebar.dialogs.worktreeDelete.descriptionNoLinked': 'Dies entfernt den ausgewählten Worktree.', + 'sessions.sidebar.dialogs.worktreeDelete.descriptionOneLinked': 'Dies entfernt den ausgewählten Worktree und archiviert {count} verknüpfte Sitzung.', + 'sessions.sidebar.dialogs.worktreeDelete.descriptionManyLinked': 'Dies entfernt den ausgewählten Worktree und archiviert {count} verknüpfte Sitzungen.', + 'sessions.sidebar.dialogs.sessionDelete.descriptionOne': 'Diese Aktion entfernt dauerhaft 1 Sitzung.', + 'sessions.sidebar.dialogs.sessionDelete.descriptionOneWithDate': 'Diese Aktion entfernt dauerhaft 1 Sitzung vom {dateLabel}.', + 'sessions.sidebar.dialogs.sessionDelete.descriptionMany': 'Diese Aktion entfernt dauerhaft {count} Sitzungen.', + 'sessions.sidebar.dialogs.sessionDelete.descriptionManyWithDate': 'Diese Aktion entfernt dauerhaft {count} Sitzungen vom {dateLabel}.', + 'sessions.sidebar.dialogs.sessionList.more': '+{count} weitere', + 'sessions.sidebar.session.share.successTitle': 'Sitzung geteilt', + 'sessions.sidebar.session.share.successDescription': 'Sie können den Link aus dem Menü kopieren.', + 'sessions.sidebar.session.share.error': 'Sitzung kann nicht geteilt werden', + 'sessions.sidebar.session.share.copyUrlError': 'URL konnte nicht kopiert werden', + 'sessions.sidebar.session.unshare.success': 'Teilen der Sitzung aufgehoben', + 'sessions.sidebar.session.unshare.error': 'Sitzung kann nicht mehr geteilt werden', + 'sessions.sidebar.session.delete.success': 'Sitzung gelöscht', + 'sessions.sidebar.session.delete.error': 'Fehler beim Löschen der Sitzung', + 'sessions.sidebar.session.archive.success': 'Sitzung archiviert', + 'sessions.sidebar.session.archive.error': 'Fehler beim Archivieren der Sitzung', + 'sessions.sidebar.group.pr.checksPassed': '{success}/{total} Checks bestanden', + 'sessions.sidebar.group.pr.failingCount': '{count} fehlgeschlagen', + 'sessions.sidebar.group.pr.pendingCount': '{count} ausstehend', + 'sessions.sidebar.group.pr.conflictsOrBlocked': 'Konflikte oder blockiert', + 'sessions.sidebar.group.pr.mergeable': 'Zusammenführen möglich', + 'sessions.sidebar.group.pr.mergeState': 'Zusammenführungszustand: {state}', + 'sessions.sidebar.group.pr.status.merged': 'Zusammengeführt', + 'sessions.sidebar.group.pr.status.readyToMerge': 'Bereit zum Zusammenführen', + 'sessions.sidebar.group.pr.status.open': 'PR offen', + 'sessions.sidebar.group.pr.status.mergeConflicts': 'Zusammenführungskonflikte', + 'sessions.sidebar.group.pr.status.mergeBlocked': 'Zusammenführung blockiert', + 'sessions.sidebar.group.pr.status.draft': 'Entwurf PR', + 'sessions.sidebar.group.pr.status.closed': 'Geschlossen', + 'sessions.sidebar.group.empty.noArchivedSessions': 'Noch keine archivierten Sitzungen.', + 'sessions.sidebar.group.empty.noSessionsInWorkspace': 'Noch keine Sitzungen in diesem Arbeitsbereich.', + 'sessions.sidebar.group.showMore': 'Weitere Sitzungen anzeigen', + 'sessions.sidebar.group.showMoreSingle': '{count} weitere Sitzung anzeigen', + 'sessions.sidebar.group.showMorePlural': '{count} weitere Sitzungen anzeigen', + 'sessions.sidebar.group.showFewer': 'Weniger Sitzungen anzeigen', + 'sessions.sidebar.group.expandAria': '{label} erweitern', + 'sessions.sidebar.group.collapseAria': '{label} einklappen', + 'sessions.sidebar.group.actions.deleteArchivedInGroupAria': 'Archivierte Sitzungen in {label} löschen', + 'sessions.sidebar.group.actions.deleteArchivedSessions': 'Archivierte Sitzungen löschen', + 'sessions.sidebar.group.actions.deleteGroupAria': '{label} löschen', + 'sessions.sidebar.group.actions.deleteWorktree': 'Worktree löschen', + 'sessions.sidebar.group.actions.newDraftInGroupAria': 'Neue Entwurfs-Sitzung in {label}', + 'sessions.sidebar.grouping.projectRoot': 'Projektstamm', + 'sessions.sidebar.grouping.projectRootWithBranch': 'Projektstamm: {branch}', + 'sessions.sidebar.grouping.archived': 'archiviert', + 'sessions.sidebar.grouping.archivedDescription': 'Archivierte und nicht zugewiesene Sitzungen', + 'sessions.sidebar.folderItem.expandAria': 'Ordner {folderName} ausklappen', + 'sessions.sidebar.folderItem.collapseAria': 'Ordner {folderName} einklappen', + 'sessions.sidebar.folderItem.namePlaceholder': 'Ordnername', + 'sessions.sidebar.folderItem.newSessionAria': 'Neue Sitzung in {folderName}', + 'sessions.sidebar.folderItem.newSubfolderAria': 'Neuer Unterordner in {folderName}', + 'sessions.sidebar.folderItem.newSubfolder': 'Neuer Unterordner', + 'sessions.sidebar.folderItem.renameAria': 'Ordner {folderName} umbenennen', + 'sessions.sidebar.folderItem.deleteArchivedInFolderAria': 'Archivierte Sitzungen im Ordner {folderName} löschen', + 'sessions.sidebar.folderItem.deleteFolderAria': 'Ordner {folderName} löschen', + 'sessions.sidebar.folderItem.emptyFolder': 'Leerer Ordner', + 'sessions.sidebar.sessionDialogs.ok': 'OK', + 'sessions.sidebar.sessionDialogs.linkedSessionSingle': 'Verknüpfte Sitzung', + 'sessions.sidebar.sessionDialogs.linkedSessionPlural': 'Verknüpfte Sitzungen', + 'sessions.sidebar.sessionDialogs.delete.note': 'Worktree-Verzeichnisse bleiben unverändert. Untersitzungen, die mit den ausgewählten Sitzungen verknüpft sind, werden ebenfalls entfernt.', + 'sessions.sidebar.sessionDialogs.directory.errorSelectTitle': 'Fehler beim Auswählen des Verzeichnisses', + 'sessions.sidebar.sessionDialogs.directory.errorOpenTitle': 'Fehler beim Öffnen des Verzeichnisses', + 'sessions.sidebar.sessionDialogs.directory.errorOpenDescription': 'Desktop konnte keinen Dateizugriff gewähren.', + 'sessions.sidebar.sessionDialogs.directory.errorAddProjectTitle': 'Fehler beim Hinzufügen des Projekts', + 'sessions.sidebar.sessionDialogs.directory.errorAddProjectDescription': 'Bitte wählen Sie einen gültigen Verzeichnispfad aus.', + 'sessions.sidebar.sessionDialogs.worktree.errorRemoveTitle': 'Fehler beim Entfernen des Worktrees', + 'sessions.sidebar.sessionDialogs.worktree.removedTitle': 'Worktree entfernt', + 'sessions.sidebar.sessionDialogs.worktree.removed': 'Worktree entfernt.', + 'sessions.sidebar.sessionDialogs.worktree.removedWithRemote': 'Worktree und Remote-Branch entfernt.', + 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Angefügter Worktree archiviert.', + 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Angefügte Worktrees archiviert.', + 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Archivierte Worktrees und entfernte Remote-Branches.', + 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', + 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree-Pfad nicht verfügbar.', + 'sessions.sidebar.sessionDialogs.worktree.uncommittedWarning': 'Nicht committete Änderungen werden verworfen.', + 'sessions.sidebar.sessionDialogs.actions.deleteRemoteBranch': 'Entfernten Branch löschen', + 'sessions.sidebar.sessionDialogs.actions.remoteBranchInfoUnavailable': 'Informationen zum entfernten Branch nicht verfügbar', + 'sessions.sidebar.sessionDialogs.actions.deleteLocalBranch': 'Lokalen Branch löschen', + 'sessions.sidebar.sessionDialogs.actions.deleting': 'Wird gelöscht…', + 'sessions.sidebar.sessionDialogs.actions.deleteWorktree': 'Worktree löschen', + 'gitView.branch.branchToMergeInto': 'Branch zum Mergen in {branch}', + 'gitView.branch.branchToRebaseOnto': 'Branch zum Rebase auf', + 'gitView.branch.create': 'Neuen Branch erstellen...', + 'gitView.branch.currentBadge': 'Aktuell', + 'gitView.branch.currentBranchFallback': 'aktueller Branch', + 'gitView.branch.currentBranchTooltip': 'Aktueller Branch', + 'gitView.branch.detachedHead': 'Detached HEAD', + 'gitView.branch.dialogDescriptionPrefix': 'Wählen Sie, wie Sie einen anderen Branch in', + 'gitView.branch.empty': 'Keine Branches gefunden.', + 'gitView.branch.localBranches': 'Lokale Branches', + 'gitView.branch.mergeDescription': 'Erstellt einen Merge-Commit und erhält den Branch-Verlauf.', + 'gitView.branch.mergeRebase': 'Merge/Rebase', + 'gitView.branch.mergeRebaseTooltip': 'Aktualisiert diesen Branch von einem anderen Branch.', + 'gitView.branch.mergingInProgress': 'Merge läuft', + 'gitView.branch.namePlaceholder': 'Branch-Name', + 'gitView.branch.newBranchPlaceholder': 'Neuer Branch-Name', + 'gitView.branch.noLocalBranches': 'Keine lokalen Branches', + 'gitView.branch.noRemoteBranches': 'Keine entfernten Branches', + 'gitView.branch.operation': 'Operation', + 'gitView.branch.operationCompleted': 'Operation abgeschlossen', + 'gitView.branch.operationFailed': 'Operation fehlgeschlagen', + 'gitView.branch.pushToPrefix': 'Push zu Präfix', + 'gitView.branch.pushToSuffix': 'Push zu Suffix', + 'gitView.branch.rebaseDescription': 'Wende deine Commits auf den ausgewählten Branch an.', + 'gitView.branch.rebasingInProgress': 'Rebasing in Bearbeitung', + 'gitView.branch.remoteBranches': 'Remote-Branches', + 'gitView.branch.renameTitle': 'Branch umbenennen', + 'gitView.branch.renameSave': 'Branch-Namen speichern', + 'gitView.branch.renameSaving': 'Speichere Branch-Name', + 'gitView.branch.renameCancel': 'Umbenennung abbrechen', + 'gitView.branch.searchPlaceholder': 'Branches durchsuchen...', + 'gitView.branch.selectBranch': 'Branch auswählen', + 'gitView.branch.summaryMergeInfix': 'in', + 'gitView.branch.summaryMergePrefix': 'Dies wird zusammenführen', + 'gitView.branch.summaryRebaseInfix': 'auf', + 'gitView.branch.summaryRebasePrefix': 'Dies wird rebasen', + 'gitView.branch.updateDescriptionPrefix': 'Bringe die neuesten Änderungen in', + 'gitView.branch.updateTitle': 'Branch aktualisieren', + 'gitView.changes.changedFilesAria': 'Geänderte Dateien', + 'gitView.changes.clearSelectionAria': 'Dateiauswahl aufheben', + 'gitView.changes.collapseDirectoryAria': 'Verzeichnis {path} einklappen', + 'gitView.changes.expandDirectoryAria': 'Verzeichnis {path} ausklappen', + 'gitView.changes.revertAll': 'Alle rückgängig machen', + 'gitView.changes.revertAllDescriptionPlural': '{count} geänderte Dateien rückgängig machen? Dies kann nicht rückgängig gemacht werden.', + 'gitView.changes.revertAllDescriptionSingle': '{count} geänderte Datei rückgängig machen? Dies kann nicht rückgängig gemacht werden.', + 'gitView.changes.revertAllDialogTitle': 'Alle Änderungen rückgängig machen?', + 'gitView.changes.revertDirectoryAria': 'Änderungen in {path} rückgängig machen', + 'gitView.changes.revertDirectory': 'Ordner rückgängig machen', + 'gitView.changes.revertDirectoryDescriptionPlural': 'Dies wird lokale Änderungen in {count} Dateien unter {path} verwerfen.', + 'gitView.changes.revertDirectoryDescriptionSingle': 'Dies wird lokale Änderungen in {count} Datei unter {path} verwerfen.', + 'gitView.changes.revertDirectoryDialogTitle': 'Ordneränderungen rückgängig machen?', + 'gitView.changes.revertDirectoryTooltip': 'Ordneränderungen rückgängig machen', + 'gitView.changes.revertFileAria': 'Änderungen in {path} rückgängig machen', + 'gitView.changes.revertFileTooltip': 'Änderungen rückgängig machen', + 'gitView.changes.reverting': 'Rückgängig machen...', + 'gitView.changes.selectAllAria': 'Alle Dateien auswählen', + 'gitView.changes.selectFileAria': '{path} auswählen', + 'gitView.changes.stagedTitle': 'Bereitgestellt', + 'gitView.changes.resizeSplitAria': 'Größe der bereitgestellten und nicht-bereitgestellten Änderungen anpassen', + 'gitView.changes.stageAllAria': 'Alle Änderungen bereitstellen', + 'gitView.changes.stageDirectoryAria': 'Alle Änderungen in {path} bereitstellen', + 'gitView.changes.stageFileAria': '{path} bereitstellen', + 'gitView.changes.title': 'Änderungen', + 'gitView.changes.toggleDirectorySelectionAria': 'Auswahl für {path} umschalten', + 'gitView.changes.unstageAllAria': 'Alle Änderungen nicht mehr bereitstellen', + 'gitView.changes.unstageDirectoryAria': 'Alle Änderungen in {path} nicht mehr bereitstellen', + 'gitView.changes.unstageFileAria': '{path} nicht mehr bereitstellen', + 'gitView.commit.addGitmoji': 'Gitmoji hinzufügen', + 'gitView.commit.aiHighlights.insertAria': 'Einfügen aria-Label', + 'gitView.commit.aiHighlights.insertTooltip': 'Einfügen tooltip', + 'gitView.commit.aiHighlights.title': 'Highlights', + 'gitView.commit.commit': 'Commit', + 'gitView.commit.commitAria': 'Commit aria-Label', + 'gitView.commit.committing': 'Committen...', + 'gitView.commit.generate': 'Generieren', + 'gitView.commit.generateAria': 'Commit-Nachricht generieren', + 'gitView.commit.messagePlaceholder': 'Commit-Nachricht', + 'gitView.commit.push': 'Commit & synchronisieren', + 'gitView.commit.pushAria': 'Commit und synchronisieren', + 'gitView.commit.pushing': 'Synchronisieren...', + 'gitView.commit.selectFilesHint': 'Dateien in Änderungen auswählen, um Commit zu aktivieren.', + 'gitView.commit.stageFilesHint': 'Stagen Sie Dateien, um Commit zu aktivieren.', + 'gitView.commit.title': 'Commit', + 'gitView.common.cancel': 'Abbrechen', + 'gitView.common.close': 'Schließen', + 'gitView.common.done': 'Fertig', + 'gitView.common.processing': 'Verarbeitung läuft...', + 'gitView.common.reset': 'Zurücksetzen', + 'gitView.conflict.abortOperation': '{operation} abbrechen', + 'gitView.conflict.conflictedFiles': 'Konflikte in Dateien:', + 'gitView.conflict.continueLater': 'Später fortfahren', + 'gitView.conflict.detectedDescription': 'Lösen Sie die {operation}-Konflikte, um fortzufahren.', + 'gitView.conflict.detectedTitle': '{operation}-Konflikte erkannt', + 'gitView.conflict.errorLoadingDetails': 'Fehler beim Laden der Konflikt-Details: {message}', + 'gitView.conflict.headInfo': 'HEAD-Information:', + 'gitView.conflict.loadFailed': 'Fehler beim Laden der Konflikt-Details', + 'gitView.conflict.loading': 'Konflikt-Details werden geladen...', + 'gitView.conflict.noActiveSession': 'Keine aktive Sitzung', + 'gitView.conflict.noActiveSessionDescription': 'Öffnen oder erstellen Sie eine Sitzung, um Konflikte zu lösen.', + 'gitView.conflict.resolveCurrentSession': 'In aktueller Sitzung auflösen', + 'gitView.conflict.resolveNewSession': 'In neuer Sitzung auflösen', + 'gitView.empty.cleanDescription': 'Alle Änderungen wurden committet', + 'gitView.empty.cleanTitle': 'Arbeitsbaum ist sauber', + 'gitView.empty.pullBehindPlural': '{count} Commits abrufen', + 'gitView.empty.pullBehindSingle': '{count} Commit abrufen', + 'gitView.header.identityTooltip': 'Git-Identität', + 'gitView.header.noIdentity': 'Keine Identität', + 'gitView.header.noProfiles': 'Keine Profile zum Anwenden verfügbar.', + 'gitView.header.repositoryViews': 'Repository-Ansichten', + 'gitView.header.removeRemoteAria': 'Entferne Remote {name}', + 'gitView.header.removeRemoteTitle': 'Entferne Remote {name}', + 'gitView.header.upstreamSynced': 'synchronisiert', + 'gitView.header.upstreamTooltip': 'Verglichen mit {target}.', + 'gitView.header.upstreamTooltipTracking': 'Verglichen mit {target}. Die primären Synchronisierungs-Kennzeichen spiegeln immer noch {tracking} wider.', + 'gitView.history.actions.cancelButton': 'Abbrechen', + 'gitView.history.actions.checkout': 'Checkout', + 'gitView.history.actions.checkoutConfirm': 'Diesen Commit als getrennte HEAD auschecken?', + 'gitView.history.actions.cherryPick': 'Cherry-pick', + 'gitView.history.actions.cherryPickConfirm': 'Diesen Commit auf den aktuellen Branch cherry-picken?', + 'gitView.history.actions.conflictToastDescription': 'Konflikte in: {files}. Manuelles Auflösen und committen oder mit git cherry-pick/revert --abort abbrechen.', + 'gitView.history.actions.conflictToastTitle': 'Konflikt', + 'gitView.history.actions.confirmButton': 'Bestätigen', + 'gitView.history.actions.createBranch': 'Hier neuen Branch erstellen', + 'gitView.history.actions.createBranchConfirm': 'Erstellen', + 'gitView.history.actions.createBranchPlaceholder': 'Branch-Name', + 'gitView.history.actions.detachedHead': 'Ausgecheckt (getrennte HEAD)', + 'gitView.history.actions.merge': 'In aktuellen Branch mergen', + 'gitView.history.actions.mergeConfirm': 'Diesen Commit in den aktuellen Branch mergen?', + 'gitView.history.actions.rebase': 'Auf diesen rebasen', + 'gitView.history.actions.rebaseConfirm': 'Aktuellen Branch auf diesen Commit rebasen?', + 'gitView.history.actions.reset': 'Zurücksetzen...', + 'gitView.history.actions.resetHard': 'Hart — alle Änderungen verwerfen', + 'gitView.history.actions.resetHardConfirm': 'Hartes Zurücksetzen — HEAD bewegt sich, alle nicht committeten Änderungen werden dauerhaft verworfen.', + 'gitView.history.actions.resetHardConfirmButton': 'Änderungen verwerfen', + 'gitView.history.actions.resetMixed': 'Gemischt — Änderungen unstagen', + 'gitView.history.actions.resetMixedConfirm': 'Gemischtes Zurücksetzen — HEAD bewegt sich, Änderungen werden unstaged.', + 'gitView.history.actions.resetSoft': 'Weich — staged Änderungen behalten', + 'gitView.history.actions.resetSoftConfirm': 'Weiches Zurücksetzen — HEAD bewegt sich, staged Änderungen bleiben erhalten.', + 'gitView.history.actions.revert': 'Revert', + 'gitView.history.actions.revertConfirm': 'Einen Revert dieses Commits stagen?', + 'gitView.history.binary': 'Binär', + 'gitView.history.binaryNoDiff': 'Binäre Datei — kein Diff verfügbar', + 'gitView.history.commitsPlaceholder': 'Commits', + 'gitView.history.copySha': 'SHA kopieren', + 'gitView.history.diffError': 'Fehler beim Laden des Diffs. Klicken zum Wiederholen.', + 'gitView.history.largeDiffDescription': 'Die Darstellung kann langsam sein. Sie können den Diff dennoch durch Klicken unten ansehen.', + 'gitView.history.largeDiffTitle': 'Großer Diff ({count} geänderte Zeilen)', + 'gitView.history.loadingDiff': 'Lade Diff...', + 'gitView.history.loadingFiles': 'Lade Dateien...', + 'gitView.history.loadMore': 'Mehr laden', + 'gitView.history.loadingMore': 'Laden...', + 'gitView.history.logSize100': '100 Commits', + 'gitView.history.logSize25': '25 Commits', + 'gitView.history.logSize50': '50 Commits', + 'gitView.history.noCommits': 'Keine Commits gefunden', + 'gitView.history.noFiles': 'Keine Dateien', + 'gitView.history.renamedNoDiff': 'Umbenannte Datei — Diff nicht unterstützt', + 'gitView.history.renderDiffAnyway': 'Dennoch rendern', + 'gitView.history.title': 'Verlauf', + 'gitView.graph.title': 'Graph', + 'gitView.integrate.checking': 'Überprüfe…', + 'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick abgebrochen', + 'gitView.integrate.cherryPickConflictDescription': 'Lösen Sie die Cherry-pick-Konflikte, um fortzufahren.', + 'gitView.integrate.cherryPickConflictToast': 'Cherry-pick-Konflikte erkannt', + 'gitView.integrate.cherryPickContinueFailedToast': 'Fehler beim Fortsetzen des Cherry-picks', + 'gitView.integrate.cherryPickFinishedToast': 'Cherry-pick abgeschlossen', + 'gitView.integrate.commitsMovedDescriptionPlural': '{count} Commits nach {branch} verschoben.', + 'gitView.integrate.commitsMovedDescriptionSingle': '{count} Commit nach {branch} verschoben.', + 'gitView.integrate.commitsMovedToast': 'Commits verschoben', + 'gitView.integrate.commitsToMove': 'Zu verschiebende Commits', + 'gitView.integrate.conflictsInFiles': 'Konflikte in Dateien', + 'gitView.integrate.currentCommit': 'Aktueller Commit', + 'gitView.integrate.currentSession': 'Aktuelle Sitzung', + 'gitView.integrate.failedToMoveToast': 'Fehler beim Verschieben der Commits', + 'gitView.integrate.moreFiles': 'Weitere Dateien', + 'gitView.integrate.move': 'Verschieben', + 'gitView.integrate.moveCommits': 'Commits verschieben', + 'gitView.integrate.moving': 'Wird verschoben…', + 'gitView.integrate.newSession': 'Neue Sitzung', + 'gitView.integrate.noActiveSession': 'Keine aktive Sitzung', + 'gitView.integrate.noActiveSessionDescription': 'Öffne oder erstelle eine Sitzung, um fortzufahren.', + 'gitView.integrate.noCommitsToMove': 'Keine Commits zum Verschieben.', + 'gitView.integrate.noCommitsToMoveToast': 'Keine Commits zum Verschieben', + 'gitView.integrate.previewUnavailable': 'Vorschau nicht verfügbar', + 'gitView.integrate.showAll': 'Alle anzeigen', + 'gitView.integrate.showLess': 'Weniger anzeigen', + 'gitView.integrate.showingFirstCommits': 'Erste Commits werden angezeigt', + 'gitView.integrate.target': 'Ziel', + 'gitView.integrate.title': 'Commits erneut integrieren', + 'gitView.integrate.toMoveCount': 'Zu verschieben', + 'gitView.operation.abort': 'Abbrechen', + 'gitView.operation.continue': 'Fortsetzen', + 'gitView.operation.inProgressTitle': '{operation} in Bearbeitung', + 'gitView.operation.merge': 'Merge', + 'gitView.operation.mergeInProgressWithHead': 'Merge von {head}', + 'gitView.operation.mergingMessage': 'Merge: {message}', + 'gitView.operation.rebase': 'Rebase', + 'gitView.operation.rebaseInProgress': 'Rebase in Bearbeitung', + 'gitView.operation.rebasingOnto': 'Rebase von {headName} auf {onto}', + 'gitView.operation.resolveConflictsHint': 'Löse Konflikte und fahre dann mit dem Vorgang fort.', + 'gitView.operation.resolveWithAi': 'Mit KI auflösen', + 'gitView.stash.confirmButton': '{operation}', + 'gitView.stash.description': 'Stash Sie Ihre lokalen Änderungen vor dem Start der {operation}.', + 'gitView.stash.mergeWith': 'mit', + 'gitView.stash.rebaseOnto': 'auf', + 'gitView.stash.restoreAfterOperation': 'Stashed Änderungen nach {operation} wiederherstellen', + 'gitView.stash.restoreAria': 'Stashed Änderungen nach Operation wiederherstellen', + 'gitView.stash.stepRestore': 'Stashed Änderungen wiederherstellen', + 'gitView.stash.stepStash': 'Nicht committete Änderungen stashen', + 'gitView.stash.thisWill': 'Das wird folgendes tun:', + 'gitView.stash.title': 'Nicht committete Änderungen', + 'gitView.stashes.actions.apply': 'Anwenden', + 'gitView.stashes.actions.drop': 'Verwerfen', + 'gitView.stashes.actions.pop': 'Pop', + 'gitView.stashes.actions.stashCurrent': 'Aktuelle Änderungen stashen', + 'gitView.stashes.actions.stashCurrentWithCount': '{count} Dateien stashen', + 'gitView.stashes.confirm.drop': '{ref} verwerfen?', + 'gitView.stashes.description': 'Speichern, wiederherstellen und aufräumen von Git-Stashes für dieses Repository.', + 'gitView.stashes.empty.list': 'Noch keine Stashes.', + 'gitView.stashes.empty.search': 'Keine passenden Stashes gefunden.', + 'gitView.stashes.includeUntrackedHint': 'Nicht verfolgte Dateien werden automatisch mit einbezogen.', + 'gitView.stashes.fileCount': '{count} Dateien', + 'gitView.stashes.fileCountLoading': 'Dateien werden gezählt...', + 'gitView.stashes.itemNumber': '#{number}', + 'gitView.stashes.latestLabel': 'Neueste', + 'gitView.stashes.messagePlaceholder': 'Stash-Name', + 'gitView.stashes.searchPlaceholder': 'Stashes durchsuchen', + 'gitView.stashes.title': 'Stashes', + 'gitView.stashes.toast.applyFailed': 'Fehler beim Anwenden des Stashes', + 'gitView.stashes.toast.applySuccess': 'Stash angewendet', + 'gitView.stashes.toast.createFailed': 'Fehler beim Stashen der Änderungen', + 'gitView.stashes.toast.created': 'Änderungen gestappt', + 'gitView.stashes.toast.dropFailed': 'Fehler beim Löschen des Stash', + 'gitView.stashes.toast.dropSuccess': 'Stash gelöscht', + 'gitView.stashes.toast.loadFailed': 'Fehler beim Laden der Stashes', + 'gitView.stashes.toast.noChanges': 'Keine lokalen Änderungen zum Stapeln', + 'gitView.stashes.toast.popFailed': 'Fehler beim Abrufen des Stash', + 'gitView.stashes.toast.popSuccess': 'Stash abgerufen', + 'gitView.stashes.untitled': 'Unbenannter Stash', + 'gitView.sync.fetch': 'Abrufen', + 'gitView.sync.fetchFromRemote': 'Von {name} abrufen', + 'gitView.sync.fetchTooltip': 'Vom Remote abrufen', + 'gitView.sync.commitOrStashTooltip': 'Committen oder Stapeln Sie Ihre Änderungen vor der Synchronisation', + 'gitView.sync.moreActionsAria': 'Weitere Synchronisationsaktionen', + 'gitView.sync.noRemoteTooltip': 'Keine Remotes konfiguriert', + 'gitView.sync.pull': 'Herunterziehen', + 'gitView.sync.pullTooltip': 'Änderungen herunterziehen', + 'gitView.sync.pullTooltipBehind': 'Änderungen herunterziehen ({count} hinterher)', + 'gitView.sync.push': 'Hochladen', + 'gitView.sync.pushTooltip': 'Änderungen hochladen', + 'gitView.sync.pushTooltipAhead': 'Änderungen hochladen ({count} voraus)', + 'gitView.sync.sync': 'synchronisieren', + 'gitView.sync.syncChanges': 'Änderungen synchronisieren', + 'gitView.sync.syncChangesTooltip': 'Änderungen synchronisieren ({behind} runter, {ahead} rauf)', + 'gitView.sync.syncChangesWithCounts': 'Änderungen synchronisieren {behind}↓ {ahead}↑', + 'gitView.sync.syncCounts': '{behind}↓ {ahead}↑', + 'gitView.branch.actionsUnavailable': 'Zweigaktionen nicht verfügbar in diesem Repository-Zustand', + 'gitView.conflict.noDetailsAvailable': 'Keine Konfliktdetails verfügbar', + 'gitView.empty.notGitRepository': 'Dieses Verzeichnis ist kein Git-Repository', + 'gitView.empty.notGitRepositoryDescription': 'Initialisieren Sie Git in diesem Verzeichnis oder öffnen Sie ein Repository.', + 'gitView.empty.selectSessionOrDirectory': 'Wählen Sie eine Sitzung oder ein Verzeichnis aus, um den Git-Status anzuzeigen', + 'gitView.empty.worktreeFeaturesUnavailable': 'Worktree-Funktionen sind in diesem Arbeitsbereichsmodus nicht verfügbar.', + 'gitView.empty.worktreeSetupDescription': 'Arbeitstruktur-Einrichtung wird abgeschlossen und Repository-Zustand wird vorbereitet.', + 'gitView.empty.worktreeSetupInProgress': 'Worktree-Einrichtung läuft', + 'worktree.bootstrap.toast.failed': 'Worktree-Einrichtung fehlgeschlagen', + 'worktree.bootstrap.toast.failedDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung wurde nicht abgeschlossen.', + 'worktree.bootstrap.toast.timeoutDescription': 'Die Worktree wurde erstellt, aber die Hintergrund-Einrichtung hat ein Timeout.', + 'gitView.gitmoji.empty': 'Keine Gitmojis gefunden', + 'gitView.gitmoji.searchPlaceholder': 'Gitmojis durchsuchen...', + 'gitView.gitmoji.title': 'Gitmoji einfügen', + 'gitView.history.dialogDescription': 'Letzte Commits durchsuchen und geänderte Dateien prüfen.', + 'gitView.loading.checkingRepository': 'Repository wird überprüft...', + 'gitView.loading.loading': 'Wird geladen...', + 'gitView.pr.actions.add': 'Hinzufügen', + 'gitView.pr.actions.cancelEditing': 'Bearbeitung abbrechen', + 'gitView.pr.actions.cancelEditingAria': 'PR-Bearbeitung abbrechen', + 'gitView.pr.actions.createPr': 'PR erstellen', + 'gitView.pr.actions.edit': 'Bearbeiten', + 'gitView.pr.actions.editPr': 'PR bearbeiten', + 'gitView.pr.actions.editPrAria': 'Pull Request bearbeiten', + 'gitView.pr.actions.hide': 'Verstecken', + 'gitView.pr.actions.markReady': 'Als bereit markieren', + 'gitView.pr.actions.markReadyAria': 'Pull Request als bereit für Review markieren', + 'gitView.pr.actions.mergePr': 'PR mergen', + 'gitView.pr.actions.mergePrAria': 'Pull Request mergen', + 'gitView.pr.actions.openChecks': 'Prüfungen öffnen', + 'gitView.pr.actions.openChecksAria': 'Fehlgeschlagene Prüfungen öffnen', + 'gitView.pr.actions.openComments': 'Kommentare öffnen', + 'gitView.pr.actions.openCommentsAria': 'Pull Request-Kommentare öffnen', + 'gitView.pr.actions.openOnGitHub': 'Auf GitHub öffnen', + 'gitView.pr.actions.openOnGitHubAria': 'Pull Request auf GitHub öffnen', + 'gitView.pr.actions.openSettings': 'Einstellungen öffnen', + 'gitView.pr.actions.repo': 'Repository', + 'gitView.pr.actions.resolveFailedChecks': 'Fehlgeschlagene Checks auflösen', + 'gitView.pr.actions.resolveFailedChecksAria': 'Fehlgeschlagene Checks an Agent senden', + 'gitView.pr.actions.savePr': 'PR speichern', + 'gitView.pr.actions.savePrAria': 'Pull-Request-Änderungen speichern', + 'gitView.pr.actions.sendCommentToAgent': 'Kommentar an Agent senden', + 'gitView.pr.actions.sendCommentToAgentAria': 'Diesen Kommentar an Agent senden', + 'gitView.pr.actions.sendToAgent': 'An Agent senden', + 'gitView.pr.actions.shareComments': 'Kommentare teilen', + 'gitView.pr.actions.shareCommentsAria': 'Pull-Request-Kommentare an Agent senden', + 'gitView.pr.actions.toggleDraftAria': 'Entwurfsstatus umschalten', + 'gitView.pr.actions.refresh': 'PR-Status aktualisieren', + 'gitView.pr.actions.refreshAria': 'Pull-Request-Status aktualisieren', + 'gitView.pr.additionalContext.added': 'Hinzugefügt', + 'gitView.pr.additionalContext.hint': 'Zusätzlichen Kontext hinzufügen für bessere Review-Qualität.', + 'gitView.pr.additionalContext.optional': 'Optional', + 'gitView.pr.additionalContext.title': 'Zusätzlicher Kontext', + 'gitView.pr.checkDetails.empty': 'Keine Check-Details verfügbar', + 'gitView.pr.checkDetails.title': 'Details zu fehlgeschlagenen Checks', + 'gitView.pr.checkingStatus': 'Pull-Request-Status wird überprüft...', + 'gitView.pr.checks.label': 'Checks', + 'gitView.pr.checks.completedLabel': 'Abgeschlossen', + 'gitView.pr.checks.conclusionLabel': 'Ergebnis', + 'gitView.pr.checks.startedLabel': 'Gestartet', + 'gitView.pr.checks.statusLabel': 'Status', + 'gitView.pr.checks.stepLabel': 'Schritt', + 'gitView.pr.checks.steps': 'Schritte', + 'gitView.pr.comments.empty': 'Keine Kommentare gefunden', + 'gitView.pr.comments.generalContext': 'Allgemeiner Kommentar', + 'gitView.pr.comments.reviewContext': 'Kommentar überprüfen', + 'gitView.pr.comments.title': 'Kommentare', + 'gitView.pr.comments.unknownAuthor': 'Unbekannter Autor', + 'gitView.pr.createTitle': 'Pull-Request erstellen', + 'gitView.pr.draftMustBeReady': 'Draft PR muss als bereit zum Merge markiert werden.', + 'gitView.pr.field.baseBranch': 'Basis-Branch', + 'gitView.pr.field.description': 'Beschreibung', + 'gitView.pr.field.draft': 'Entwurf', + 'gitView.pr.field.title': 'Titel', + 'gitView.pr.githubNotConnected': 'GitHub ist nicht verbunden', + 'gitView.pr.loadingDescription': 'Beschreibung wird geladen...', + 'gitView.pr.mergeMethod.merge': 'Einen Merge-Commit erstellen', + 'gitView.pr.mergeMethod.rebase': 'Rebase und Merge', + 'gitView.pr.mergeMethod.squash': 'Squash und Merge', + 'gitView.pr.noDescription': 'Keine Beschreibung', + 'gitView.pr.noMergePermission': 'Sie haben keine Berechtigung, diesen Pull-Request zu mergen.', + 'gitView.pr.notMergeable': 'Nicht mergebar', + 'gitView.pr.numberLabel': 'PR #{number}', + 'gitView.pr.placeholder.additionalContext': 'Beliebige Einschränkungen, Rollout-Hinweise oder Kontext für die Reviewer...', + 'gitView.pr.placeholder.description': 'Beschreiben Sie die Änderung...', + 'gitView.pr.placeholder.main': 'Zusammenfassung Ihres Pull-Requests', + 'gitView.pr.placeholder.selectBaseBranch': 'Basis-Branch auswählen', + 'gitView.pr.placeholder.title': 'Pull-Request-Titel', + 'gitView.pr.placeholder.whatChanged': 'Was hat sich geändert und warum?', + 'gitView.pr.statusUnavailable': 'Pull-Request-Status nicht verfügbar', + 'gitView.pr.toast.baseBranchRequired': 'Wählen Sie einen Basis-Branch aus', + 'gitView.pr.toast.baseMustDifferFromHead': 'Basis-Branch muss sich vom aktuellen Branch unterscheiden', + 'gitView.pr.toast.createPrFailed': 'Fehler beim Erstellen des Pull-Requests', + 'gitView.pr.toast.generateDescriptionFailed': 'Fehler bei der Generierung der Pull-Request-Beschreibung', + 'gitView.pr.toast.githubApiUnavailable': 'GitHub-API ist nicht verfügbar', + 'gitView.pr.toast.loadCheckDetailsFailed': 'Fehler beim Laden der Prüfungsdetails', + 'gitView.pr.toast.loadChecksFailed': 'Fehler beim Laden der Pull-Request-Prüfungen', + 'gitView.pr.toast.loadCommentsFailed': 'Fehler beim Laden der Kommentare', + 'gitView.pr.toast.loadPrCommentsFailed': 'Fehler beim Laden der Pull-Request-Kommentare', + 'gitView.pr.toast.markReadyFailed': 'Fehler beim Markieren des Pull-Requests als bereit', + 'gitView.pr.toast.markedReady': 'Pull-Request als bereit für die Überprüfung markiert', + 'gitView.pr.toast.mergeFailed': 'Fehler beim Zusammenführen des Pull-Requests', + 'gitView.pr.toast.noActiveSession': 'Keine aktive Chat-Sitzung', + 'gitView.pr.toast.noActiveSessionDescription': 'Öffne oder erstelle eine Chat-Sitzung, um Kontext an einen Agenten zu senden.', + 'gitView.pr.toast.noFailedChecks': 'Keine fehlgeschlagenen Prüfungen gefunden', + 'gitView.pr.toast.noModelSelected': 'Wähle zuerst ein Modell aus', + 'gitView.pr.toast.noPrComments': 'Keine Pull-Request-Kommentare gefunden', + 'gitView.pr.toast.prCreated': 'Pull-Request erstellt', + 'gitView.pr.toast.prMerged': 'Pull-Request zusammengeführt', + 'gitView.pr.toast.prNotMerged': 'Pull-Request wurde nicht zusammengeführt', + 'gitView.pr.toast.prUpdated': 'Pull-Request aktualisiert', + 'gitView.pr.toast.sendMessageFailed': 'Fehler beim Senden der Nachricht an den Agenten', + 'gitView.pr.toast.titleRequired': 'Titel ist erforderlich', + 'gitView.pr.toast.updatePrFailed': 'Fehler beim Aktualisieren des Pull-Requests', + 'gitView.pullRequest.createHint': 'Erstelle und verwalte Pull-Requests aus diesem Branch.', + 'gitView.pullRequest.availableOnFeatureBranches': 'Verfügbar, wenn der aktuelle Branch einen Pull-Request öffnen kann.', + 'gitView.pullRequest.title': 'Pull-Request', + 'gitView.tabs.worktree': 'Worktree', + 'gitView.toast.abortOperationFailed': 'Fehler beim Abbrechen der Operation', + 'gitView.toast.appliedIdentity': 'Identität angewendet: {name}', + 'gitView.toast.applyIdentityFailed': 'Fehler beim Anwenden der Identität', + 'gitView.toast.branchCreatedLocally': 'Branch lokal erstellt, aber Fehler beim Festlegen des Upstreams.', + 'gitView.toast.cannotCheckout': 'Kann Branch nicht auschecken: {reason}', + 'gitView.toast.cannotCreateBranch': 'Kann Branch nicht erstellen: {reason}', + 'gitView.toast.cannotRemoveOriginRemote': 'Kann den origin-Remote nicht entfernen', + 'gitView.toast.cannotRenameBranch': 'Kann Branch nicht umbenennen: {reason}', + 'gitView.toast.checkedOut': 'Ausgecheckt {name}', + 'gitView.toast.checkoutFailed': 'Fehler beim Auschecken von {name}', + 'gitView.toast.commitCreated': 'Commit erstellt', + 'gitView.toast.commitHashCopied': 'Commit-Hash kopiert', + 'gitView.toast.continueOperationFailed': 'Fehler beim Fortsetzen der Operation', + 'gitView.toast.copyFailed': 'Fehler beim Kopieren', + 'gitView.toast.createBranchFailed': 'Fehler beim Erstellen des Branches', + 'gitView.toast.createCommitFailed': 'Fehler beim Erstellen des Commits', + 'gitView.toast.createdBranch': 'Branch {name} erstellt', + 'gitView.toast.enterCommitMessage': 'Geben Sie eine Commit-Nachricht ein', + 'gitView.toast.fetchedFromRemote': 'Von {name} abgerufen', + 'gitView.toast.generateCommitMessageFailed': 'Fehler beim Generieren der Commit-Nachricht', + 'gitView.toast.mergeAborted': 'Merge abgebrochen', + 'gitView.toast.mergeCompleted': 'Merge abgeschlossen', + 'gitView.toast.mergeConflictsDetected': 'Merge-Konflikte erkannt', + 'gitView.toast.mergedIntoBranch': '{branch} in {currentBranch} gemerged', + 'gitView.toast.pulledFilesPlural': '{count} Dateien von {name} abgerufen', + 'gitView.toast.pulledFilesSingle': '{count} Datei von {name} abgerufen', + 'gitView.toast.pushedToUpstream': 'Zu {name} gepusht', + 'gitView.toast.commitOrStashBeforeSync': 'Committen oder stashen Sie Ihre Änderungen vor dem Sync', + 'gitView.toast.alreadyUpToDate': 'Bereits auf dem neuesten Stand', + 'gitView.toast.syncedPulledPluralAndPushed': '{count} Dateien von {name} abgerufen und zu Upstream gepusht', + 'gitView.toast.syncedPulledSingleAndPushed': '{count} Datei von {name} abgerufen und zu Upstream gepusht', + 'gitView.toast.syncedChanges': 'Änderungen synchronisiert', + 'gitView.toast.rebaseAborted': 'Rebase abgebrochen', + 'gitView.toast.rebaseConflictsDetected': 'Rebase-Konflikte erkannt', + 'gitView.toast.rebaseStepCompleted': 'Rebase-Schritt abgeschlossen', + 'gitView.toast.rebasedOntoBranch': '{currentBranch} neu basiert auf {branch}', + 'gitView.toast.refreshRepositoryFailed': 'Fehler beim Aktualisieren des Repositorys', + 'gitView.toast.remoteNameRequired': 'Remote-Name ist erforderlich', + 'gitView.toast.removedRemote': 'Remote {name} entfernt', + 'gitView.toast.renameBranchFailed': 'Fehler beim Umbenennen von {oldName} in {newName}', + 'gitView.toast.renamedBranch': '{oldName} umbenannt in {newName}', + 'gitView.toast.restoreStashFailed': 'Fehler beim Wiederherstellen des Stashes', + 'gitView.toast.restoreStashManually': 'Manuelles Wiederherstellen des Stashes erforderlich', + 'gitView.toast.revertFailed': 'Fehler beim Rückgängig machen der Änderungen', + 'gitView.toast.revertedFile': '{path} rückgängig gemacht', + 'gitView.toast.revertedFilesPlural': '{count} Dateien rückgängig gemacht', + 'gitView.toast.revertedFilesSingle': '{count} Datei rückgängig gemacht', + 'gitView.toast.revertedSomePlural': '{success} Dateien rückgängig gemacht, {failed} fehlgeschlagen', + 'gitView.toast.revertedSomeSingle': '{success} Datei rückgängig gemacht, {failed} fehlgeschlagen', + 'gitView.toast.stageFileFailed': 'Fehler beim Stagen der Änderungen', + 'gitView.toast.stageFileToCommit': 'Stage mindestens eine Datei zum Commit', + 'gitView.toast.stageFileToDescribe': 'Stage mindestens eine Datei zur Beschreibung', + 'gitView.toast.selectFileToCommit': 'Wähle mindestens eine Datei zum Commit aus', + 'gitView.toast.selectFileToDescribe': 'Wähle mindestens eine Datei zur Beschreibung aus', + 'gitView.toast.stashedRestored': 'Gestashete Änderungen wiederhergestellt', + 'gitView.toast.syncActionFailed': '{action} fehlgeschlagen', + 'gitView.toast.unstageFileFailed': 'Fehler beim Aufheben des Stages der Änderungen', + 'gitView.toast.upstreamSet': 'Upstream für {branch} auf {remote} gesetzt', + 'gitView.worktree.availableInWorktreeMode': 'Nur im Worktree-Modus verfügbar', + 'contextPanel.mode.chat': 'Chat', + 'contextPanel.mode.files': 'Dateien', + 'contextPanel.mode.diff': 'Änderungen', + 'contextPanel.mode.stagedDiff': 'Staged Diff', + 'contextPanel.mode.workingDiff': 'Working Diff', + 'contextPanel.mode.plan': 'Plan', + 'contextPanel.mode.context': 'Kontext', + 'contextPanel.mode.preview': 'Vorschau', + 'contextPanel.mode.browser': 'Browser', + 'contextPanel.browser.open': 'Browser-Panel öffnen', + 'contextPanel.browser.addressAria': 'Browser-Adresse', + 'contextPanel.browser.empty': 'Webbrowser', + 'contextPanel.browser.emptyHint': 'Geben Sie oben eine Adresse ein, um mit dem Surfen im Web zu beginnen', + 'contextPanel.browser.inspectUnavailable': 'Diese Seite kann nicht aus dem Browser-Panel inspectiert werden.', + 'contextPanel.browser.trustNotice': 'Seiten, die hier geöffnet werden, laufen mit vollständigem Zugriff auf OpenChamber — erforderlich für Inspect und Screenshots. Öffnen Sie nur Seiten, denen Sie vertrauen: Eine bösartige Seite könnte Ihre Daten lesen oder in Ihrem Namen handeln.', + 'contextPanel.tab.closeTabAria': '{label}-Registerkarte schließen', + 'contextPanel.actions.collapsePanel': 'Panel einklappen', + 'contextPanel.actions.expandPanel': 'Panel ausklappen', + 'contextPanel.actions.closePanel': 'Panel schließen', + 'contextPanel.actions.resizePanelAria': 'Größe des Kontextpanels ändern', + 'contextPanel.iframe.sessionChatTitle': 'Sitzungschat {sessionID}', + 'contextPanel.preview.actions.reload': 'Vorschau neu laden', + 'contextPanel.preview.actions.openExternal': 'Im Browser öffnen', + 'contextPanel.preview.actions.retry': 'Erneut versuchen', + 'contextPanel.preview.iframeTitle': 'Vorschau', + 'contextPanel.preview.invalidUrl': 'Vorschau benötigt eine gültige http(s)-URL.', + 'contextPanel.preview.empty': 'Keine Vorschau-URL', + 'contextPanel.preview.loading': 'Vorschau-Proxy wird verbunden...', + 'contextPanel.preview.proxyError': 'Konnte Vorschau-Proxy nicht starten.', + 'contextPanel.preview.upstreamUnreachable': 'Dev-Server antwortet nicht.', + 'contextPanel.preview.upstreamUnreachableHint': 'Stellen Sie sicher, dass Ihr Dev-Server noch läuft, und versuchen Sie es dann erneut.', + 'contextPanel.preview.startingServer': 'Dev-Server wird gestartet...', + 'contextPanel.preview.startingServerHint': 'Warten auf Annahme von Verbindungen durch den Server.', + 'contextPanel.preview.title': 'Vorschau', + 'contextPanel.preview.description': 'Verwenden Sie Projektaktionen oder eine Vorschau-Schaltfläche im Terminal, um eine Vorschau zu öffnen.', + 'contextPanel.preview.startPreview': 'Vorschau starten', + 'contextPanel.preview.starting': 'Starte...', + 'contextPanel.preview.noDevServer': 'Kein Dev-Server-Befehl gefunden. Konfiguriere eine Projektaktion oder füge einen "dev"-Skript in package.json hinzu.', + 'contextPanel.preview.startFailed': 'Fehler beim Starten des Vorschau-Servers.', + 'contextPanel.preview.serverExited': 'Dev-Server wurde unerwartet beendet.', + 'contextPanel.preview.noUrlDetected': 'Dev-Server gestartet, aber keine URL in der Ausgabe erkannt. Öffne den Vorschau-Terminal-Tab, um die Protokolle zu prüfen.', + 'contextPanel.preview.serverExitedWithLog': 'Dev-Server wurde unerwartet beendet. Letzte Ausgabe:\n\n{log}', + 'contextPanel.preview.noUrlDetectedWithLog': 'Dev-Server ist nicht erreichbar geworden. Letzte Ausgabe:\n\n{log}', + 'contextPanel.preview.console.open': 'Vorschau-Konsole öffnen', + 'contextPanel.preview.console.waiting': 'Warte auf Vorschau-Konsole', + 'contextPanel.preview.console.title': 'Vorschau-Konsole', + 'contextPanel.preview.console.attach': 'Anhängen', + 'contextPanel.preview.console.copy': 'Kopieren', + 'contextPanel.preview.console.clear': 'Leeren', + 'contextPanel.preview.console.empty': 'Noch keine Vorschau-Konsole-Ereignisse.', + 'contextPanel.preview.console.noFilteredEvents': 'Keine Ereignisse entsprechen diesem Filter.', + 'contextPanel.preview.console.runtimeError': 'Laufzeitfehler', + 'contextPanel.preview.console.copied': 'Vorschau-Konsole kopiert', + 'contextPanel.preview.console.copyFailed': 'Fehler beim Kopieren der Vorschau-Konsole', + 'contextPanel.preview.console.attached': 'Vorschau-Konsole an Chat angehängt', + 'contextPanel.preview.console.attachNoSession': 'Öffne eine Chat-Sitzung, bevor du Vorschau-Protokolle anhängst', + 'contextPanel.preview.console.attachAnnotation': 'Dies sind Browser-Konsole-Protokolle vom Dev-Server, der für dieses Projekt läuft.', + 'contextPanel.preview.inspect.toggle': 'Vorschau-Element untersuchen', + 'contextPanel.preview.inspect.attached': 'Vorschau-Anmerkung an Chat angehängt', + 'contextPanel.preview.inspect.attachNoSession': 'Öffne eine Chat-Sitzung, bevor du Vorschau-Anmerkungen anhängst', + 'contextPanel.preview.inspect.attachAnnotation': 'Dies ist ein ausgewähltes DOM-Element aus der In-App-Vorschau.', + 'contextPanel.preview.inspect.attachAnnotationWithScreenshot': 'Dies ist ein ausgewähltes DOM-Element aus der In-App-Vorschau. Ein Screenshot des sichtbaren Vorschau-Bereichs mit dem ausgewählten Element hervorgehoben ist angehängt.', + 'contextPanel.preview.console.filter.all': 'Alle', + 'contextPanel.preview.console.filter.errors': 'Fehler', + 'contextPanel.preview.console.filter.warnings': 'Warnungen', + 'contextPanel.preview.console.filter.logs': 'Protokolle', + 'terminalView.preview.open': 'Vorschau', + 'terminalView.preview.openTitle': 'Vorschaubereich öffnen', + 'sidebarFilesTree.menu.rename': 'Umbenennen', + 'sidebarFilesTree.menu.copyPath': 'Pfad kopieren', + 'sidebarFilesTree.menu.save': 'Speichern', + 'sidebarFilesTree.menu.newFile': 'Neue Datei', + 'sidebarFilesTree.menu.newFolder': 'Neuer Ordner', + 'sidebarFilesTree.menu.delete': 'Löschen', + 'sidebarFilesTree.toast.pathCopied': 'Pfad kopiert', + 'sidebarFilesTree.toast.copyFailed': 'Kopieren fehlgeschlagen', + 'sidebarFilesTree.toast.revealFailed': 'Pfad konnte nicht angezeigt werden', + 'sidebarFilesTree.toast.filenameRequired': 'Dateiname ist erforderlich', + 'sidebarFilesTree.toast.writeNotSupported': 'Schreiben nicht unterstützt', + 'sidebarFilesTree.toast.fileCreated': 'Datei erstellt', + 'sidebarFilesTree.toast.operationFailed': 'Operation fehlgeschlagen', + 'sidebarFilesTree.toast.folderNameRequired': 'Ordnername ist erforderlich', + 'sidebarFilesTree.toast.folderCreated': 'Ordner erstellt', + 'sidebarFilesTree.toast.nameRequired': 'Name ist erforderlich', + 'sidebarFilesTree.toast.renameNotSupported': 'Umbenennen nicht unterstützt', + 'sidebarFilesTree.toast.renamedSuccessfully': 'Erfolgreich umbenannt', + 'sidebarFilesTree.toast.deleteNotSupported': 'Löschen nicht unterstützt', + 'sidebarFilesTree.toast.deletedSuccessfully': 'Erfolgreich gelöscht', + 'sidebarFilesTree.search.placeholder': 'Dateien suchen...', + 'sidebarFilesTree.search.clearAria': 'Suche löschen', + 'sidebarFilesTree.actions.newFileTitle': 'Neue Datei', + 'sidebarFilesTree.actions.newFolderTitle': 'Neuer Ordner', + 'sidebarFilesTree.actions.refreshTitle': 'Aktualisieren', + 'sidebarFilesTree.actions.fileMenuTitle': 'Dateimenü', + 'sidebarFilesTree.state.searching': 'Suche läuft...', + 'sidebarFilesTree.state.loading': 'Wird geladen...', + 'sidebarFilesTree.dialog.createFile.title': 'Datei erstellen', + 'sidebarFilesTree.dialog.createFolder.title': 'Ordner erstellen', + 'sidebarFilesTree.dialog.rename.title': 'Umbenennen', + 'sidebarFilesTree.dialog.delete.title': 'Löschen', + 'sidebarFilesTree.dialog.createFile.description': 'Erstelle eine neue Datei in {path}', + 'sidebarFilesTree.dialog.createFolder.description': 'Erstelle einen neuen Ordner in {path}', + 'sidebarFilesTree.dialog.rename.description': '{name} umbenennen', + 'sidebarFilesTree.dialog.delete.description': 'Sind Sie sicher, dass Sie {name} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.', + 'sidebarFilesTree.dialog.rootFallback': 'root', + 'sidebarFilesTree.dialog.rename.placeholder': 'Neuer Name', + 'sidebarFilesTree.dialog.namePlaceholder': 'Name', + 'sidebarFilesTree.dialog.cancel': 'Abbrechen', + 'sidebarFilesTree.dialog.confirm': 'Bestätigen', + 'sidebarFilesTree.dialog.delete.confirm': 'Löschen', + 'filesView.dialog.createFile.title': 'Datei erstellen', + 'filesView.dialog.createFolder.title': 'Ordner erstellen', + 'filesView.dialog.rename.title': 'Umbenennen', + 'filesView.dialog.delete.title': 'Löschen', + 'filesView.dialog.createFile.description': 'Erstelle eine neue Datei in {path}', + 'filesView.dialog.createFolder.description': 'Erstelle einen neuen Ordner in {path}', + 'filesView.dialog.rename.description': '{name} umbenennen', + 'filesView.dialog.delete.description': 'Sind Sie sicher, dass Sie {name} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.', + 'filesView.dialog.rootFallback': 'root', + 'filesView.dialog.rename.placeholder': 'Neuer Name', + 'filesView.dialog.namePlaceholder': 'Name', + 'filesView.dialog.cancel': 'Abbrechen', + 'filesView.dialog.confirm': 'Bestätigen', + 'filesView.dialog.delete.confirm': 'Löschen', + 'filesView.editor.saving': 'Speichern...', + 'filesView.editor.saved': 'Gespeichert', + 'filesView.editor.autoSaveOn': 'Automatisch speichern ein', + 'filesView.editor.manualSave': 'Manuelles Speichern', + 'filesView.editor.saveNowTitle': 'Jetzt speichern ({shortcut}) - automatisches Speichern nach 1,5s', + 'filesView.editor.saveNowManualTitle': 'Jetzt speichern ({shortcut})', + 'filesView.editor.saveAria': 'Speichern ({shortcut})', + 'filesView.editor.openInDesktopApp': 'In Desktop-App öffnen', + 'filesView.editor.refreshApps': 'Apps aktualisieren', + 'filesView.editor.disableLineWrap': 'Zeilenumbruch deaktivieren', + 'filesView.editor.enableLineWrap': 'Zeilenumbruch aktivieren', + 'filesView.editor.findInFile': 'In Datei suchen', + 'filesView.editor.goToLine': 'Gehe zu Zeile', + 'filesView.editor.switchToEditMode': 'Zum Bearbeitungsmodus wechseln', + 'filesView.editor.switchToPreviewMode': 'Zum Vorschau-Modus wechseln', + 'filesView.editor.switchToTextView': 'Zur Textansicht wechseln', + 'filesView.editor.switchToTreeView': 'Zur Baumansicht wechseln', + 'filesView.toast.copyFailed': 'Kopieren fehlgeschlagen', + 'filesView.toast.openInAppFailed': 'Öffnen in {app} fehlgeschlagen', + 'filesView.toast.savingNotSupported': 'Speichern nicht unterstützt', + 'filesView.toast.writeFileFailed': 'Schreiben der Datei fehlgeschlagen', + 'filesView.toast.saveFailed': 'Speichern fehlgeschlagen', + 'filesView.editor.copyFileContents': 'Dateiinhalte kopieren', + 'filesView.editor.copyFilePathTitle': 'Dateipfad kopieren ({path})', + 'filesView.editor.saveFile': 'Datei speichern', + 'filesView.editor.exitFullscreen': 'Vollbild beenden', + 'filesView.editor.fullscreen': 'Vollbild', + 'filesView.unsaved.title': 'Ungespeicherte Änderungen', + 'filesView.unsaved.description': 'Änderungen speichern, bevor Sie fortfahren?', + 'filesView.unsaved.saveChanges': 'Änderungen speichern', + 'filesView.unsaved.discard': 'Verwerfen', + 'filesView.editor.back': 'Zurück', + 'filesView.editor.openFilesAria': 'Offene Dateien', + 'filesView.editor.closeFileAria': 'Schließe {name}', + 'filesView.editor.selectFile': 'Datei auswählen', + 'filesView.editor.showControlsAria': 'Editor-Kontrollen anzeigen', + 'filesView.editor.controlsTitle': 'Editor-Kontrollen', + 'filesView.editor.pickFileFromTree': 'Wählen Sie eine Datei aus dem Baum aus.', + 'filesView.state.loading': 'Laden...', + 'filesView.state.openingFileAtChange': 'Öffne Datei bei Änderung...', + 'filesView.tree.search.placeholder': 'Dateien durchsuchen...', + 'filesView.tree.search.clearAria': 'Suche löschen', + 'filesView.tree.search.searching': 'Suche...', + 'filesView.tree.actions.newFileTitle': 'Neue Datei', + 'filesView.tree.actions.newFolderTitle': 'Neuer Ordner', + 'filesView.tree.actions.refreshTitle': 'Aktualisieren', + 'filesView.editor.imageAltFallback': 'Bild', + 'filesView.error.jsonViewerUnavailable': 'JSON-Betrachter nicht verfügbar', + 'filesView.error.switchToTextMode': 'Wechseln Sie zum Textmodus, um den Rohinhalt anzuzeigen.', + 'filesView.warning.largeFilePreviewLimited': 'Diese Datei ist groß ({sizeKb}KB). Die Vorschau kann eingeschränkt sein.', + 'filesView.error.previewUnavailable': 'Vorschau nicht verfügbar', + 'filesView.error.switchToEditMode': 'Wechseln Sie zum Bearbeitungsmodus, um das Problem zu beheben.', + 'filesView.error.readFileFailed': 'Fehler beim Lesen der Datei', + 'filesView.editor.htmlPreviewTitle': 'HTML-Vorschau', + 'filesView.diagram.closeDiagramView': 'Diagrammansicht schließen', + 'filesView.diagram.saveDiagram': 'Diagramm speichern', + 'contextUsage.aria.label': 'Kontextnutzung', + 'contextUsage.mobile.title': 'Kontextnutzung', + 'contextUsage.mobile.usedTokens': 'Verwendete Tokens', + 'contextUsage.mobile.contextLimit': 'Kontextlimit', + 'contextUsage.mobile.outputLimit': 'Ausgabelimit', + 'contextUsage.mobile.usage': 'Nutzung', + 'contextUsage.tooltip.usedTokens': 'Verwendete Tokens: {tokens}', + 'contextUsage.tooltip.contextLimit': 'Kontextlimit: {tokens}', + 'contextUsage.tooltip.outputLimit': 'Ausgabelimit: {tokens}', + 'contextSidebar.session.untitled': 'Unbenannte Sitzung', + 'contextSidebar.empty.openSession': 'Öffnen Sie eine Sitzung, um den Kontext zu prüfen.', + 'contextSidebar.section.context': 'Kontext', + 'contextSidebar.section.lastAssistantMessage': 'Letzte Assistentennachricht', + 'contextSidebar.section.rawMessages': 'Rohmeldungen', + 'contextSidebar.context.percentUsed': '{percent}% verwendet', + 'contextSidebar.breakdown.user': 'Benutzer', + 'contextSidebar.breakdown.assistant': 'Assistent', + 'contextSidebar.breakdown.toolCalls': 'Werkzeugaufrufe', + 'contextSidebar.breakdown.other': 'Andere', + 'contextSidebar.stats.messages': 'Nachrichten', + 'contextSidebar.stats.user': 'Benutzer', + 'contextSidebar.stats.assistant': 'Assistent', + 'contextSidebar.stats.cost': 'Kosten', + 'contextSidebar.tokens.input': 'Eingabe', + 'contextSidebar.tokens.output': 'Ausgabe', + 'contextSidebar.tokens.reasoning': 'Reasoning', + 'contextSidebar.tokens.cacheRead': 'Cache-Lesevorgang', + 'contextSidebar.tokens.cacheWrite': 'Cache-Schreibvorgang', + 'contextSidebar.tokens.cacheHit': 'Cache-Treffer', + 'contextSidebar.actions.copyJson': 'JSON kopieren', + 'contextSidebar.actions.copy': 'Kopieren', + 'contextSidebar.actions.copied': 'Kopiert', + 'planView.file.defaultName': 'plan', + 'planView.title.default': 'Plan', + 'planView.error.saveFailed': 'Speichern fehlgeschlagen', + 'planView.error.previewUnavailable': 'Vorschau nicht verfügbar', + 'planView.error.switchToEditMode': 'Wechseln Sie zum Bearbeitungsmodus, um das Problem zu beheben.', + 'planView.error.writeFailed': 'Schreiben fehlgeschlagen', + 'planView.error.writePlanFileFailed': 'Fehler beim Schreiben der Plan-Datei ({status})', + 'planView.actions.improvePlanAria': 'Plan verbessern', + 'planView.actions.improve': 'Verbessern', + 'planView.actions.implementPlanAria': 'Plan implementieren', + 'planView.actions.implement': 'Implementieren', + 'planView.actions.sendToNewSession': 'An neue Sitzung senden', + 'planView.actions.sendToNewWorktreeSession': 'An neue Worktree-Sitzung senden', + 'planView.actions.copyPlanContents': 'Plan-Inhalt kopieren', + 'planView.state.loading': 'Wird geladen...', + 'diffView.binary.unavailable': 'Der Inhalt dieser Datei kann nicht angezeigt werden.', + 'diffView.change.untracked': 'Nicht überwachte Datei', + 'diffView.change.new': 'Neue Datei', + 'diffView.change.deleted': 'Gelöschte Datei', + 'diffView.change.renamed': 'Umbenannte Datei', + 'diffView.change.copied': 'Kopierte Datei', + 'diffView.change.modified': 'Geänderte Datei', + 'diffView.selector.selectFile': 'Datei auswählen', + 'diffView.image.original': 'Original', + 'diffView.image.modified': 'Geändert', + 'diffView.image.new': 'Neu', + 'diffView.image.originalAlt': 'Original: {path}', + 'diffView.image.modifiedAlt': 'Geändert: {path}', + 'diffView.section.files': 'Dateien', + 'diffView.state.selectSessionDirectory': 'Wählen Sie ein Sitzungsverzeichnis aus, um Unterschiede anzuzeigen', + 'diffView.state.loadingRepositoryStatus': 'Repository-Status wird geladen...', + 'diffView.state.notGitRepository': 'Kein Git-Repository. Verwenden Sie den Git-Tab, um es zu initialisieren oder ein Verzeichnis zu wechseln.', + 'diffView.state.cleanWorkingTree': 'Arbeitsverzeichnis ist sauber, keine Änderungen zum Anzeigen', + 'diffView.state.noLastTurnChanges': 'Keine Änderungen im letzten Zug zum Anzeigen', + 'diffView.state.failedToLoadDiff': 'Fehler beim Laden des Diffs', + 'diffView.state.loadingDiff': 'Lade Diff...', + 'diffView.state.loadingChanges': 'Lade Änderungen...', + 'diffView.state.largeDiff': 'Großer Diff ({count} geänderte Zeilen)', + 'diffView.state.largeDiffDescription': 'Die Darstellung kann langsam sein. Sie können den Diff dennoch anzeigen, indem Sie unten klicken.', + 'diffView.summary.changedFilesSingle': '{count} Datei geändert', + 'diffView.summary.changedFilesPlural': '{count} Dateien geändert', + 'diffView.scope.changed': 'Geändert', + 'diffView.scope.staged': 'Staged', + 'diffView.scope.lastTurn': 'Letzter Zug', + 'diffView.scope.selectorAria': 'Änderungsmodus auswählen', + 'diffView.actions.retry': 'Erneut versuchen', + 'diffView.actions.renderAnyway': 'Trotzdem rendern', + 'diffView.actions.expandAll': 'Alle erweitern', + 'diffView.actions.collapseAll': 'Alle reduzieren', + 'diffView.actions.loadFullFiles': 'Vollständige Dateien laden', + 'diffView.actions.disableFullFiles': 'Vollständige Dateien entladen', + 'diffView.actions.disableLineWrap': 'Zeilenumbruch deaktivieren', + 'diffView.actions.enableLineWrap': 'Zeilenumbruch aktivieren', + 'diffView.actions.openFileInEditorAtChange': 'Diese Datei im Editor bei der Änderung öffnen', + 'diffView.actions.openFileAtFirstChangedLine': 'Diese Datei an erster geänderter Zeile öffnen', + 'diffView.actions.review': 'Überprüfen', + 'diffView.actions.reviewAria': 'Änderungen überprüfen', + 'diffView.reviewDialog.title': 'Änderungen überprüfen', + 'diffView.reviewDialog.description': 'Starten Sie eine separate Überprüfungs-Sitzung für die aktuellen Änderungen.', + 'diffView.reviewDialog.generateHandoff': 'Übergabe generieren', + 'diffView.reviewDialog.autoReview': 'Automatische Überprüfungs-Schleife ausführen', + 'diffView.reviewDialog.info': 'Dieser Ablauf funktioniert am besten, wenn er aus der Sitzung gestartet wird, in der die Änderungen implementiert wurden.', + 'diffView.reviewDialog.actions.cancel': 'Abbrechen', + 'diffView.reviewDialog.actions.start': 'Überprüfen', + 'diffView.reviewDialog.actions.starting': 'Starte...', + 'diffView.reviewDialog.toast.noSessionDirectory': 'Sitzungsverzeichnis ist nicht verfügbar', + 'diffView.reviewDialog.toast.startFailed': 'Fehler beim Starten des Überprüfungsflusses', + 'chat.history.loadOlder': 'Ältere Nachrichten laden', + 'chat.autoReview.title': 'Code-Überprüfungs-Schleife läuft', + 'chat.autoReview.status.waitingForReviewer': 'Warte auf Überprüfer', + 'chat.autoReview.status.waitingForImplementer': 'Warte auf Implementierer', + 'chat.autoReview.reviewSessionLabel': 'Überprüfungssitzung', + 'chat.autoReview.actions.open': 'Öffnen', + 'chat.autoReview.actions.stop': 'Stoppen', + 'diffView.hunk.label': 'Stücke', + 'diffView.hunk.stage': 'Zu Staging hinzufügen', + 'diffView.hunk.unstage': 'Aus Staging entfernen', + 'diffView.hunk.discard': 'Verwerfen', + 'diffView.hunk.stageTitle': 'Stück {index} zu Staging hinzufügen', + 'diffView.hunk.unstageTitle': 'Stück {index} aus Staging entfernen', + 'diffView.hunk.discardTitle': 'Stück {index} verwerfen', + 'diffView.hunk.unavailable': 'Dieses Stück ist nicht länger verfügbar. Aktualisiere die Diff-Ansicht und versuche es erneut.', + 'diffView.hunk.unsupported': 'Das Staging einzelner Stücke wird in dieser Laufzeitumgebung nicht unterstützt.', + 'rightSidebar.contextNotesTodo.plan.defaultTitle': 'Plan', + 'rightSidebar.contextNotesTodo.empty.selectProject': 'Wähle ein Projekt aus, um Notizen und Aufgaben hinzuzufügen.', + 'rightSidebar.contextNotesTodo.notes.title': 'Schnelle Notizen - {project}', + 'rightSidebar.contextNotesTodo.notes.placeholder': 'Kontext, Erinnerungen oder Links festhalten', + 'rightSidebar.contextNotesTodo.todo.title': 'Aufgaben', + 'rightSidebar.contextNotesTodo.todo.itemsSingle': '{count} Element', + 'rightSidebar.contextNotesTodo.todo.itemsPlural': '{count} Elemente', + 'rightSidebar.contextNotesTodo.todo.clearCompleted': 'Abgeschlossene löschen', + 'rightSidebar.contextNotesTodo.todo.inputPlaceholder': 'Eine Aufgabe hinzufügen', + 'rightSidebar.contextNotesTodo.todo.addAria': 'Aufgabe hinzufügen', + 'rightSidebar.contextNotesTodo.todo.empty': 'Noch keine Aufgaben. Fügen Sie eine kleine Checkliste für dieses Projekt hinzu.', + 'rightSidebar.contextNotesTodo.todo.actions.markComplete': 'Markiere "{text}" als erledigt', + 'rightSidebar.contextNotesTodo.todo.actions.collapse': 'Aufgabe "{text}" einklappen', + 'rightSidebar.contextNotesTodo.todo.actions.expand': 'Aufgabe "{text}" ausklappen', + 'rightSidebar.contextNotesTodo.todo.actions.delete': 'Lösche "{text}"', + 'rightSidebar.contextNotesTodo.todo.actions.send': 'Sende "{text}"', + 'rightSidebar.contextNotesTodo.todo.actions.reorder': 'Ordne "{text}" neu', + 'rightSidebar.contextNotesTodo.todo.resizeAria': 'Größe der Aufgabenliste ändern', + 'rightSidebar.contextNotesTodo.todo.sendMenu.currentSession': 'An aktuelle Sitzung senden', + 'rightSidebar.contextNotesTodo.todo.sendMenu.newSession': 'An neue Sitzung senden', + 'rightSidebar.contextNotesTodo.todo.sendMenu.newWorktreeSession': 'An neue Worktree-Sitzung senden', + 'rightSidebar.contextNotesTodo.plans.title': 'Pläne', + 'rightSidebar.contextNotesTodo.plans.filesSingle': '{count} Datei', + 'rightSidebar.contextNotesTodo.plans.filesPlural': '{count} Dateien', + 'rightSidebar.contextNotesTodo.plans.importFromFile': 'Plan aus Datei importieren', + 'rightSidebar.contextNotesTodo.plans.empty': 'Noch keine gespeicherten Pläne.', + 'rightSidebar.contextNotesTodo.plans.deletePlan': 'Plan löschen', + 'rightSidebar.contextNotesTodo.plans.deletePlanWithTitle': 'Lösche Plan "{title}"', + 'rightSidebar.contextNotesTodo.sendDialog.title.newSession': 'An neue Sitzung senden', + 'rightSidebar.contextNotesTodo.sendDialog.title.newWorktree': 'An neuen Worktree senden', + 'rightSidebar.contextNotesTodo.sendDialog.variant.default': 'Standard', + 'rightSidebar.contextNotesTodo.sendDialog.actions.cancel': 'Abbrechen', + 'rightSidebar.contextNotesTodo.sendDialog.actions.send': 'Senden', + 'rightSidebar.contextNotesTodo.sendDialog.actions.sending': 'Senden', + 'rightSidebar.contextNotesTodo.toast.saveNotesFailed': 'Fehler beim Speichern der Projektnotizen', + 'rightSidebar.contextNotesTodo.toast.loadNotesFailed': 'Fehler beim Laden der Projektnotizen', + 'rightSidebar.contextNotesTodo.toast.noActiveSession': 'Keine aktive Sitzung ausgewählt', + 'rightSidebar.contextNotesTodo.toast.sentToCurrentSession': 'Aufgabe an aktuelle Sitzung gesendet', + 'rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo': 'Worktree-Aktionen sind nur für Git-Repositories verfügbar', + 'rightSidebar.contextNotesTodo.toast.createSessionFailed': 'Fehler beim Erstellen der Sitzung', + 'rightSidebar.contextNotesTodo.toast.sentToNewSession': 'Todo an neue Sitzung gesendet', + 'rightSidebar.contextNotesTodo.toast.sentToNewWorktreeSession': 'Todo an neue Worktree-Sitzung gesendet', + 'rightSidebar.contextNotesTodo.toast.sendTodoFailed': 'Fehler beim Senden des Todos', + 'rightSidebar.contextNotesTodo.toast.deletePlanFailed': 'Fehler beim Löschen des Plans', + 'rightSidebar.contextNotesTodo.toast.planFileEmpty': 'Plan-Datei ist leer', + 'rightSidebar.contextNotesTodo.toast.importPlanFailed': 'Fehler beim Importieren des Plans', + 'rightSidebar.contextNotesTodo.toast.planImported': 'Plan importiert', + 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Fehler beim Lesen der Plan-Datei', + 'inlineComment.range.lines': 'Zeilen {start}-{end}', + 'inlineComment.input.placeholder': 'Kommentar hinzufügen... (Cmd+Enter zum Speichern)', + 'inlineComment.input.placeholderShort': 'Kommentar hinzufügen...', + 'inlineComment.actions.cancel': 'Abbrechen', + 'inlineComment.actions.save': 'Speichern', + 'inlineComment.actions.comment': 'Kommentieren', + 'inlineComment.actions.showLess': 'Weniger anzeigen', + 'inlineComment.actions.showMore': 'Mehr anzeigen', + 'inlineComment.actions.editComment': 'Kommentar bearbeiten', + 'inlineComment.actions.deleteComment': 'Kommentar löschen', + 'inlineComment.toast.selectSessionToSave': 'Sitzung zum Speichern des Kommentars auswählen', + 'header.github.connectedWithLogin': 'GitHub: {login}', + 'header.github.connected': 'GitHub verbunden', + 'header.github.avatarWithLogin': '{login} Avatar', + 'header.github.avatar': 'GitHub-Avatar', + 'header.github.accountsTitle': 'GitHub-Konten', + 'header.github.accountSource.oauth': 'OAuth', + 'header.github.accountSource.cli': 'CLI', + 'header.services.openWithCurrent': 'Instanz öffnen, Nutzung und MCP (aktuell: {current})', + 'header.services.open': 'Dienste öffnen, Nutzung und MCP', + 'header.services.tooltip.currentInstanceWithShortcuts': 'Aktuelle Instanz: {current} ({toggle}; nächster Tab {nextTab})', + 'header.services.tooltip.servicesWithShortcuts': 'Dienste ({toggle}; nächster Tab {nextTab})', + 'header.services.title': 'Dienste', + 'header.services.viewAria': 'Dienste anzeigen', + 'header.services.closeAria': 'Dienste schließen', + 'header.services.rateLimits': 'Ratenlimits', + 'header.services.refreshRateLimitsAria': 'Ratenlimits aktualisieren', + 'header.services.noRateLimits': 'Keine Ratenlimits verfügbar.', + 'header.services.noRateLimitsReported': 'Keine Ratenlimits berichtet.', + 'header.services.remoteUpdate.title': 'Remote-Instanz-Update', + 'header.services.remoteUpdate.checking': 'Suche nach Updates...', + 'header.services.remoteUpdate.upToDate': 'Diese Instanz ist auf dem neuesten Stand.', + 'header.services.remoteUpdate.available': 'Version {version} ist für diese Instanz verfügbar.', + 'header.services.remoteUpdate.error': 'Fehler beim Prüfen von Remote-Instanz-Updates', + 'header.services.remoteUpdate.actions.open': 'Aktualisieren', + 'header.services.used': 'Verwendet', + 'header.services.remaining': 'Verbleibend', + 'header.services.modelFamily.other': 'Andere', + 'header.services.shutdownDev': 'OpenChamber stoppen', + 'header.actions.openPlanAria': 'Plan öffnen', + 'header.actions.toggleChangesPanel': 'Änderungspanel', + 'header.actions.toggleChangesPanelAria': 'Änderungspanel umschalten', + 'header.actions.planWithShortcut': 'Plan ({shortcut})', + 'header.actions.terminalPanelWithShortcut': 'Terminalpanel ({shortcut})', + 'chat.recap.aria': 'Sitzungs-Zusammenfassung', + 'chat.recap.label': 'Zusammenfassung:', + 'chat.goal.dialog.titleCreate': 'Sitzungsziel festlegen', + 'chat.goal.dialog.titleManage': 'Sitzungsziel', + 'chat.goal.dialog.objectiveLabel': 'Ziel', + 'chat.goal.dialog.objectivePlaceholder': 'Beschreiben Sie den Endzustand, den der Agent erreichen und überprüfen sollte...', + 'chat.goal.dialog.budgetLabel': 'Token-Budget', + 'chat.goal.status.active': 'Aktiv', + 'chat.goal.status.evaluating': 'Wird bewertet…', + 'chat.goal.status.paused': 'Pausiert', + 'chat.goal.status.blocked': 'Gesperrt', + 'chat.goal.status.budgetLimited': 'Budget erreicht', + 'chat.goal.status.complete': 'Abgeschlossen', + 'chat.goal.usage.tokens': '{used} Tokens', + 'chat.goal.usage.tokensWithBudget': '{used}/{budget} Tokens', + 'chat.goal.usage.turns': '{turns} Fortsetzungen', + 'chat.goal.action.pause': 'Pausieren', + 'chat.goal.action.resume': 'Fortsetzen', + 'chat.goal.action.markComplete': 'Als abgeschlossen markieren', + 'chat.goal.action.clear': 'Ziel entfernen', + 'chat.goal.action.cancel': 'Abbrechen', + 'chat.goal.action.save': 'Ziel speichern', + 'chat.goal.action.start': 'Ziel starten', + 'chat.goal.toast.actionFailed': 'Aktualisierung des Ziels fehlgeschlagen', + 'chat.goal.row.aria': 'Sitzungsziel — Details öffnen', + 'chat.goal.button.createAria': 'Sitzungsziel festlegen', + 'chat.goal.button.manageAria': 'Sitzungsziel verwalten', + 'chat.goal.button.armAria': 'Ein Ziel mit der nächsten Nachricht starten', + 'chat.goal.counter.aria': 'Längenlimit des Ziels', + 'chat.goal.button.disarmAria': 'Ziel aktiv — tippen, um es zu deaktivieren', + 'chat.goal.button.cancelAria': 'Ziel läuft — tippen, um es abzubrechen', + 'chat.goal.cancelDialog.title': 'Dieses Ziel abbrechen?', + 'chat.goal.cancelDialog.description': 'Der Agent wird automatisch mit der Erreichung dieses Ziels aufhören.', + 'chat.goal.cancelDialog.keep': 'Ziel behalten', + 'chat.goal.cancelDialog.confirm': 'Ziel abbrechen', + 'chat.suggestion.applyAria': 'Vorgeschlagene Nachricht verwenden', + 'chat.suggestion.dismissAria': 'Vorschlag ablehnen', + 'header.actions.toggleTerminalPanelAria': 'Terminalbereich ein-/ausblenden', + 'terminalView.stream.processExitedMessage': '[Prozess beendet{exitCodeSegment}{signalSegment}]', + 'terminalView.stream.processExitedWithCode': ' mit Code {exitCode}', + 'terminalView.stream.processExitedWithSignal': ' (Signal {signal})', + 'terminalView.error.sessionEnded': 'Terminal-Sitzung beendet', + 'terminalView.error.connectionFailed': 'Verbindung fehlgeschlagen: {message}', + 'terminalView.error.startSessionFailed': 'Start der Terminal-Sitzung fehlgeschlagen', + 'terminalView.error.restartFailed': 'Neustart des Terminals fehlgeschlagen', + 'terminalView.error.sendInputFailed': 'Senden der Eingabe fehlgeschlagen', + 'terminalView.empty.noWorkingDirectory': 'Kein Arbeitsverzeichnis für das Terminal verfügbar.', + 'terminalView.empty.selectSession': 'Wählen Sie eine Sitzung aus, um das Terminal zu öffnen.', + 'terminalView.empty.noWorkingDirectoryForSession': 'Kein Arbeitsverzeichnis für diese Sitzung verfügbar.', + 'terminalView.actions.retry': 'Erneut versuchen', + 'terminalView.actions.hardRestart': 'Harter Neustart', + 'terminalView.actions.hardRestartTitle': 'Erzwinge Beenden und erstelle neue Sitzung', + 'terminalView.quickKeys.escape': 'Esc', + 'terminalView.quickKeys.tabAria': 'Tab', + 'terminalView.quickKeys.controlLabel': 'Strg', + 'terminalView.quickKeys.controlModifierAria': 'Steuerungsmodifikator', + 'terminalView.quickKeys.commandModifierAria': 'Befehlsmodifikator', + 'terminalView.quickKeys.arrowUpAria': 'Pfeil nach oben', + 'terminalView.quickKeys.arrowLeftAria': 'Pfeil nach links', + 'terminalView.quickKeys.arrowDownAria': 'Pfeil nach unten', + 'terminalView.quickKeys.arrowRightAria': 'Pfeil nach rechts', + 'terminalView.quickKeys.enterAria': 'Enter', + 'terminalView.tabs.closeTabTitle': 'Registerkarte schließen', + 'terminalView.tabs.newTabTitle': 'Neue Registerkarte', + 'terminalView.viewport.inputAria': 'Terminal-Eingabe', + 'directoryExplorerDialog.title': 'Projektverzeichnis hinzufügen', + 'directoryExplorerDialog.description': 'Wählen Sie einen Ordner aus, der als Projekt hinzugefügt werden soll.', + 'directoryExplorerDialog.toggle.showHidden': 'Versteckte anzeigen', + 'directoryExplorerDialog.pathInput.placeholder': 'Pfad eingeben oder aus Baumstruktur auswählen...', + 'directoryExplorerDialog.actions.openingFinder': 'Öffne...', + 'directoryExplorerDialog.actions.openInFinder': 'Im Finder öffnen', + 'directoryExplorerDialog.actions.adding': 'Füge hinzu...', + 'directoryExplorerDialog.actions.addProject': 'Projekt hinzufügen', + 'directoryExplorerDialog.actions.addLocalProject': 'Lokales Projekt hinzufügen', + 'directoryExplorerDialog.actions.cloneRepository': 'Repository klonen', + 'directoryExplorerDialog.actions.cloneAndAdd': 'Klonen & hinzufügen', + 'directoryExplorerDialog.actions.cloning': 'Klone...', + 'directoryExplorerDialog.actions.createAndAdd': 'Erstellen & hinzufügen', + 'directoryExplorerDialog.actions.alreadyAdded': 'Bereits hinzugefügt', + 'directoryExplorerDialog.clone.remoteUrlPlaceholder': 'Repository-URL (HTTPS oder SSH)', + 'directoryExplorerDialog.browse.directories': 'Verzeichnisse', + 'directoryExplorerDialog.browse.loading': 'Lade Verzeichnisse...', + 'directoryExplorerDialog.browse.empty': 'Keine passenden Verzeichnisse.', + 'directoryExplorerDialog.browse.parentDirectory': 'Übergeordnetes Verzeichnis', + 'directoryExplorerDialog.browse.addedBadge': 'Hinzugefügt', + 'directoryExplorerDialog.browse.quickAdd': 'Hinzufügen', + 'directoryExplorerDialog.footer.navigate': 'Navigieren', + 'directoryExplorerDialog.footer.select': 'Auswählen', + 'directoryExplorerDialog.footer.add': 'Hinzufügen', + 'directoryExplorerDialog.shortcut.enter': 'Enter', + 'directoryExplorerDialog.toast.unableToAccessDirectory': 'Auf Verzeichnis konnte nicht zugegriffen werden', + 'directoryExplorerDialog.toast.desktopDeniedAccess': 'Desktop hat den Zugriff auf das Verzeichnis verweigert.', + 'directoryExplorerDialog.toast.failedToOpenDirectory': 'Fehler beim Öffnen des Verzeichnisses', + 'directoryExplorerDialog.toast.desktopCouldNotGrantAccess': 'Desktop konnte keinen Dateizugriff gewähren.', + 'directoryExplorerDialog.toast.failedToAddProject': 'Fehler beim Hinzufügen des Projekts', + 'directoryExplorerDialog.toast.cloneUrlRequired': 'Geben Sie eine Repository-URL ein, bevor Sie klonen.', + 'directoryExplorerDialog.toast.selectValidDirectoryPath': 'Bitte wählen Sie einen gültigen Verzeichnispfad aus.', + 'directoryExplorerDialog.toast.failedToSelectDirectory': 'Fehler beim Auswählen des Verzeichnisses', + 'directoryExplorerDialog.toast.unknownError': 'Ein unbekannter Fehler ist aufgetreten.', + 'directoryTree.actions.createNewDirectory': 'Neues Verzeichnis erstellen', + 'directoryTree.actions.pinDirectory': 'Verzeichnis anheften', + 'directoryTree.actions.unpinDirectory': 'Verzeichnis lösen', + 'directoryTree.actions.createDirectory': 'Verzeichnis erstellen', + 'directoryTree.actions.cancel': 'Abbrechen', + 'directoryTree.actions.selectWorkingDirectoryAria': 'Arbeitsverzeichnis auswählen', + 'directoryTree.state.locatingHomeDirectory': 'Heimatverzeichnis wird gesucht...', + 'directoryTree.state.loading': 'Wird geladen...', + 'directoryTree.state.noDirectoriesFound': 'Keine Verzeichnisse gefunden', + 'directoryTree.section.pinned': 'Angeheftet', + 'directoryTree.section.browse': 'Durchsuchen', + 'aboutDialog.versionLabel': 'Version {version}', + 'aboutDialog.openChamberVersionLabel': 'OpenChamber Version {version}', + 'aboutDialog.openCodeVersionLabel': 'OpenCode Version {version}', + 'aboutDialog.actions.copyDiagnostics': 'Diagnose kopieren', + 'aboutDialog.actions.preparingDiagnostics': 'Diagnose wird vorbereitet...', + 'aboutDialog.actions.diagnosticsCopied': 'Diagnose kopiert', + 'aboutDialog.diagnosticsDescription': 'Enthält den OpenChamber-Zustand, die OpenCode-Integrität, Verzeichnisse und Projekte.', + 'aboutDialog.footerNote': 'Mit Liebe für die Community gemacht', + 'aboutDialog.toast.copyFailed': 'Kopieren fehlgeschlagen', + 'aboutDialog.toast.diagnosticsNotReady': 'Diagnose noch nicht bereit. Warten Sie eine Sekunde und versuchen Sie es erneut.', + 'aboutDialog.toast.diagnosticsCopied': 'Diagnose kopiert', + 'helpDialog.title': 'Tastaturkürzel', + 'helpDialog.description': 'Verwenden Sie diese Tastaturkürzel, um effizient durch OpenChamber zu navigieren', + 'helpDialog.section.navigationCommands': 'Navigation & Befehle', + 'helpDialog.section.sessionManagement': 'Sitzungsverwaltung', + 'helpDialog.section.panels': 'Panels', + 'helpDialog.section.interface': 'Oberfläche', + 'helpDialog.item.openCommandPalette': 'Befehlspalette öffnen', + 'helpDialog.item.showKeyboardShortcuts': 'Tastaturkürzel anzeigen (dieses Dialogfeld)', + 'helpDialog.item.toggleSessionSidebar': 'Sitzungs-Seitenleiste umschalten', + 'helpDialog.item.cycleAgent': 'Agent wechseln (Chat-Eingabe)', + 'helpDialog.item.openModelSelector': 'Modell-Auswahldialog öffnen', + 'helpDialog.item.navigateModels': 'Modelle navigieren (in Auswahl)', + 'helpDialog.item.adjustThinkingMode': 'Denkmodus anpassen (in Auswahl, wenn unterstützt)', + 'helpDialog.item.cycleThinkingVariant': 'Denkvariante wechseln (globale Tastenkombination)', + 'helpDialog.item.newWindow': 'Neues Fenster (nur Desktop)', + 'helpDialog.item.createNewSession': 'Neue Sitzung erstellen', + 'helpDialog.item.createNewWorktreeDraft': 'Neuer Worktree-Entwurf erstellen', + 'helpDialog.item.focusChatInput': 'Chat-Eingabe fokussieren', + 'helpDialog.item.togglePromptNavigator': 'Aufforderungs-Navigator umschalten', + 'helpDialog.item.abortActiveRun': 'Aktuelle Ausführung abbrechen (Doppeltaste)', + 'helpDialog.item.toggleRightSidebar': 'Rechte Seitenleiste umschalten', + 'helpDialog.item.openRightSidebarGitTab': 'Git-Registerkarte der rechten Seitenleiste öffnen', + 'helpDialog.item.openRightSidebarFilesTab': 'Datei-Registerkarte der rechten Seitenleiste öffnen', + 'helpDialog.item.toggleTerminalDock': 'Terminal-Dock umschalten', + 'helpDialog.item.toggleTerminalExpanded': 'Terminal erweitert umschalten', + 'helpDialog.item.togglePlanContextPanel': 'Plan-Kontext-Panel umschalten', + 'helpDialog.item.cycleTheme': 'Thema wechseln (Hell → Dunkel → System)', + 'helpDialog.item.switchProject': 'Projekt wechseln', + 'helpDialog.item.toggleServicesMenu': 'Dienstemenü umschalten', + 'helpDialog.item.cycleServicesTab': 'Dienste-Registerkarte durchgehen', + 'helpDialog.item.openSettings': 'Einstellungen öffnen', + 'helpDialog.keyCombiner.or': 'oder', + 'helpDialog.proTips.title': 'Pro-Tipps:', + 'helpDialog.proTips.commandPalette': 'Verwenden Sie die Befehlspalette ({shortcut}), um schnell auf alle Aktionen zuzugreifen', + 'helpDialog.proTips.recentSessions': 'Die 5 zuletzt verwendeten Sitzungen erscheinen in der Befehlspalette', + 'helpDialog.proTips.themeCycling': 'Themenwechsel merken sich Ihre Einstellung über Sitzungen hinweg', + 'header.actions.rightSidebarWithShortcut': 'Rechte Seitenleiste ({shortcut})', + 'header.actions.toggleRightSidebarAria': 'Rechte Seitenleiste umschalten', + 'header.actions.openAppMenu': 'OpenChamber-Menü', + 'header.actions.openAppMenuAria': 'OpenChamber-Menü öffnen', + 'header.actions.openSessionsWithShortcut': 'Sitzungen öffnen ({shortcut})', + 'header.actions.openSessionsAria': 'Sitzungen öffnen', + 'header.actions.closeSessionsAria': 'Sitzungen schließen', + 'header.actions.newSessionAria': 'Neue Sitzung', + 'header.actions.newSessionWithShortcut': 'Neue Sitzung ({shortcut})', + 'header.actions.backAria': 'Zurück', + 'header.navigation.mainAria': 'Hauptnavigation', + 'header.sessions.title': 'Sitzungen', + 'header.changes.availableAria': 'Änderungen verfügbar', + 'session.githubIssuePicker.error.noActiveProject': 'Kein aktives Projekt', + 'session.githubIssuePicker.error.runtimeUnavailable': 'GitHub-Laufzeit-API nicht verfügbar', + 'session.githubIssuePicker.error.notConnected': 'GitHub nicht verbunden', + 'session.githubIssuePicker.error.repoNotResolvable': 'Repository kann nicht aufgelöst werden', + 'session.githubIssuePicker.error.repoMustBeGithub': 'Das origin-Remote muss eine GitHub-URL sein', + 'session.githubIssuePicker.error.issueNotFound': 'Problem nicht gefunden', + 'session.githubIssuePicker.error.noModelSelected': 'Kein Modell ausgewählt', + 'session.githubIssuePicker.toast.loadMoreFailed': 'Fehler beim Laden weiterer Probleme', + 'session.githubIssuePicker.toast.loadIssueDetailsFailed': 'Fehler beim Laden der Problem-details', + 'session.githubIssuePicker.toast.sendContextFailed': 'Fehler beim Senden des Problem-Kontexts', + 'session.githubIssuePicker.toast.sessionCreated': 'Sitzung aus Problem erstellt', + 'session.githubIssuePicker.toast.startSessionFailed': 'Fehler beim Starten der Sitzung', + 'session.githubIssuePicker.title.select': 'GitHub-Probleme verknüpfen', + 'session.githubIssuePicker.title.createSession': 'Neue Sitzung aus GitHub-Problem', + 'session.githubIssuePicker.description.select': 'Wählen Sie ein Problem, um es mit dieser Sitzung zu verknüpfen.', + 'session.githubIssuePicker.description.createSession': 'Erstellt eine neue Sitzung mit verstecktem Problem-Kontext (Titel/Inhalt/Labels/Kommentare).', + 'session.githubIssuePicker.searchPlaceholder': 'Suche unter Verwendung der GitHub-Code-Suchsyntax', + 'session.githubIssuePicker.empty.noActiveProject': 'Kein aktives Projekt ausgewählt.', + 'session.githubIssuePicker.empty.runtimeUnavailable': 'GitHub-Laufzeit-API nicht verfügbar.', + 'session.githubIssuePicker.empty.notConnected': 'GitHub nicht verbunden. Verbinden Sie Ihr GitHub-Konto in den Einstellungen.', + 'session.githubIssuePicker.empty.noIssuesFound': 'Keine Probleme gefunden', + 'session.githubIssuePicker.empty.noOpenIssuesFound': 'Keine offenen Probleme gefunden', + 'session.githubIssuePicker.loading.issues': 'Probleme werden geladen...', + 'session.githubIssuePicker.loading.more': 'Wird geladen...', + 'session.githubIssuePicker.actions.openSettings': 'Einstellungen öffnen', + 'session.githubIssuePicker.actions.useIssue': 'Problem #{number} verwenden', + 'session.githubIssuePicker.actions.openInGitHubAria': 'In GitHub öffnen', + 'session.githubIssuePicker.actions.loadMore': 'Mehr laden', + 'session.githubIssuePicker.actions.sectionTitle': 'Aktionen', + 'session.githubIssuePicker.actions.toggleWorktreeAria': 'Worktree umschalten', + 'session.githubIssuePicker.actions.createInWorktree': 'In Worktree erstellen', + 'session.githubIssuePicker.actions.openRepo': 'Repository öffnen', + 'session.githubIssuePicker.actions.refresh': 'Aktualisieren', + 'session.githubPrPicker.error.noActiveProject': 'Kein aktives Projekt', + 'session.githubPrPicker.error.runtimeUnavailable': 'GitHub-Laufzeit-API nicht verfügbar', + 'session.githubPrPicker.error.notConnected': 'GitHub nicht verbunden', + 'session.githubPrPicker.error.prNotFound': 'Pull-Request nicht gefunden', + 'session.githubPrPicker.error.repoNotResolvable': 'Repository kann nicht aufgelöst werden', + 'session.githubPrPicker.error.repoMustBeGithub': 'Das origin-Remote muss eine GitHub-URL sein', + 'session.githubPrPicker.toast.loadMoreFailed': 'Fehler beim Laden weiterer Pull Requests', + 'session.githubPrPicker.toast.loadDetailsFailed': 'Fehler beim Laden der Pull Request-Details', + 'session.githubPrPicker.title': 'GitHub-Pull Request verknüpfen', + 'session.githubPrPicker.description': 'Wählen Sie einen Pull Request aus, um den Überprüfungs-Kontext zu dieser Nachricht hinzuzufügen.', + 'session.githubPrPicker.searchPlaceholder': 'Suche mit GitHub-Code-Suchsyntax', + 'session.githubPrPicker.includeDiffAria': 'PR-Diff in angehängten Kontext einfügen', + 'session.githubPrPicker.includeDiff': 'PR-Diff einfügen', + 'session.githubPrPicker.empty.noActiveProject': 'Kein aktives Projekt ausgewählt.', + 'session.githubPrPicker.empty.runtimeUnavailable': 'GitHub-Laufzeit-API nicht verfügbar.', + 'session.githubPrPicker.empty.notConnected': 'GitHub nicht verbunden. Verbinden Sie Ihr GitHub-Konto in den Einstellungen.', + 'session.githubPrPicker.empty.noPullRequestsFound': 'Keine Pull Requests gefunden', + 'session.githubPrPicker.empty.noOpenPullRequestsFound': 'Keine offenen Pull Requests gefunden', + 'session.githubPrPicker.loading.pullRequests': 'Pull Requests werden geladen...', + 'session.githubPrPicker.loading.more': 'Wird geladen...', + 'session.githubPrPicker.actions.openSettings': 'Einstellungen öffnen', + 'session.githubPrPicker.actions.usePullRequest': 'Pull Request #{number} verwenden', + 'session.githubPrPicker.actions.openInGitHubAria': 'In GitHub öffnen', + 'session.githubPrPicker.actions.loadMore': 'Mehr laden', + 'session.newWorktree.title': 'Neuer Worktree', + 'session.newWorktree.mode.newBranch': 'Neue Branch', + 'session.newWorktree.mode.existingBranch': 'Vorhandener Branch', + 'session.newWorktree.selectBranch': 'Branch auswählen', + 'session.newWorktree.chooseBranch': 'Einen Branch wählen...', + 'session.newWorktree.fetchBranches': 'Branches abrufen', + 'session.newWorktree.searchBranches': 'Branches durchsuchen...', + 'session.newWorktree.loadingBranches': 'Branches werden geladen...', + 'session.newWorktree.noBranchesFound': 'Keine Branches gefunden', + 'session.newWorktree.matchingBranches': 'Übereinstimmende Branches', + 'session.newWorktree.noMatchingBranches': 'Keine übereinstimmenden Branches', + 'session.newWorktree.localBranches': 'Lokale Branches', + 'session.newWorktree.remoteBranches': 'Remote-Branches', + 'session.newWorktree.otherLocalBranches': 'Andere lokale Branches', + 'session.newWorktree.otherRemoteBranches': 'Andere Remote-Branches', + 'session.newWorktree.branchName': 'Branch-Name', + 'session.newWorktree.branchNamePlaceholder': 'feature/mein-geil-feature', + 'session.newWorktree.actions.change': 'Ändern', + 'session.newWorktree.actions.startFromGitHubIssuePr': 'Starte von GitHub Issue/PR', + 'session.newWorktree.usingPrBranch': 'Verwende PR-Branch: {branch}', + 'session.newWorktree.fromIssue': 'Von Issue #{number}: {title}', + 'session.newWorktree.worktreeDirectory': 'Worktree-Verzeichnis', + 'session.newWorktree.worktreeDirectoryPlaceholder': 'mein-worktree-verzeichnis', + 'session.newWorktree.resetToMatchBranchName': 'Zurücksetzen, um mit Branch-Namen übereinzustimmen', + 'session.newWorktree.sourceBranch': 'Quell-Branch', + 'session.newWorktree.selectSourceBranch': 'Quell-Branch auswählen', + 'session.newWorktree.selectSourceBranchPlaceholder': 'Quell-Branch auswählen...', + 'session.newWorktree.newBranchFromSource': 'Neuer Branch wird erstellt von {source}', + 'session.newWorktree.issueNumber': 'Issue #{number}', + 'session.newWorktree.prNumber': 'PR #{number}', + 'session.newWorktree.includeDiffBadge': '+diff', + 'session.newWorktree.newSessionTitle': 'Neue Sitzung', + 'session.newWorktree.fromSource': 'von {source}', + 'session.newWorktree.actions.cancel': 'Abbrechen', + 'session.newWorktree.actions.creating': 'Erstelle...', + 'session.newWorktree.actions.createWorktree': 'Worktree erstellen', + 'session.newWorktree.actions.reset': 'Zurücksetzen', + 'session.newWorktree.error.noModelSelected': 'Kein Modell ausgewählt', + 'session.newWorktree.error.noActiveProject': 'Kein aktives Projekt', + 'session.newWorktree.error.branchNameRequired': 'Branch-Name ist erforderlich', + 'session.newWorktree.error.worktreeDirectoryRequired': 'Worktree-Verzeichnis ist erforderlich', + 'session.newWorktree.error.sendGitHubContextFailed': 'Fehler beim Senden des GitHub-Kontexts', + 'session.newWorktree.error.createWorktreeFailed': 'Fehler beim Erstellen des Worktrees', + 'session.newWorktree.toast.sessionFromIssue': 'Sitzung aus Problem erstellt', + 'session.newWorktree.toast.sessionFromPr': 'Sitzung aus Pull Request erstellt', + 'session.newWorktree.toast.worktreeCreated': 'Worktree erstellt', + 'session.newWorktree.toast.worktreeCreatedDescription': '{target} - im Hintergrund initialisiert', + 'session.githubIntegration.title': 'Aus GitHub auswählen', + 'session.githubIntegration.tabs.issues': 'Probleme', + 'session.githubIntegration.tabs.pullRequests': 'Pull Requests', + 'session.githubIntegration.connect.title': 'Mit GitHub verbinden', + 'session.githubIntegration.connect.description': 'Verknüpfen Sie Probleme oder Pull Requests, um Worktree-Details automatisch auszufüllen', + 'session.githubIntegration.connect.action': 'GitHub verbinden', + 'session.githubIntegration.search.issuesPlaceholder': 'Suche mit GitHub-Code-Suchsyntax', + 'session.githubIntegration.search.prsPlaceholder': 'Suche mit GitHub-Code-Suchsyntax', + 'session.githubIntegration.empty.noIssuesFound': 'Keine Probleme gefunden', + 'session.githubIntegration.empty.noPullRequestsFound': 'Keine Pull Requests gefunden', + 'session.githubIntegration.actions.loadMore': 'Mehr laden', + 'session.githubIntegration.actions.cancel': 'Abbrechen', + 'session.githubIntegration.actions.select': 'Auswählen', + 'session.githubIntegration.selected.issueNumber': 'Problem #{number}', + 'session.githubIntegration.selected.prNumber': 'PR #{number}', + 'session.githubIntegration.includeDiffAria': 'PR-Diff in Sitzungskontext einbeziehen', + 'session.githubIntegration.includeDiff': 'PR-Diff einbeziehen', + 'session.githubIntegration.error.notConnected': 'GitHub nicht verbunden', + 'session.githubIntegration.error.loadDataFailed': 'Fehler beim Laden der Daten', + 'session.githubIntegration.validation.branchAlreadyCheckedOut': 'Branch ist bereits in einem Worktree ausgecheckt', + 'session.githubIntegration.validation.branchAlreadyExists': 'Branch existiert bereits lokal', + 'session.githubIntegration.validation.failed': 'Validierung fehlgeschlagen', + 'chat.fileAttachment.toast.attachFailed': 'Fehler beim Anhängen der Datei', + 'chat.fileAttachment.toast.someFilesSkipped': 'Einige Dateien wurden übersprungen:\n{summary}', + 'chat.fileAttachment.toast.vscodePickFailed': 'Fehler beim Auswählen von Dateien in VS Code', + 'chat.fileAttachment.fileFallback': 'Datei', + 'chat.fileAttachment.skippedFallback': 'übersprungen', + 'chat.fileAttachment.actions.attachAria': 'Dateien anhängen', + 'chat.fileAttachment.actions.attach': 'Dateien anhängen', + 'chat.fileAttachment.actions.removeNamed': '{name} entfernen', + 'chat.fileAttachment.actions.removeImage': 'Bild entfernen', + 'chat.fileAttachment.activeEditor.addFile': 'Datei:{name} zum Kontext hinzufügen', + 'chat.fileAttachment.activeEditor.pinSelection': 'Auswahl zum Kontext hinzufügen', + 'chat.fileAttachment.activeEditor.remove': 'Aus Kontext entfernen', + 'chat.fileAttachment.openInDiagram': 'In Diagrammansicht öffnen', + 'chat.pendingChanges.fileCountSingle': '{count} Datei', + 'chat.pendingChanges.fileCountPlural': '{count} Dateien', + 'chat.pendingChanges.changedInWorkspace': 'im Workspace geändert', + 'chat.changedFiles.title': 'Geänderte Dateien', + 'chat.changedFiles.actions.openFileTitle': '{path} öffnen', + 'chat.emptyState.opencodeUnreachable': 'OpenCode ist nicht erreichbar', + 'chat.emptyState.startNewChat': 'Neuen Chat starten', + 'chat.emptyState.draftTitle': 'Worüber arbeiten wir?', + 'chat.emptyState.draftTitleWithProject': 'Worüber arbeiten wir in {project}?', + 'chat.draftPresets.explore.label': 'Codebase erkunden', + 'chat.draftPresets.catchup.label': 'Aktuellstand', + 'chat.draftPresets.weigh.label': 'Meine Optionen bewerten', + 'chat.draftPresets.plan.label': 'Feature-Planung starten', + 'chat.draftPresets.craftGoal.label': 'Ein Ziel formulieren', + 'chat.draftPresets.debug.label': 'Ein Problem debuggen', + 'chat.draftPresets.review.label': 'Meine Änderungen überprüfen', + 'chat.draftStarters.add': 'Einen Starter hinzufügen', + 'chat.draftStarters.searchPlaceholder': 'Befehle und Fähigkeiten durchsuchen…', + 'chat.draftStarters.empty': 'Nichts zum Hinzufügen', + 'chat.draftStarters.sectionBuiltIn': 'Integriert', + 'chat.draftStarters.sectionCommands': 'Befehle', + 'chat.draftStarters.sectionSkills': 'Fähigkeiten', + 'chat.draftStarters.remove': 'Entfernen', + 'chat.scrollToBottom.aria': 'Zum Ende scrollen', + 'chat.promptNavigator.aria': 'Prompt-Navigation', + 'chat.promptNavigator.currentPrompt': 'Aktueller Prompt', + 'chat.promptNavigator.loadMore': 'Weitere Prompts laden', + 'chat.timeline.relative.justNow': 'gerade jetzt', + 'chat.timeline.relative.minutesAgo': '{count}m her', + 'chat.timeline.relative.hoursAgo': '{count}h her', + 'chat.timeline.relative.daysAgo': '{count}d her', + 'chat.timeline.title': 'Konversations-Zeitleiste', + 'chat.timeline.description': 'Navigieren Sie zu einem beliebigen Punkt in der Konversation oder erstellen Sie eine neue Sitzung', + 'chat.timeline.searchPlaceholder': 'Nachrichten durchsuchen...', + 'chat.timeline.empty.search': 'Keine Nachrichten gefunden', + 'chat.timeline.empty.session': 'Noch keine Nachrichten in dieser Sitzung', + 'chat.timeline.noTextContent': '[Kein Textinhalt]', + 'chat.timeline.actions.title': 'Aktionen', + 'chat.timeline.actions.revertFromHere': 'Hierher zurücksetzen', + 'chat.timeline.actions.forkFromHere': 'Hier abzweigen', + 'chat.timeline.actions.previousTurn': 'Vorheriger Zug', + 'chat.timeline.actions.latest': 'Neueste', + 'chat.timeline.help.clickMessage': 'Klicken Sie auf eine Nachricht, um zur entsprechenden Stelle in der Konversation zu scrollen', + 'chat.timeline.help.undoToPoint': 'Zu diesem Punkt rückgängig machen (Nachrichtentext wird in das Eingabefeld übernommen)', + 'chat.timeline.help.createSessionFromHere': 'Neue Sitzung ab hier starten', + 'chat.statusRow.todo.status.inProgress': 'In Bearbeitung', + 'chat.statusRow.todo.status.pending': 'Ausstehend', + 'chat.statusRow.todo.status.completed': 'Abgeschlossen', + 'chat.statusRow.todo.status.cancelled': 'Abgebrochen', + 'chat.statusRow.todo.priority.high': 'Hohe Priorität', + 'chat.statusRow.todo.priority.medium': 'Mittlere Priorität', + 'chat.statusRow.todo.priority.low': 'Niedrige Priorität', + 'chat.statusRow.actions.stopGeneratingAria': 'Generierung stoppen', + 'chat.statusRow.tasksTitle': 'Aufgaben', + 'chat.statusRow.summary.activeLeft': '{active} aktiv · {left} übrig', + 'chat.statusRow.aborted': 'Abgebrochen', + 'chat.revertIndicator.redo': 'Wiederholen', + 'chat.revertIndicator.redoAria': 'Wiederholen — wiederhergestellte Nachrichten', + 'chat.revertPopover.title': 'Zurückgesetzt', + 'chat.revertPopover.noTextContent': 'Kein Textinhalt', + 'chat.revertPopover.forkFromHere': 'Hier forken', + 'chat.revertPopover.forkFromMessage': 'Von dieser Nachricht forken', + 'chat.revertPopover.restoreAll': 'Alle wiederherstellen', + 'chat.revertPopover.restore': 'Wiederherstellen', + 'chat.revertPopover.revert': 'Zurücksetzen', + 'chat.revertPopover.fork': 'Fork', + 'chat.revert.toast.undo': 'Zurückgesetzt auf {preview}', + 'chat.revert.toast.redo': 'Wiederholt', + 'chat.revert.toast.restored': 'Alle Nachrichten wiederhergestellt', + 'chat.errorBoundary.title': 'Chat-Fehler', + 'chat.errorBoundary.description': 'Die Chat-Oberfläche hat einen Fehler festgestellt. Dies könnte auf ein vorübergehendes Netzwerkproblem oder beschädigte Nachrichtendaten zurückzuführen sein.', + 'chat.errorBoundary.sessionLabel': 'Sitzung', + 'chat.errorBoundary.detailsSummary': 'Fehlerdetails', + 'chat.errorBoundary.resetAction': 'Chat zurücksetzen', + 'chat.errorBoundary.persistentHint': 'Wenn das Problem weiterhin besteht, versuchen Sie, die Seite neu zu laden.', + 'chat.autocomplete.tabs.commands': 'Befehle', + 'chat.autocomplete.tabs.agents': 'Agenten', + 'chat.autocomplete.tabs.files': 'Dateien', + 'chat.autocomplete.keyboardHint': '↑↓ navigieren • Enter auswählen • Esc schließen', + 'chat.commandAutocomplete.command.initDescription': 'AGENTS.md-Datei erstellen/aktualisieren', + 'chat.commandAutocomplete.command.undoDescription': 'Letzte Nachricht rückgängig machen', + 'chat.commandAutocomplete.command.redoDescription': 'Zuvor rückgängig gemachte Nachrichten erneut ausführen', + 'chat.commandAutocomplete.command.timelineDescription': 'Konversations-Zeitleiste öffnen', + 'chat.commandAutocomplete.command.compactDescription': 'Sitzungsverlauf mit KI komprimieren, um die Kontextgröße zu reduzieren', + 'chat.commandAutocomplete.command.summaryDescription': 'Nicht-destructive Zusammenfassung der Sitzung. Optionaler Themenhinweis nach dem Befehl.', + 'chat.commandAutocomplete.command.workspaceReviewDescription': 'Überprüfung des Arbeitsbereichs-Unterschieds hinsichtlich Absicht, Richtigkeit und Angemessenheit, bewertet nach Schweregrad.', + 'chat.commandAutocomplete.command.handoffReviewDescription': 'Erstellen oder Wiederverwenden einer separaten Überprüfungssitzung aus einem generierten Handoff.', + 'chat.commandAutocomplete.command.featurePlanDescription': 'Starte eine geführte, hin und her gehende Planungssitzung für eine neue Funktion.', + 'chat.commandAutocomplete.command.craftGoalDescription': 'Verwandelt eine Idee oder Aufgabe in ein klares, überprüfbares Ziel.', + 'chat.chatInput.toast.craftGoalFailed': 'Fehler beim Starten der Ziel-Erstellung', + 'chat.commandAutocomplete.command.catchUpDescription': 'Kontext wiederherstellen: Was du getan hast und wo du weitermachen sollst.', + 'chat.commandAutocomplete.command.debugDescription': 'Geführte Ursachenforschung für einen Fehler, bevor eine Lösung vorgeschlagen wird.', + 'chat.commandAutocomplete.command.weighDescription': 'Zwei bis drei Ansätze mit Kompromissen und einer Empfehlung bewerten, bevor du dich entscheidest.', + 'chat.commandAutocomplete.command.exploreDescription': 'Vertraut machen mit diesem Codebase: Eine Übersicht über die Architektur und Hauptbestandteile.', + 'chat.commandAutocomplete.badge.skill': 'Fähigkeit', + 'chat.commandAutocomplete.badge.command': 'Befehl', + 'chat.commandAutocomplete.badge.system': 'System', + 'chat.commandAutocomplete.empty': 'Keine Befehle gefunden', + 'chat.agentMentionAutocomplete.badge.system': 'System', + 'chat.agentMentionAutocomplete.empty': 'Keine Agenten gefunden', + 'chat.fileMentionAutocomplete.searchMoreAgents': 'Tippe, um mehr Agenten zu suchen', + 'chat.fileMentionAutocomplete.empty': 'Keine Übereinstimmungen gefunden', + 'chat.queuedMessage.attachments': '+{count} Datei(en)', + 'chat.queuedMessage.empty': '(leer)', + 'chat.queuedMessage.title': 'Nachrichten in der Warteschlange', + 'chat.queuedMessage.edit': 'bearbeiten', + 'chat.queuedMessage.send': 'senden', + 'chat.queuedMessage.removeAria': 'Aus der Warteschlange entfernen', + 'chat.queuedMessage.reorderAria': 'Ziehen, um neu anzuordnen', + 'chat.container.returnToParent.aria': 'Zurück zur übergeordneten Sitzung', + 'chat.container.returnToParent.titleNamed': 'Zurück zu: {title}', + 'chat.container.returnToParent.title': 'Zurück zur übergeordneten Sitzung', + 'chat.container.returnToParent.label': 'Übergeordnet', + 'chat.container.readOnlySubagentPromptBanner': 'Subagent-Sitzungen können nicht abgefragt werden.', + 'chat.unifiedControls.title': 'Steuerung', + 'chat.unifiedControls.model.title': 'Modell', + 'chat.unifiedControls.model.noRecent': 'Keine kürzlichen Modelle', + 'chat.unifiedControls.model.moreAria': 'Weitere Modelle', + 'chat.unifiedControls.effort.title': 'Aufwand', + 'chat.unifiedControls.effort.moreAria': 'Weitere Aufwandsoptionen', + 'chat.questionCard.summaryTab': 'Zusammenfassung', + 'chat.questionCard.noAnswer': '(keine Antwort)', + 'chat.questionCard.inputNeeded': 'Eingabe erforderlich', + 'chat.questionCard.fromSubagent': 'Von Subagent', + 'chat.questionCard.questionFallback': 'Frage {index}', + 'chat.questionCard.selectMultiple': 'Mehrere auswählen', + 'chat.questionCard.recommended': 'empfohlen', + 'chat.questionCard.other': 'Andere…', + 'chat.questionCard.yourAnswer': 'Ihre Antwort', + 'chat.questionCard.submit': 'Senden', + 'chat.questionCard.next': 'Weiter', + 'chat.questionCard.dismiss': 'Verwerfen', + 'chat.questionCard.copyMarkdown': 'Als Markdown kopieren', + 'chat.questionCard.copyJson': 'Als JSON kopieren', + 'chat.questionCard.copiedMarkdown': 'Frage als Markdown kopiert', + 'chat.questionCard.copiedJson': 'Frage als JSON kopiert', + 'chat.questionCard.copyFailed': 'Fehler beim Kopieren der Frage', + 'chat.questionCard.submitFailed': 'Fehler beim Senden der Antwort', + 'chat.questionCard.dismissFailed': 'Fehler beim Verwerfen der Frage', + 'chat.questionCard.noLongerPending': 'Diese Frage wartet nicht mehr auf eine Antwort.', + 'chat.questionCard.tryAgain': 'Bitte versuchen Sie es in einem Moment erneut.', + 'chat.textSelection.toast.noProject': 'Kein Projekt für diese Sitzung gefunden', + 'chat.textSelection.toast.addToNotesFailed': 'Fehler beim Hinzufügen zu Notizen', + 'chat.textSelection.toast.addToNotesSuccess': 'Ausgewählter Text zu Notizen hinzugefügt', + 'chat.textSelection.toast.addToNotesSummaryFailed': 'Zusammenfassung der Auswahl nicht möglich, ausgewählter Text wurde zu Notizen hinzugefügt', + 'chat.textSelection.actions.addToChat': 'Zum Chat hinzufügen', + 'chat.textSelection.actions.newSession': 'Neue Sitzung', + 'chat.textSelection.actions.copy': 'Kopieren', + 'chat.textSelection.actions.addToNotes': 'Zu Notizen hinzufügen', + 'chat.textSelection.title.addToCurrentChat': 'Zum aktuellen Chat hinzufügen', + 'chat.textSelection.title.newSessionWithSelection': 'Neue Sitzung mit Auswahl erstellen', + 'chat.textSelection.title.saveInsightToNotes': 'Ausgewählten Text zu Notizen speichern', + 'chat.messageBody.actions.revertAria': 'Zu dieser Nachricht zurückkehren', + 'chat.messageBody.actions.revert': 'Von hier zurückkehren', + 'chat.messageBody.actions.forkAria': 'Abzweigen von dieser Nachricht', + 'chat.messageBody.actions.fork': 'Abzweigen von hier', + 'chat.messageBody.actions.copyMessageAria': 'Nachrichtentext kopieren', + 'chat.messageBody.actions.copyMessage': 'Nachricht kopieren', + 'chat.messageBody.actions.openPreviewAria': 'Vorschau öffnen', + 'chat.messageBody.actions.openPreview': 'Vorschau öffnen', + 'chat.messageBody.actions.copyAnswer': 'Antwort kopieren', + 'chat.messageBody.actions.savingImage': 'Bild wird gespeichert...', + 'chat.messageBody.actions.saveAsImage': 'Als Bild speichern', + 'chat.messageBody.actions.saveAsPlan': 'Als Plan speichern', + 'chat.messageBody.actions.startNewSession': 'Neue Sitzung von dieser Antwort starten', + 'chat.messageBody.actions.startNewMultiRun': 'Neuer Multi-Run von dieser Antwort starten', + 'chat.messageBody.forkDialog.instructions.label': 'Anweisungen', + 'chat.messageBody.forkDialog.instructions.placeholder': 'Fügen Sie Anweisungen für die neue Sitzung hinzu…', + 'chat.messageBody.forkDialog.createWorktree': 'Worktree erstellen', + 'chat.generatedResult.actions.copy': 'Kopieren', + 'chat.generatedResult.actions.copied': 'Kopiert', + 'chat.generatedResult.commit.title': 'Generierte Commit-Nachricht', + 'chat.generatedResult.commit.highlights': 'Highlights', + 'chat.generatedResult.pullRequest.title': 'Generierter Pull Request', + 'chat.generatedResult.pullRequest.titleLabel': 'Titel', + 'chat.generatedResult.pullRequest.bodyLabel': 'Inhalt', + 'chat.messageBody.tts.stopSpeaking': 'Sprache stoppen', + 'chat.messageBody.tts.readAloud': 'Vorlesen', + 'chat.messageBody.tts.readAloudWithProvider': 'Vorlesen ({provider} Stimme)', + 'planView.tts.readAloud': 'Plan vorlesen', + 'planView.tts.stopSpeaking': 'Sprache stoppen', + 'filesView.tts.readAloud': 'Datei vorlesen', + 'filesView.tts.stopSpeaking': 'Sprache stoppen', + 'chat.messageBody.toast.noProject': 'Kein Projekt für diese Sitzung gefunden', + 'chat.messageBody.toast.savePlanFailed': 'Fehler beim Speichern des Plans', + 'chat.messageBody.toast.planSaved': 'Plan gespeichert', + 'chat.messageBody.toast.imageSaved': 'Bild gespeichert', + 'chat.messageBody.toast.generateImageFailed': 'Fehler bei der Bildgenerierung', + 'chat.chatInput.actions.commands': 'Befehle', + 'chat.chatInput.actions.attachFiles': 'Dateien anhängen', + 'chat.chatInput.actions.addAttachment': 'Anhang hinzufügen', + 'chat.chatInput.actions.linkGithubIssue': 'GitHub-Issue verknüpfen', + 'chat.chatInput.actions.linkGithubPr': 'GitHub-PR verknüpfen', + 'chat.chatInput.actions.modelAgentSettings': 'Modell- und Agenteneinstellungen', + 'chat.chatInput.actions.sendMessageAria': 'Nachricht senden', + 'chat.chatInput.actions.queueMessageAria': 'Nachricht in die Warteschlange stellen', + 'chat.chatInput.actions.stopGeneratingAria': 'Generierung stoppen', + 'chat.chatInput.focusMode.toggleAria': 'Fokusmodus umschalten', + 'chat.chatInput.focusMode.label': 'Fokusmodus', + 'chat.chatInput.permissionAutoAccept.disable': 'Automatische Zustimmung für Berechtigungen deaktivieren', + 'chat.chatInput.permissionAutoAccept.enable': 'Automatische Zustimmung für Berechtigungen aktivieren', + 'chat.chatInput.permissionAutoAccept.on': 'Automatische Zustimmung für Berechtigungen: an', + 'chat.chatInput.permissionAutoAccept.off': 'Automatische Zustimmung für Berechtigungen: aus', + 'chat.chatInput.linked.byAuthor': 'von {author}', + 'chat.chatInput.linked.issue.openInBrowserAria': 'Issue im Browser öffnen', + 'chat.chatInput.linked.issue.removeAria': 'Verknüpftes Issue entfernen', + 'chat.chatInput.linked.pr.number': 'PR #{number}', + 'chat.chatInput.linked.pr.openInBrowserAria': 'Pull Request im Browser öffnen', + 'chat.chatInput.linked.pr.removeAria': 'Verknüpften Pull Request entfernen', + 'chat.chatInput.placeholder.shell': 'Shell-Befehl eingeben...', + 'chat.chatInput.placeholder.chat': '@ für Dateien/Agenten; / für Befehle und Fähigkeiten; ! für Shell; # für Snippets', + 'chat.chatInput.placeholder.chatCompact': 'Verwende @ / ! # für Hilfsmittel', + 'chat.chatInput.placeholder.selectSession': 'Sitzung auswählen oder erstellen, um zu chatten', + 'chat.dictation.start': 'Dictation starten', + 'chat.dictation.overlayAria': 'Dictation', + 'chat.dictation.downloadingModel': 'Sprachmodell wird heruntergeladen...', + 'chat.dictation.downloadingModelProgress': 'Sprachmodell wird heruntergeladen... {percent}%', + 'chat.dictation.listening': 'Hören...', + 'chat.dictation.processing': 'Transkribieren...', + 'chat.dictation.failed': 'Transkription fehlgeschlagen', + 'chat.dictation.cancel': 'Dictation verwerfen', + 'chat.dictation.insert': 'Transkript einfügen', + 'chat.dictation.insertAndSend': 'Einfügen und senden', + 'chat.dictation.retry': 'Transkription erneut versuchen', + 'chat.dictation.discard': 'Aufnahme verwerfen', + 'chat.snippetAutocomplete.action.addNew': '+ Neues Snippet hinzufügen', + 'chat.snippetAutocomplete.empty': 'Keine Snippets gefunden', + 'chat.snippetAutocomplete.footer': '↑↓ navigieren • Enter auswählen • Esc schließen', + 'snippets.source.global': 'global', + 'snippets.source.project': 'Projekt', + 'chat.chatInput.toast.compactFailed': 'Fehler beim Komprimieren der Sitzung', + 'chat.chatInput.toast.summaryFailed': 'Fehler bei der Erstellung der Zusammenfassung', + 'chat.messageBody.actions.sendReviewFeedback': 'Feedback zur Überprüfung an den implementierenden Agenten senden', + 'chat.messageBody.actions.sendImplementationResponse': 'Implementierungsantwort an den überprüfenden Agenten senden', + 'chat.chatInput.toast.reviewFailed': 'Fehler bei der Überprüfung der Änderungen', + 'chat.chatInput.toast.planFeatureFailed': 'Fehler beim Starten der Funktionsplanung', + 'chat.chatInput.toast.catchUpFailed': 'Fehler beim Aufholen', + 'chat.chatInput.toast.debugFailed': 'Fehler beim Starten der Debugging', + 'chat.chatInput.toast.weighFailed': 'Fehler beim Vergleichen der Optionen', + 'chat.chatInput.toast.exploreFailed': 'Fehler beim Starten der Tour', + 'chat.chatInput.toast.attachmentsTooLarge': 'Anhänge sind zu groß zum Senden. Bitte versuche, die Anzahl oder Größe der Bilder zu reduzieren.', + 'chat.chatInput.toast.sendAttachmentsFailed': 'Fehler beim Senden der Anhänge. Versuche weniger Dateien oder kleinere Bilder.', + 'chat.chatInput.toast.messageSendFailed': 'Nachricht konnte nicht gesendet werden. Anhänge wurden wiederhergestellt.', + 'chat.chatInput.toast.clipboardAttachFailed': 'Fehler beim Anhängen des Bildes aus der Zwischenablage', + 'chat.chatInput.toast.addedFileMentions': '{count} Datei(er) hinzugefügt', + 'chat.chatInput.toast.attachFileFailed': 'Fehler beim Anhängen der Datei', + 'chat.chatInput.toast.attachNamedFailed': 'Fehler beim Anhängen von {name}', + 'chat.chatInput.toast.someFilesSkipped': 'Einige Dateien wurden übersprungen:\n{summary}', + 'chat.chatInput.toast.vscodePickFailed': 'Fehler beim Auswählen der Dateien in VS Code', + 'chat.chatInput.toast.openSessionFirst': 'Öffne zuerst eine Sitzung', + 'chat.chatInput.toast.togglePermissionAutoAcceptFailed': 'Fehler beim Umschalten der automatischen Zustimmung zur Berechtigung', + 'chat.chatInput.reviewComments': 'Kommentare zur Überprüfung:', + 'chat.chatInput.reviewCommentsRemove': 'Kommentare zur Überprüfung entfernen', + 'chat.chatInput.devServerLogs': 'Entwicklungsserver-Protokolle:', + 'chat.chatInput.devServerLogsRemove': 'Entwicklungsserver-Protokolle entfernen', + 'chat.chatInput.previewAnnotations': 'Vorschau-Anmerkungen:', + 'chat.chatInput.previewContext': 'Vorschau-Kontext:', + 'chat.chatInput.previewContextRemove': 'Vorschau-Kontext entfernen', + 'chat.chatInput.projectRoot': 'Projektstamm', + 'chat.chatInput.branch': 'Zweig', + 'chat.chatInput.draftPicker.projectTitle': 'Projekt', + 'chat.chatInput.draftPicker.searchProjects': 'Projekte durchsuchen...', + 'chat.chatInput.draftPicker.searchBranches': 'Branches durchsuchen...', + 'chat.chatInput.worktrees': 'Worktrees', + 'chat.chatInput.worktreeNew': '+ Neu', + 'chat.chatInput.drop.insertMention': 'Hier ablegen, um als Erwähnung einzufügen', + 'chat.chatInput.drop.attachFiles': 'Dateien hier ablegen, um sie anzuhängen', + 'chat.chatInput.fileFallback': 'Datei', + 'chat.toolOutputDialog.image.previousAria': 'Vorheriges Bild', + 'chat.toolOutputDialog.image.nextAria': 'Nächstes Bild', + 'chat.toolOutputDialog.image.closeAria': 'Bildvorschau schließen', + 'chat.toolOutputDialog.mermaid.missingSource': 'Fehlende Mermaid-Quell-URL.', + 'chat.toolOutputDialog.mermaid.loadFailed': 'Kann Mermaid-Diagramm nicht laden.', + 'chat.toolOutputDialog.mermaid.dataUrlMalformed': 'Die Mermaid-Daten-URL ist fehlerhaft formatiert.', + 'chat.toolOutputDialog.mermaid.invalidLocalPath': 'Der lokale Mermaid-Dateipfad ist ungültig.', + 'chat.toolOutputDialog.mermaid.readFileFailedWithStatus': 'Kann Mermaid-Datei nicht lesen. Status: {status}.', + 'chat.toolOutputDialog.mermaid.unsupportedUrlProtocol': 'Das Mermaid-URL-Protokoll wird nicht unterstützt.', + 'chat.toolOutputDialog.mermaid.loadFailedWithStatus': 'Kann Mermaid-Diagramm nicht laden. Status: {status}.', + 'chat.toolOutputDialog.mermaid.closeAria': 'Diagrammvorschau schließen', + 'chat.toolOutputDialog.mermaid.loading': 'Diagramm wird geladen...', + 'chat.toolOutputDialog.mermaid.renderFailed': 'Kann Mermaid-Diagramm nicht rendern.', + 'chat.toolOutputDialog.mermaid.retry': 'Wiederholen', + 'chat.toolOutputDialog.commandCompleted': 'Befehl erfolgreich abgeschlossen', + 'chat.toolOutputDialog.noOutputProduced': 'Keine Ausgabe erzeugt', + 'chat.toolPart.lspErrors': 'LSP-Fehler', + 'chat.toolPart.moreErrors': '+{count} weitere Fehler', + 'chat.toolPart.error': 'Fehler:', + 'chat.toolPart.awaitingResponse': 'Warte auf Antwort...', + 'chat.toolPart.noOutputProduced': 'Keine Ausgabe erzeugt', + 'chat.toolPart.output': 'Ausgabe', + 'chat.toolPart.showRawJson': 'Rohe JSON anzeigen', + 'chat.toolPart.showFormattedJson': 'Formatierte JSON anzeigen', + 'chat.toolPart.showNavigableJson': 'Navigierbare JSON anzeigen', + 'chat.toolPart.openFileAtFirstChange': 'Datei bei erster Änderung öffnen', + 'chat.toolPart.openFileDiff': 'Datei-Unterschied öffnen', + 'chat.toolPart.copyOutput': 'Ausgabe kopieren', + 'chat.toolPart.copiedOutput': 'Ausgabe kopiert', + 'chat.toolPart.copyOutputFailed': 'Kopieren der Ausgabe fehlgeschlagen', + 'chat.toolPart.openSubtask': '{type}-Unteraufgabe öffnen', + 'chat.todo.total': 'Gesamt', + 'chat.todo.inProgress': 'In Bearbeitung', + 'chat.todo.pending': 'Ausstehend', + 'chat.todo.completed': 'Abgeschlossen', + 'chat.todo.cancelled': 'Abgebrochen', + 'chat.permissionCard.workingDirectory': 'Arbeitsverzeichnis:', + 'chat.permissionCard.timeout': 'Timeout:', + 'chat.permissionCard.request': 'Anfrage:', + 'chat.permissionCard.headers': 'Headers:', + 'chat.permissionCard.body': 'Body:', + 'chat.permissionCard.action': 'Aktion:', + 'chat.permissionCard.details': 'Details:', + 'chat.permissionCard.patterns': 'Muster:', + 'chat.permissionToast.sessionFallback': 'Sitzung', + 'chat.permissionToast.permissionFallback': 'Berechtigungsdetails nicht verfügbar', + 'chat.permissionToast.labels.session': 'Sitzung:', + 'chat.permissionToast.labels.permission': 'Berechtigung:', + 'chat.permissionToast.actions.once': 'Einmal', + 'chat.permissionToast.actions.always': 'Immer', + 'chat.permissionToast.actions.deny': 'Ablehnen', + 'chat.permissionToast.actions.approveOnceAria': 'Einmal genehmigen', + 'chat.permissionToast.actions.approveOnceAriaWithSession': 'Einmal für {session} genehmigen', + 'chat.permissionToast.actions.approveAlwaysAria': 'Immer genehmigen', + 'chat.permissionToast.actions.approveAlwaysAriaWithSession': 'Immer für {session} genehmigen', + 'chat.permissionToast.actions.denyAria': 'Berechtigung ablehnen', + 'chat.permissionToast.actions.denyAriaWithSession': 'Berechtigung für {session} ablehnen', + 'chat.permissionRequest.required': 'Berechtigung erforderlich:', + 'chat.permissionRequest.actions.once': 'Einmal', + 'chat.permissionRequest.actions.always': 'Immer', + 'chat.permissionRequest.actions.reject': 'Ablehnen', + 'chat.modelControls.provider': 'Anbieter', + 'chat.modelControls.capability.toolCalling': 'Tool-Aufruf', + 'chat.modelControls.capability.reasoning': 'Reasoning', + 'chat.modelControls.modality.text': 'Text', + 'chat.modelControls.modality.image': 'Bild', + 'chat.modelControls.modality.video': 'Video', + 'chat.modelControls.modality.audio': 'Audio', + 'chat.modelControls.modality.pdf': 'PDF', + 'chat.modelControls.capabilities': 'Fähigkeiten', + 'chat.modelControls.modalities': 'Modalitäten', + 'chat.modelControls.input': 'Eingabe', + 'chat.modelControls.output': 'Ausgabe', + 'chat.modelControls.limits': 'Grenzen', + 'chat.modelControls.context': 'Kontext', + 'chat.modelControls.metadata': 'Metadaten', + 'chat.modelControls.knowledge': 'Wissen', + 'chat.modelControls.release': 'Veröffentlichung', + 'chat.modelControls.mode': 'Modus', + 'chat.modelControls.model': 'Modell', + 'chat.modelControls.temperature': 'Temperatur', + 'chat.modelControls.topP': 'Top P', + 'chat.modelControls.permissions': 'Berechtigungen', + 'chat.modelControls.edit': 'Bearbeiten', + 'chat.modelControls.bash': 'Bash', + 'chat.modelControls.webFetch': 'WebFetch', + 'chat.modelControls.customPrompt': 'Benutzerdefinierte Eingabeaufforderung', + 'chat.modelControls.selectModel': 'Modell auswählen', + 'chat.modelControls.searchProvidersOrModels': 'Anbieter oder Modelle suchen', + 'chat.modelControls.clearSearch': 'Suche löschen', + 'chat.modelControls.current': 'Aktuell', + 'chat.modelControls.thinking': 'Denken', + 'chat.modelControls.default': 'Standard', + 'chat.modelControls.selectAgent': 'Agent auswählen', + 'chat.modelControls.costPerMillion': 'Kosten ($/1M Tokens)', + 'chat.modelControls.metadataUnavailable': 'Modell-Metadaten nicht verfügbar.', + 'chat.modelControls.addNewProvider': 'Neuen Anbieter hinzufügen', + 'chat.modelControls.noAgentSelected': 'Kein Agent ausgewählt.', + 'chat.modelControls.resetToDefault': 'Auf Standard zurücksetzen', + 'chat.modelControls.searchModels': 'Modelle suchen', + 'chat.modelControls.searchAgents': 'Agenten suchen', + 'chat.modelControls.noModelsFound': 'Keine Modelle gefunden', + 'chat.modelControls.favorites': 'Favoriten', + 'chat.modelControls.recent': 'Kürzlich', + 'chat.modelControls.addToFavorites': 'Zu Favoriten hinzufügen', + 'chat.modelControls.removeFromFavorites': 'Aus Favoriten entfernen', + 'chat.modelControls.favoriteAria': 'Favorit', + 'chat.modelControls.unfavoriteAria': 'Nicht favorit', + 'chat.modelControls.collapseProvider': 'Anbieter einklappen', + 'chat.modelControls.expandProvider': 'Anbieter ausklappen', + 'chat.modelControls.keyboardHint': '↑↓ navigieren{thinking} • Enter auswählen • Esc schließen', + 'chat.modelControls.keyboardHintNavigate': '↑↓ navigieren', + 'chat.modelControls.keyboardHintSwitchAgent': '{shortcut} Agent wechseln', + 'chat.modelControls.keyboardHintThinking': '←→ denken', + 'chat.modelControls.showThinkingModes': 'Denkmodi anzeigen', + 'chat.modelControls.hideThinkingModes': 'Denkmodi ausblenden', + 'chat.modelControls.moreThinkingModes': 'Weitere Denkmodi', + 'chat.modelControls.noProvidersOrModelsFound': 'Keine Anbieter oder Modelle entsprechen Ihrer Suche.', + 'chat.modelControls.reorderFavoriteAria': 'Favorit neu anordnen', + 'chat.modelControls.reorderFavoriteTitle': 'Ziehen, um Favorit neu anzuordnen', + 'chat.modelControls.reorderProviderTitle': 'Ziehen, um Anbieter neu anzuordnen', + 'chat.modelControls.permissionLabel.custom': 'Benutzerdefiniert', + 'chat.modelControls.permissionLabel.allow': 'Erlauben', + 'chat.modelControls.permissionLabel.deny': 'Ablehnen', + 'chat.modelControls.permissionLabel.ask': 'Fragen', + 'chat.modelControls.modeValue.primary': 'Primär', + 'chat.modelControls.modeValue.subagent': 'Unteragent', + 'chat.modelControls.modeValue.all': 'Alle', + 'chat.modelControls.modeValue.none': '—', + 'chat.reasoningTrace.thinking': 'Denken', + 'chat.reasoningTrace.justification': 'Begründung', + 'chat.reasoningTrace.expandAria': 'Begründungstrace erweitern', + 'chat.reasoningTrace.collapseAria': 'Begründungstrace reduzieren', + 'chat.reasoningTrace.thought': 'Gedanke', + 'chat.messageBody.subtask.title': 'Delegierte Aufgabe', + 'chat.messageBody.subtask.hidePrompt': 'Eingabe ausblenden', + 'chat.messageBody.subtask.showPrompt': 'Eingabe anzeigen', + 'chat.messageBody.subtask.openSession': 'Unteraufgaben-Sitzung öffnen', + 'chat.messageBody.shellCommand.title': 'Shell-Befehl', + 'chat.messageBody.shellCommand.hideOutput': 'Ausgabe ausblenden', + 'chat.messageBody.shellCommand.showOutput': 'Ausgabe anzeigen', + 'chat.messageBody.shellCommand.copied': 'Kopiert', + 'chat.messageBody.shellCommand.copyOutput': 'Ausgabe kopieren', + 'commandPalette.title': 'Befehlspalette', + 'commandPalette.description': 'Dateien, Sitzungen und Befehle durchsuchen.', + 'commandPalette.input.placeholder': 'Dateien, Sitzungen, Befehle durchsuchen...', + 'commandPalette.empty.noResults': 'Keine Ergebnisse gefunden.', + 'commandPalette.empty.searchingFiles': 'Dateien werden durchsucht...', + 'commandPalette.item.newSession': 'Neue Sitzung', + 'commandPalette.item.newMiniChat': 'Neues Mini-Chat-Fenster', + 'commandPalette.item.newWorktreeDraft': 'Neuer Worktree-Entwurf', + 'commandPalette.item.addProject': 'Projekt hinzufügen', + 'commandPalette.item.showSessionSwitcher': 'Sitzungsumschalter anzeigen', + 'commandPalette.item.toggleSidebar': 'Seitenleiste umschalten', + 'commandPalette.item.showContextUsage': 'Kontextnutzung anzeigen', + 'commandPalette.item.toggleTerminal': 'Terminal umschalten', + 'commandPalette.item.openSettings': 'Einstellungen öffnen...', + 'commandPalette.session.untitled': 'Unbenannte Sitzung', + 'openCodeStatusDialog.title': 'OpenCode-Status', + 'openCodeStatusDialog.description': 'Aktuellen OpenCode-Status und Diagnoseergebnisse prüfen.', + 'openCodeStatusDialog.actions.copy': 'Kopieren', + 'openCodeStatusDialog.empty.noData': 'Keine Daten.', + 'openCodeStatusDialog.toast.copiedTitle': 'Kopiert', + 'openCodeStatusDialog.toast.copiedDescription': 'Status in die Zwischenablage kopiert', + 'openCodeStatusDialog.toast.copyFailed': 'Fehler beim Kopieren des Status', + 'saveProjectPlanDialog.title': 'Projektplan speichern', + 'saveProjectPlanDialog.description': 'Diesen Plan als Markdown-Datei in Ihrem Projekt speichern.', + 'saveProjectPlanDialog.field.title': 'Titel', + 'saveProjectPlanDialog.field.titlePlaceholder': 'Plan-Titel', + 'saveProjectPlanDialog.field.contentPreview': 'Inhaltsvorschau', + 'saveProjectPlanDialog.actions.cancel': 'Abbrechen', + 'saveProjectPlanDialog.actions.save': 'Speichern', + 'saveProjectPlanDialog.actions.saving': 'Speichern...', + 'branchPickerDialog.title': 'Branch-Auswähler', + 'branchPickerDialog.description.localBranchesForProject': 'Lokale Branches für {project}', + 'branchPickerDialog.description.selectProject': 'Projekt auswählen, um Branches anzuzeigen', + 'branchPickerDialog.search.placeholder': 'Branches durchsuchen...', + 'branchPickerDialog.search.renameBranchPlaceholder': 'Branch umbenennen', + 'branchPickerDialog.state.noProjectSelected': 'Kein Projekt ausgewählt', + 'branchPickerDialog.state.loadingBranches': 'Branches werden geladen...', + 'branchPickerDialog.state.noMatchingBranches': 'Keine passenden Branches', + 'branchPickerDialog.state.noBranchesFound': 'Keine Branches gefunden', + 'branchPickerDialog.badge.head': 'HEAD', + 'branchPickerDialog.badge.worktree': 'Worktree', + 'branchPickerDialog.actions.createWorktreeAria': 'Worktree aus Branch erstellen', + 'branchPickerDialog.actions.renameAria': 'Branch umbenennen', + 'branchPickerDialog.actions.deleteAria': 'Branch löschen', + 'branchPickerDialog.actions.deleteWorktreeAria': 'Arbeitsbaum entfernen', + 'branchPickerDialog.actions.confirmRenameAria': 'Umbenennen bestätigen', + 'branchPickerDialog.actions.cancelRenameAria': 'Umbenennen abbrechen', + 'branchPickerDialog.actions.confirmDeleteAria': 'Löschen des Branches bestätigen', + 'branchPickerDialog.actions.cancelDeleteAria': 'Löschen des Branches abbrechen', + 'branchPickerDialog.actions.deletePrompt': 'Löschen', + 'branchPickerDialog.actions.forceDeletePrompt': 'Löschen erzwingen', + 'branchPickerDialog.tooltip.createWorktree': 'Arbeitsbaum erstellen', + 'branchPickerDialog.tooltip.worktreeAlreadyExists': 'Arbeitsbaum existiert bereits', + 'branchPickerDialog.tooltip.rename': 'Branch umbenennen', + 'branchPickerDialog.tooltip.renameDisabledForRoot': 'Root-Branch kann nicht umbenannt werden', + 'branchPickerDialog.tooltip.delete': 'Branch löschen', + 'branchPickerDialog.tooltip.deleteCurrentBranch': 'Aktueller Branch kann nicht gelöscht werden', + 'branchPickerDialog.tooltip.deleteDisabledForRoot': 'Root-Branch kann nicht gelöscht werden', + 'branchPickerDialog.tooltip.deleteWorktree': 'Arbeitsbaum entfernen', + 'branchPickerDialog.tooltip.deleteWorktreeRootProtected': 'Root-Arbeitsbaum kann nicht entfernt werden', + 'branchPickerDialog.toast.branchRenamed': 'Branch umbenannt', + 'branchPickerDialog.toast.failedToRenameBranch': 'Fehler beim Umbenennen des Branches', + 'branchPickerDialog.toast.branchDeleted': 'Branch gelöscht', + 'branchPickerDialog.toast.branchNotMerged': 'Branch ist nicht vollständig gemerged', + 'branchPickerDialog.toast.confirmAgainToForceDelete': 'Erneut bestätigen, um Löschen zu erzwingen', + 'branchPickerDialog.toast.failedToDeleteBranch': 'Fehler beim Löschen des Branches', + 'branchPickerDialog.toast.worktreeCreated': 'Arbeitsbaum erstellt', + 'branchPickerDialog.toast.failedToCreateWorktree': 'Fehler beim Erstellen des Arbeitsbaums', + 'branchPickerDialog.error.failedToLoad': 'Fehler beim Laden der Branches', + 'branchPickerDialog.error.renameRejected': 'Umbenennen wurde abgelehnt', + 'branchPickerDialog.error.renameFailed': 'Fehler beim Umbenennen des Branches', + 'branchPickerDialog.error.deleteRejected': 'Löschen wurde abgelehnt', + 'branchPickerDialog.error.deleteFailed': 'Fehler beim Löschen des Branches', + 'branchPickerDialog.error.createWorktreeFailed': 'Fehler beim Erstellen des Worktrees', + 'projectEditDialog.title': 'Projekt bearbeiten', + 'projectEditDialog.field.name': 'Name', + 'projectEditDialog.field.namePlaceholder': 'Projektname', + 'projectEditDialog.field.color': 'Farbe', + 'projectEditDialog.field.icon': 'Symbol', + 'projectEditDialog.field.preview': 'Vorschau', + 'projectEditDialog.field.iconBackground': 'Symbol-Hintergrund', + 'projectEditDialog.field.iconBackgroundAria': 'Projekt-Symbol-Hintergrundfarbe', + 'projectEditDialog.option.none': 'Keine', + 'projectEditDialog.actions.uploadIcon': 'Symbol hochladen', + 'projectEditDialog.actions.uploading': 'Hochladen...', + 'projectEditDialog.actions.discoverFavicon': 'Favicon entdecken', + 'projectEditDialog.actions.discovering': 'Entdecken...', + 'projectEditDialog.actions.removeProjectIcon': 'Projekt-Symbol entfernen', + 'projectEditDialog.actions.removing': 'Entfernen...', + 'projectEditDialog.actions.undoRemove': 'Entfernen rückgängig machen', + 'projectEditDialog.actions.clear': 'Leeren', + 'projectEditDialog.actions.cancel': 'Abbrechen', + 'projectEditDialog.actions.save': 'Speichern', + 'projectEditDialog.toast.failedToUploadIcon': 'Fehler beim Hochladen des Projekt-Symbols', + 'projectEditDialog.toast.iconUpdated': 'Projekt-Symbol aktualisiert', + 'projectEditDialog.toast.failedToRemoveIcon': 'Fehler beim Entfernen des Projekt-Symbols', + 'projectEditDialog.toast.iconRemoved': 'Projekt-Symbol entfernt', + 'projectEditDialog.toast.failedToDiscoverIcon': 'Fehler bei der Erkennung des Projekt-Symbols', + 'projectEditDialog.toast.customIconAlreadySet': 'Benutzerdefiniertes Symbol bereits für dieses Projekt festgelegt', + 'projectEditDialog.toast.iconDiscovered': 'Projekt-Symbol entdeckt', + 'agentManager.sidebar.search.placeholder': 'Agent-Gruppen durchsuchen...', + 'agentManager.sidebar.actions.newAgentGroup': 'Neue Agent-Gruppe', + 'agentManager.sidebar.actions.more': '... Mehr ({count})', + 'agentManager.sidebar.actions.showLess': 'Weniger anzeigen', + 'agentManager.sidebar.section.agentGroups': 'Agentengruppen', + 'agentManager.sidebar.state.loading': 'Laden...', + 'agentManager.sidebar.state.noGroupsFound': 'Keine Gruppen gefunden', + 'agentManager.sidebar.state.noGroupsYet': 'Noch keine Agentengruppen', + 'agentManager.sidebar.state.createToGetStarted': 'Erstellen Sie eine neue Agentengruppe, um loszulegen', + 'agentManager.sidebar.item.modelCountSingle': '{count} Modell', + 'agentManager.sidebar.item.modelCountPlural': '{count} Modelle', + 'agentManager.sidebar.item.groupMenuAria': 'Gruppenmenü', + 'agentManager.sidebar.item.delete': 'Löschen', + 'agentManager.sidebar.relativeTime.now': 'jetzt', + 'agentManager.sidebar.relativeTime.minutes': '{count}m', + 'agentManager.sidebar.relativeTime.hours': '{count}h', + 'agentManager.sidebar.relativeTime.days': '{count}d', + 'agentManager.sidebar.dialog.deleteGroupTitle': 'Agentengruppe löschen', + 'agentManager.sidebar.dialog.deleteGroupDescription': '"{group}" löschen? Dadurch werden alle Worktrees und Sitzungen in dieser Gruppe entfernt.', + 'agentManager.sidebar.dialog.cancel': 'Abbrechen', + 'agentManager.sidebar.dialog.delete': 'Löschen', + 'agentManager.sidebar.dialog.deleting': 'Löschen...', + 'agentManager.sidebar.toast.deletingGroup': '"{group}" wird gelöscht...', + 'agentManager.sidebar.toast.deletedGroup': '"{group}" gelöscht', + 'agentManager.sidebar.toast.failedToDeleteGroup': 'Vollständiges Löschen von "{group}" fehlgeschlagen', + 'agentManager.detail.header.modelCountSingle': '{count} Modell', + 'agentManager.detail.header.modelCountPlural': '{count} Modelle', + 'agentManager.detail.header.noBranch': 'Kein Branch', + 'agentManager.detail.actions.worktreeActionsAria': 'Worktree-Aktionen', + 'agentManager.detail.actions.removeThisWorktree': 'Diesen Worktree entfernen', + 'agentManager.detail.actions.keepThisRemoveOthers': 'Diesen behalten, andere entfernen', + 'agentManager.detail.actions.copyWorktreePath': 'Worktree-Pfad kopieren', + 'agentManager.detail.dialog.removeWorktreeTitle': 'Worktree entfernen', + 'agentManager.detail.dialog.removeOtherWorktreesTitle': 'Andere Worktrees entfernen', + 'agentManager.detail.dialog.removeWorktreeDescription': '"{label}" entfernen? Dadurch werden alle Sitzungen in diesem Worktree gelöscht und der Worktree selbst entfernt.', + 'agentManager.detail.dialog.removeOtherWorktreesDescription': '"{label}" behalten und die anderen Worktrees in "{group}" entfernen.', + 'agentManager.detail.dialog.cancel': 'Abbrechen', + 'agentManager.detail.dialog.remove': 'Entfernen', + 'agentManager.detail.dialog.removeOthers': 'Andere entfernen', + 'agentManager.detail.dialog.working': 'Arbeite...', + 'agentManager.detail.state.loadingSessionFor': 'Lade Sitzung für {label}', + 'agentManager.detail.state.sessionId': 'Sitzungs-ID: {id}', + 'agentManager.detail.state.noSessionsInGroup': 'Keine Sitzungen in dieser Gruppe', + 'agentManager.detail.toast.noWorktreePath': 'Kein Worktree-Pfad verfügbar', + 'agentManager.detail.toast.worktreePathCopied': 'Worktree-Pfad kopiert', + 'agentManager.detail.toast.failedToCopyPath': 'Fehler beim Kopieren des Pfads', + 'agentManager.detail.toast.removingWorktree': 'Entferne Worktree...', + 'agentManager.detail.toast.removingOtherWorktrees': 'Entferne andere Worktrees...', + 'agentManager.detail.toast.failedToFullyRemoveWorktree': 'Fehler beim vollständigen Entfernen des Worktrees', + 'agentManager.detail.toast.worktreeRemoved': 'Worktree entfernt', + 'agentManager.detail.toast.otherWorktreesRemoved': 'Andere Worktrees entfernt', + 'agentManager.empty.groupName.label': 'Gruppenname', + 'agentManager.empty.groupName.placeholder': 'z.B. feature-auth, bugfix-login', + 'agentManager.empty.groupName.description': 'Wird für Verzeichnis- und Branch-Namen verwendet', + 'agentManager.empty.baseBranch.label': 'Basis-Branch', + 'agentManager.empty.baseBranch.description': 'Erstellt neue Branches aus {branch}', + 'agentManager.empty.setupCommands.label': 'Setup-Befehle', + 'agentManager.empty.setupCommands.configured': '{count} konfiguriert', + 'agentManager.empty.setupCommands.description': 'Befehle, die in jedem neuen Worktree ausgeführt werden. Verwende $ROOT_PROJECT_PATH für das Projekt-Root.', + 'agentManager.empty.setupCommands.loading': 'Laden...', + 'agentManager.empty.setupCommands.commandPlaceholder': 'z.B., bun install', + 'agentManager.empty.setupCommands.removeCommandAria': 'Befehl entfernen', + 'agentManager.empty.setupCommands.addCommand': 'Befehl hinzufügen', + 'agentManager.empty.agent.label': 'Agent', + 'agentManager.empty.agent.description': 'Verwendet standardmäßig Ihren konfigurierten Standard-Agent', + 'agentManager.empty.models.label': 'Modelle', + 'agentManager.empty.models.addModel': 'Modell hinzufügen', + 'agentManager.empty.models.selectedSingle': '{count} Modell ausgewählt', + 'agentManager.empty.models.selectedPlural': '{count} Modelle ausgewählt', + 'agentManager.empty.prompt.label': 'Prompt', + 'agentManager.empty.prompt.placeholder': 'Frage alles...', + 'agentManager.empty.prompt.addAttachmentAria': 'Anhang hinzufügen', + 'agentManager.empty.actions.startAgentGroupAria': 'Agent-Gruppe starten', + 'agentManager.empty.toast.fileTooLarge': 'Datei "{fileName}" ist zu groß (max. 10MB)', + 'agentManager.empty.toast.failedToAttach': 'Fehler beim Anhängen von "{fileName}"', + 'agentManager.empty.toast.attachedSingle': '{count} Datei angehängt', + 'agentManager.empty.toast.attachedPlural': '{count} Dateien angehängt', + 'agentManager.empty.toast.failedToCreateGroup': 'Fehler beim Erstellen der Agent-Gruppe', + 'openInApp.actions.open': 'Öffnen', + 'openInApp.actions.openInAria': 'In {app} öffnen', + 'openInApp.actions.chooseAppAria': 'App zum Öffnen auswählen', + 'openInApp.actions.copyPath': 'Pfad kopieren', + 'openInApp.actions.refreshApps': 'Apps aktualisieren', + 'openInApp.toast.pathCopied': 'Pfad in die Zwischenablage kopiert', + 'projectActions.actions.addActionAria': 'Aktion hinzufügen', + 'projectActions.actions.addAction': 'Aktion hinzufügen', + 'projectActions.actions.addNewAction': 'Neue Aktion hinzufügen', + 'projectActions.actions.autoDiscover': 'Automatisch entdecken', + 'projectActions.actions.chooseActionAria': 'Projektaktion auswählen', + 'projectActions.actions.openPreview': 'Vorschau öffnen', + 'projectActions.actions.runNamedAria': '{name} ausführen', + 'projectActions.actions.stopNamedAria': '{name} stoppen', + 'projectActions.label.fallbackAction': 'Aktion', + 'projectActions.toast.openedUrlFromOutput': 'URL aus der Aktionsausgabe geöffnet', + 'projectActions.toast.openedForwardedUrl': 'Weitergeleitete URL geöffnet', + 'projectActions.toast.openedActionUrl': 'Aktions-URL geöffnet', + 'projectActions.error.noActiveDirectory': 'Kein aktives Verzeichnis', + 'projectActions.error.noActiveDirectoryForAction': 'Kein aktives Verzeichnis für Aktion', + 'projectActions.error.failedToCreateTerminalSession': 'Erstellung der Terminal-Sitzung fehlgeschlagen', + 'projectActions.error.invalidCustomUrlFormat': 'Ungültiges benutzerdefiniertes URL-Format', + 'projectActions.error.selectedDesktopSshForwardUnavailable': 'Ausgewählte Desktop-SSH-Weiterleitung ist nicht verfügbar', + 'projectActions.error.failedToRunAction': 'Aktion konnte nicht ausgeführt werden', + 'mcpDropdown.title': 'MCP-Server', + 'mcpDropdown.actions.refreshAria': 'Aktualisieren', + 'mcpDropdown.actions.openAria': 'MCP-Server', + 'mcpDropdown.statusAria': 'MCP-Status', + 'mcpDropdown.status.unknown': 'Unbekannt', + 'mcpDropdown.status.connected': 'Verbunden', + 'mcpDropdown.status.failed': 'Fehlgeschlagen: {error}', + 'mcpDropdown.status.unknownError': 'Unbekannter Fehler', + 'mcpDropdown.status.needsAuth': 'Authentifizierung erforderlich', + 'mcpDropdown.status.needsRegistration': 'Registrierung erforderlich: {error}', + 'mcpDropdown.empty.configureInConfig': 'Konfigurieren Sie MCP-Server in den Einstellungen.', + 'sessionAuth.error.rateLimitTitle': 'Zu viele Versuche', + 'sessionAuth.error.networkTitle': 'Server nicht erreichbar', + 'sessionAuth.error.rateLimitDescriptionSingle': 'Bitte warten Sie {minutes} Minute, bevor Sie es erneut versuchen.', + 'sessionAuth.error.rateLimitDescriptionPlural': 'Bitte warten Sie {minutes} Minuten, bevor Sie es erneut versuchen.', + 'sessionAuth.error.networkDescription': 'Wir konnten die UI-Sitzung nicht verifizieren. Wenn Sie OpenChamber von einem anderen Gerät in Ihrem lokalen Netzwerk öffnen, stellen Sie sicher, dass Desktop-Netzwerkzugriff in der Desktop-Anwendung aktiviert ist und verwenden Sie die im Einstellungen angezeigte LAN-Adresse.', + 'sessionAuth.error.retry': 'Erneut versuchen', + 'sessionAuth.error.passkeySetupFailed': 'Einrichtung des Passkeys fehlgeschlagen.', + 'sessionAuth.error.incorrectPassword': 'Falsches Passwort. Bitte erneut versuchen.', + 'sessionAuth.error.unexpectedResponse': 'Unerwartete Antwort vom Server.', + 'sessionAuth.error.networkRetry': 'Netzwerkfehler. Verbindung prüfen und erneut versuchen.', + 'sessionAuth.error.passkeySignInCanceled': 'Passkey-Anmeldung wurde abgebrochen.', + 'sessionAuth.error.enterPasswordForPasskey': 'Geben Sie Ihr Passwort ein, um einen Passkey hinzuzufügen.', + 'sessionAuth.locked.tunnelTitle': 'Tunnel-Zugriff erforderlich', + 'sessionAuth.locked.unlockTitle': 'OpenChamber entsperren', + 'sessionAuth.locked.tunnelDescription': 'Öffnen Sie diesen Tunnel über den Einmal-Verbindungslink aus der Desktop-Anwendung.', + 'sessionAuth.locked.passwordDescription': 'Diese Sitzung ist passwortgeschützt.', + 'sessionAuth.locked.hostSwitcherHint': 'Verwenden Sie Local, wenn Remote nicht erreichbar ist.', + 'sessionAuth.actions.cancelPasskey': 'Passkey abbrechen', + 'sessionAuth.actions.usePasskey': 'Passkey verwenden', + 'sessionAuth.actions.unlockingAria': 'Entsperren', + 'sessionAuth.actions.unlockAria': 'Entsperren', + 'sessionAuth.actions.trustDevice': 'Dieses Gerät vertrauen', + 'sessionAuth.actions.trustDeviceAria': 'Dieses Gerät vertrauen', + 'sessionAuth.actions.cancelPasskeySetup': 'Passkey-Einrichtung abbrechen', + 'sessionAuth.actions.addPasskey': 'Passkey hinzufügen', + 'sessionAuth.password.placeholder': 'Passwort eingeben', + 'sessionAuth.toast.passkeyAdded': 'Passkey hinzugefügt', + 'sessionAuth.toast.passkeySetupCanceled': 'Passkey-Einrichtung abgebrochen', + 'desktopHostSwitcher.title': 'Instanz', + 'desktopHostSwitcher.description': 'Zwischen lokalen und Remote-OpenChamber-Servern wechseln', + 'desktopHostSwitcher.header.current': 'Aktuell', + 'desktopHostSwitcher.header.default': 'Standard', + 'desktopHostSwitcher.header.currentColon': 'Aktuell:', + 'desktopHostSwitcher.header.currentDefaultColon': 'Aktueller Standard:', + 'desktopHostSwitcher.status.connected': 'Verbunden', + 'desktopHostSwitcher.status.authRequired': 'Authentifizierung erforderlich', + 'desktopHostSwitcher.status.checking': 'Überprüfung', + 'desktopHostSwitcher.status.updateRecommended': 'Aktualisierung empfohlen', + 'desktopHostSwitcher.status.incompatible': 'Nicht kompatibel', + 'desktopHostSwitcher.status.wrongService': 'Falscher Dienst', + 'desktopHostSwitcher.status.unreachable': 'Nicht erreichbar', + 'desktopHostSwitcher.status.unknown': 'Unbekannt', + 'desktopHostSwitcher.status.ping': ' · {ms}ms Ping', + 'desktopHostSwitcher.statusAria': 'Instanzstatus', + 'desktopHostSwitcher.sshPhase.ready': 'Bereit', + 'desktopHostSwitcher.sshPhase.error': 'Fehler', + 'desktopHostSwitcher.sshPhase.reconnecting': 'Verbindung wird wiederhergestellt', + 'desktopHostSwitcher.sshPhase.resolvingConfig': 'Konfiguration wird aufgelöst', + 'desktopHostSwitcher.sshPhase.checkingAuth': 'Authentifizierung wird überprüft', + 'desktopHostSwitcher.sshPhase.connectingSsh': 'SSH-Verbindung wird hergestellt', + 'desktopHostSwitcher.sshPhase.probingRemote': 'Entfernte Systeme werden untersucht', + 'desktopHostSwitcher.sshPhase.installing': 'Wird installiert', + 'desktopHostSwitcher.sshPhase.updating': 'Wird aktualisiert', + 'desktopHostSwitcher.sshPhase.detectingServer': 'Server wird erkannt', + 'desktopHostSwitcher.sshPhase.startingServer': 'Server wird gestartet', + 'desktopHostSwitcher.sshPhase.forwardingPorts': 'Ports werden weitergeleitet', + 'desktopHostSwitcher.sshPhase.idle': 'Leerlauf', + 'desktopHostSwitcher.ssh.needInstancesHint': 'Benötigen Sie SSH-Instanzen? Verwalten Sie diese in den Einstellungen.', + 'desktopHostSwitcher.ssh.instanceFallback': 'SSH-Instanz', + 'desktopHostSwitcher.ssh.connectingTo': 'Verbindung zu {host} wird hergestellt', + 'desktopHostSwitcher.actions.refresh': 'Aktualisieren', + 'desktopHostSwitcher.actions.refreshInstancesAria': 'Instanzen aktualisieren', + 'desktopHostSwitcher.actions.remoteSsh': 'Entferntes SSH', + 'desktopHostSwitcher.actions.instanceActionsAria': 'Instanzaktionen', + 'desktopHostSwitcher.actions.edit': 'Bearbeiten', + 'desktopHostSwitcher.actions.delete': 'Löschen', + 'desktopHostSwitcher.actions.connect': 'Verbinden', + 'desktopHostSwitcher.actions.defaultInstanceAria': 'Standardinstanz', + 'desktopHostSwitcher.actions.setAsDefaultAria': 'Als Standard festlegen', + 'desktopHostSwitcher.actions.setAsDefault': 'Als Standard festlegen', + 'desktopHostSwitcher.actions.openInNewWindowAria': 'In neuem Fenster öffnen', + 'desktopHostSwitcher.actions.openInNewWindow': 'In neuem Fenster öffnen', + 'desktopHostSwitcher.actions.addInstance': 'Instanz hinzufügen', + 'desktopHostSwitcher.actions.cancel': 'Abbrechen', + 'desktopHostSwitcher.actions.save': 'Speichern', + 'desktopHostSwitcher.actions.add': 'Hinzufügen', + 'desktopHostSwitcher.actions.switchToLocal': 'Zu Lokal wechseln', + 'desktopHostSwitcher.actions.retry': 'Wiederholen', + 'desktopHostSwitcher.actions.switchInstanceAria': 'Instanz wechseln', + 'desktopHostSwitcher.actions.switchInstance': 'Instanz wechseln', + 'desktopHostSwitcher.actions.switchToAria': 'Zu {instance} wechseln', + 'desktopHostSwitcher.field.labelPlaceholder': 'Bezeichnung', + 'desktopHostSwitcher.field.labelOptionalPlaceholder': 'Bezeichnung (optional)', + 'desktopHostSwitcher.field.urlPlaceholder': 'https://host:port', + 'desktopHostSwitcher.state.limitedOnPage': 'Instanzwechsler ist auf dieser Seite begrenzt. Verwenden Sie Lokal, um wiederherzustellen.', + 'desktopHostSwitcher.state.loading': 'Wird geladen...', + 'desktopHostSwitcher.state.instanceUnreachable': 'Instanz nicht erreichbar', + 'desktopHostSwitcher.edit.title': 'Instanz bearbeiten', + 'desktopHostSwitcher.add.title': 'Instanz hinzufügen', + 'desktopHostSwitcher.error.failedToSave': 'Speichern fehlgeschlagen', + 'desktopHostSwitcher.error.failedToLoad': 'Laden fehlgeschlagen', + 'desktopHostSwitcher.error.invalidUrl': 'Ungültige URL (muss http/https sein)', + 'desktopHostSwitcher.error.failedToOpenNewWindow': 'Öffnen eines neuen Fensters fehlgeschlagen', + 'desktopHostSwitcher.instance.local': 'Lokal', + 'desktopHostSwitcher.instance.fallback': 'Instanz', + 'desktopHostSwitcher.startup.title': 'Standard-SSH-Instanz nicht verfügbar', + 'desktopHostSwitcher.startup.connectingTo': 'Verbindung zu {host} herstellen...', + 'desktopHostSwitcher.startup.failed': 'Verbindung zur Standard-SSH-Instanz fehlgeschlagen.', + 'desktopHostSwitcher.toast.sshConnected': 'SSH-Instanz "{host}" verbunden', + 'desktopHostSwitcher.toast.sshFailedToConnect': 'SSH-Instanz "{host}" konnte keine Verbindung herstellen', + 'desktopHostSwitcher.toast.instanceUnreachable': 'Instanz "{host}" ist nicht erreichbar', + 'miniChat.header.newSession': 'Neue Sitzung', + 'miniChat.header.session': 'Sitzung', + 'miniChat.header.defaultAgent': 'Standard-Agent', + 'miniChat.header.noModel': 'Kein Modell', + 'miniChat.status.busy': 'Läuft', + 'miniChat.status.retry': 'Wiederholen', + 'miniChat.status.idle': 'Leerlauf', + 'miniChat.actions.pin': 'Oben über anderen Fenstern anheften', + 'miniChat.actions.unpin': 'Fenster lösen', + 'miniChat.actions.pinAria': 'Mini Chat-Fenster anheften', + 'miniChat.actions.unpinAria': 'Mini Chat-Fenster lösen', + 'miniChat.actions.openMain': 'In Hauptfenster öffnen', + 'miniChat.actions.openMainAria': 'Sitzung im Hauptfenster öffnen', + 'miniChat.unavailable.title': 'Sitzung nicht verfügbar', + 'miniChat.unavailable.description': 'Diese Sitzung konnte nicht geladen werden. Sie wurde möglicherweise gelöscht, archiviert oder aus einem anderen Projekt-Kontext geöffnet.', + 'sessions.sidebar.session.menu.openMiniChatWindow': 'In Mini Chat-Fenster öffnen', + 'header.actions.newMiniChat': 'Neues Mini Chat-Fenster', + 'header.actions.newMiniChatAria': 'Ein neues Mini Chat-Fenster öffnen', + 'header.actions.openSessionMiniChat': 'Sitzung in Mini Chat öffnen', + 'header.actions.openSessionMiniChatAria': 'Aktuelle Sitzung in Mini Chat öffnen', + 'header.windowControls.groupAria': 'Fenstersteuerung', + 'header.windowControls.minimize': 'Fenster minimieren', + 'header.windowControls.maximize': 'Fenster maximieren', + 'header.windowControls.restore': 'Fenster wiederherstellen', + 'header.windowControls.close': 'Fenster schließen', + 'errorBoundary.title': 'Etwas ist schiefgelaufen', + 'errorBoundary.description': 'Die Anwendung hat einen unerwarteten Fehler festgestellt. Dies wurde zur Fehlersuche protokolliert.', + 'errorBoundary.state.unknownError': 'Unbekannter Fehler', + 'errorBoundary.state.componentStackLabel': 'Komponentenstapel:', + 'errorBoundary.actions.errorDetails': 'Fehlerdetails', + 'errorBoundary.actions.tryAgain': 'Erneut versuchen', + 'errorBoundary.actions.copy': 'Kopieren', + 'errorBoundary.actions.copied': 'Kopiert', + 'jsonTreeView.error.emptyJson': 'Leerer JSON-Inhalt', + 'jsonTreeView.error.invalidJson': 'Ungültiges JSON', + 'jsonTreeView.error.invalidJsonTitle': 'Ungültiges JSON', + 'jsonTreeView.actions.expandAll': 'Alle erweitern', + 'jsonTreeView.actions.collapseAll': 'Alle reduzieren', + 'numberInput.actions.decreaseAria': 'Wert verringern', + 'numberInput.actions.increaseAria': 'Wert erhöhen', + 'goToLineDialog.field.linePlaceholderShort': 'Zeile', + 'goToLineDialog.field.linePlaceholder': 'Zeilennummer', + 'goToLineDialog.actions.go': 'Gehe zu', + 'goToLineDialog.helper.editorUnavailable': 'Editor nicht verfügbar.', + 'goToLineDialog.helper.currentLineRange': 'Aktuelle Zeile: {current}. Geben Sie eine Zeilennummer zwischen 1 und {max} ein, um zur gewünschten Zeile zu navigieren.', + 'goToLineDialog.helper.goToLine': 'Gehe zu Zeile {line}', + 'dialog.common.actions.close': 'Schließen', + 'filesView.toast.relativePathCopied': 'Relativer Pfad kopiert', + 'filesView.tree.menu.copyRelativePath': 'Relativen Pfad kopieren', + 'multiRun.branchSelector.placeholder.selectSourceBranch': 'Quell-Branch auswählen...', + 'multiRun.branchSelector.status.loadingBranches': 'Lade Branches...', + 'multiRun.branchSelector.status.noBranchesFound': 'Keine Branches gefunden', + 'multiRun.branchSelector.status.notInGitRepository': 'Nicht in einem Git-Repository.', + 'multiRun.branchSelector.groups.localBranches': 'Lokale Branches', + 'multiRun.branchSelector.groups.remoteBranches': 'Remote-Branches', + 'multiRun.window.actions.closeAria': 'Multi-Run schließen', + 'multiRun.window.description': 'OpenChamber Multi-Run Fenster.', + 'voice.status.idle': 'Sprache bereit', + 'voice.status.listening': 'Höre...', + 'voice.status.processing': 'Verarbeite...', + 'voice.status.speaking': 'Spricht...', + 'voice.status.error': 'Sprachfehler', + 'voice.status.conversationModeActiveAria': 'Konversationsmodus aktiv', + 'voice.action.finishAndTranscribe': 'Spracheingabe beenden und transkribieren', + 'onboarding.common.actions.back': 'Zurück', + 'onboarding.common.copyToClipboard': 'In Zwischenablage kopieren', + 'onboarding.common.status.copiedToClipboard': 'In Zwischenablage kopiert', + 'onboarding.chooser.title': 'Willkommen bei OpenChamber', + 'onboarding.chooser.description': 'Wählen Sie, wie Sie sich verbinden möchten, um loszulegen.', + 'onboarding.chooser.tabs.localInstall': 'Lokale Installation', + 'onboarding.chooser.tabs.connectRemote': 'Entfernte Verbindung', + 'onboarding.localSetup.title': 'Einrichtung von OpenCode', + 'onboarding.localSetup.description': 'Installieren Sie die OpenCode CLI, um fortzufahren.', + 'onboarding.localSetup.dialog.selectOpencodeBinary': 'opencode-Binärdatei auswählen', + 'onboarding.localSetup.errors.cliNotReady': 'OpenCode CLI ist noch nicht bereit. Bitte bestätigen Sie, dass die Installation abgeschlossen ist, und versuchen Sie es erneut.', + 'onboarding.localSetup.errors.detectionFailed': 'Erkennung fehlgeschlagen', + 'onboarding.localSetup.windows.title': 'Windows-Einrichtung', + 'onboarding.localSetup.windows.stepInstallWsl': 'Installieren Sie OpenCode mit dem folgenden Befehl.', + 'onboarding.localSetup.windows.stepRunInstallInWsl': 'Führen Sie den Installationsbefehl unten in einer Windows-Konsole aus.', + 'onboarding.localSetup.windows.stepSetBinaryPath': 'Wenn OpenChamber OpenCode nicht automatisch erkennt, legen Sie den Binärpfad unten fest.', + 'onboarding.localSetup.intro': 'OpenCode ist das Herz von OpenChamber – installieren Sie es, um loszulegen.', + 'onboarding.localSetup.docs.windows': 'OpenCode-Dokumentation', + 'onboarding.localSetup.docs.default': 'OpenCode-Dokumentation', + 'onboarding.localSetup.actions.checking': 'Überprüfe…', + 'onboarding.localSetup.actions.checkNow': 'Jetzt überprüfen', + 'onboarding.localSetup.helper.checkAndContinue': 'Klicken Sie, um zu prüfen, ob OpenCode CLI verfügbar ist. Bei Erfolg gelangen Sie automatisch zum Hauptbildschirm.', + 'onboarding.localSetup.status.watching': 'Warte auf OpenCode', + 'onboarding.localSetup.field.alreadyInstalled': 'Bereits installiert? Legen Sie den OpenCode CLI-Pfad fest:', + 'onboarding.localSetup.advanced.title': 'Benutzerdefinierten Binärpfad festlegen', + 'onboarding.localSetup.troubleshoot.title': 'Probleme beim Verbinden?', + 'onboarding.localSetup.actions.browse': 'Durchsuchen', + 'onboarding.localSetup.actions.apply': 'Anwenden', + 'onboarding.localSetup.helper.saveAndReload': 'Speichert in die OpenChamber-Einstellungen und lädt die OpenCode-Konfiguration neu.', + 'onboarding.localSetup.remotePreference': 'Bevorzugen Sie die Verwendung eines Remote-Servers?', + 'onboarding.localSetup.actions.connectRemoteServer': 'Mit Remote-Server verbinden →', + 'onboarding.localSetup.windows.hintInstallInWsl': 'Unter Windows installieren und führen Sie OpenCode nativ aus.', + 'onboarding.localSetup.windows.hintDetectionFailed': 'Falls die Erkennung fehlschlägt, legen Sie einen nativen Pfad wie opencode.cmd oder opencode.exe fest.', + 'onboarding.localSetup.hint.ensurePath': 'Bereits installiert? Stellen Sie sicher, dass opencode in Ihrem PATH enthalten ist.', + 'onboarding.localSetup.hint.setEnv': 'Oder legen Sie die Umgebungsvariable OPENCODE_BINARY fest.', + 'onboarding.localSetup.hint.missingRuntime': 'Wenn Sie „env: node: No such file or directory“ oder „env: bun: No such file or directory“ sehen, installieren Sie diese Laufzeitumgebung oder stellen Sie sicher, dass sie im PATH enthalten ist.', + 'onboarding.remoteConnection.title': 'Mit Remote-Server verbinden', + 'onboarding.remoteConnection.titleRecovery': 'Mit einem anderen Server verbinden', + 'onboarding.remoteConnection.description': 'Geben Sie die Adresse eines OpenChamber-Servers ein, mit dem Sie sich verbinden möchten.', + 'onboarding.remoteConnection.field.serverAddress': 'Server-Adresse', + 'onboarding.remoteConnection.field.serverAddressPlaceholder': 'https://your-server.example.com:4096', + 'onboarding.remoteConnection.field.nameOptional': 'Name (optional)', + 'onboarding.remoteConnection.field.namePlaceholder': 'Mein Remote-Server', + 'onboarding.remoteConnection.errors.connectionTestFailed': 'Verbindungstest fehlgeschlagen', + 'onboarding.remoteConnection.errors.failedToSaveConnection': 'Speichern der Verbindung fehlgeschlagen', + 'onboarding.remoteConnection.status.connectedSuccessfully': 'Erfolgreich verbunden ({latencyMs}ms)', + 'onboarding.remoteConnection.status.authWarning': 'Server erfordert Authentifizierung. Sie können sich immer noch verbinden.', + 'onboarding.remoteConnection.status.connectionFailed': 'Verbindung fehlgeschlagen', + 'onboarding.remoteConnection.status.suggestionsUnreachable': 'Vorschläge: Überprüfen Sie die Serveradresse, stellen Sie sicher, dass der Server läuft, oder prüfen Sie Ihre Netzwerkverbindung.', + 'onboarding.remoteConnection.status.suggestionsWrongService': 'Vorschläge: Stellen Sie sicher, dass die URL auf einen OpenChamber-Server zeigt, oder kontaktieren Sie den Server-Administrator.', + 'onboarding.remoteConnection.actions.testing': 'Teste…', + 'onboarding.remoteConnection.actions.testConnection': 'Verbindung testen', + 'onboarding.remoteConnection.actions.connectAndRestart': 'Verbinden & Neustarten', + 'onboarding.remoteConnection.actions.whatToDo': 'Was möchten Sie tun?', + 'onboarding.remoteConnection.actions.chooseDifferentServer': 'Anderen Server auswählen', + 'onboarding.remoteConnection.actions.useLocalInstead': 'Lokal stattdessen verwenden', + 'onboarding.remoteConnection.probe.authMessage': 'Der Server erfordert Authentifizierung. Sie können sich immer noch verbinden, aber müssen möglicherweise Anmeldedaten bereitstellen.', + 'onboarding.remoteConnection.probe.updateRecommendedMessage': 'Diese Instanz verwendet eine andere OpenChamber-Version. Sie können sich verbinden, aber aktualisieren Sie beide Apps, falls etwas nicht funktioniert.', + 'onboarding.remoteConnection.probe.incompatibleMessage': 'Der Server läuft unter OpenChamber, ist aber nicht kompatibel mit dieser App-Version. Aktualisieren Sie OpenChamber auf dem Server und versuchen Sie es dann erneut.', + 'onboarding.remoteConnection.probe.wrongServiceMessage': 'Der Server hat geantwortet, aber es läuft kein OpenChamber. Überprüfen Sie, ob die Adresse auf einen OpenChamber-Server zeigt.', + 'onboarding.remoteConnection.probe.unreachableMessage': 'Der Server ist nicht erreichbar. Überprüfen Sie Ihre Netzwerkverbindung und die Serveradresse.', + 'onboarding.desktopRecovery.localUnavailable.title': 'Lokales OpenCode nicht verfügbar', + 'onboarding.desktopRecovery.localUnavailable.description': 'OpenCode CLI konnte nicht gestartet werden oder ist nicht installiert. Installieren Sie OpenCode oder verbinden Sie sich stattdessen mit einem Remote-Server.', + 'onboarding.desktopRecovery.localUnavailable.retry': 'Lokal erneut versuchen', + 'onboarding.desktopRecovery.localUnavailable.useLocal': 'Lokale Einrichtung', + 'onboarding.desktopRecovery.noDefaultConnection.title': 'Keine Standardverbindung', + 'onboarding.desktopRecovery.noDefaultConnection.description': 'Ihre gespeicherte Standardverbindung konnte nicht gefunden werden. Wählen Sie aus, wie Sie sich verbinden möchten.', + 'onboarding.desktopRecovery.remoteUnreachable.title': 'Remote-Server nicht erreichbar', + 'onboarding.desktopRecovery.remoteUnreachable.description': 'Konnte keine Verbindung zu "{host}" herstellen. Überprüfen Sie Ihre Netzwerkverbindung und die Serveradresse.', + 'onboarding.desktopRecovery.remoteUnreachable.retry': 'Verbindung erneut versuchen', + 'onboarding.desktopRecovery.incompatibleServer.title': 'Inkompatibler Server', + 'onboarding.desktopRecovery.incompatibleServer.description': 'Der Server unter "{host}" läuft nicht unter OpenChamber. Überprüfen Sie, ob die Adresse auf einen OpenChamber-Server zeigt.', + 'onboarding.desktopRecovery.remoteIncompatible.title': 'Server-Update erforderlich', + 'onboarding.desktopRecovery.remoteIncompatible.description': 'Der OpenChamber-Server unter "{host}" ist nicht kompatibel mit dieser App-Version. Aktualisieren Sie OpenChamber auf dem Server und versuchen Sie es dann erneut.', + 'onboarding.desktopRecovery.common.useLocal': 'Lokal verwenden', + 'onboarding.desktopRecovery.common.useRemote': 'Remote verwenden', + 'onboarding.desktopRecovery.actions.retrying': 'Erneuter Versuch…', + 'onboarding.desktopRecovery.actions.retryConnection': 'Verbindung erneut versuchen', + 'startup.initRecovery.title': 'Start fehlgeschlagen', + 'startup.initRecovery.description': 'OpenChamber konnte die Initialisierung nicht abschließen. Überprüfen Sie, ob der Server läuft, und versuchen Sie es dann erneut.', + 'startup.initRecovery.retry': 'Erneut versuchen', + 'startup.initRecovery.retrying': 'Erneuter Versuch…', + 'onboarding.desktopRecovery.placeholders.remoteServer': 'der Remote-Server', + 'onboarding.desktopRecovery.placeholders.unknownServer': 'unbekannt', + 'vscodeLayout.title.chat': 'Chat', + 'vscodeLayout.title.newSession': 'Neue Sitzung', + 'vscodeLayout.title.sessions': 'Sitzungen', + 'vscodeLayout.title.sessionFallback': 'Sitzung', + 'vscodeLayout.actions.backToSessionsAria': 'Zurück zu den Sitzungen', + 'vscodeLayout.actions.archiveAllAria': 'Alle Sitzungen archivieren', + 'vscodeLayout.actions.archiveAllConfirm': 'Alle archivieren', + 'vscodeLayout.actions.archiveAllSuccess': '{count} Sitzung(en) archiviert', + 'vscodeLayout.actions.archiveAllError': 'Fehler beim Archivieren von {count} Sitzung(en)', + 'vscodeLayout.actions.cancel': 'Abbrechen', + 'vscodeLayout.actions.newSessionAria': 'Neue Sitzung', + 'vscodeLayout.actions.openAgentManagerAria': 'Agenten-Manager öffnen', + 'vscodeLayout.actions.resizeSessionsSidebarAria': 'Größe der Sitzungs-Seitenleiste anpassen', + 'vscodeLayout.actions.settingsAria': 'Einstellungen', + 'vscodeLayout.quota.title': 'Ratenlimits', + 'vscodeLayout.quota.mode.used': 'Verwendet', + 'vscodeLayout.quota.mode.remaining': 'Verbleibend', + 'vscodeLayout.quota.lastUpdated': 'Zuletzt aktualisiert {time}', + 'vscodeLayout.quota.noRateLimitsAvailable': 'Keine Ratenlimits verfügbar.', + 'vscodeLayout.quota.noRateLimitsReported': 'Keine Ratenlimits gemeldet.', + 'vscodeLayout.quota.actions.rateLimitsAria': 'Ratenlimits', + 'vscodeLayout.quota.actions.showUsedAria': 'Verwendete Quote anzeigen', + 'vscodeLayout.quota.actions.showRemainingAria': 'Verbleibende Quote anzeigen', + 'vscodeLayout.quota.actions.refreshAria': 'Ratenlimits aktualisieren', + 'updateDialog.header.updateAvailable': 'Update verfügbar', + 'updateDialog.header.updating': 'OpenChamber wird aktualisiert...', + 'updateDialog.status.installingUpdate': 'Update wird installiert...', + 'updateDialog.status.serverRestarting': 'Server wird neu gestartet...', + 'updateDialog.status.waitingForServer': 'Warten auf Server...', + 'updateDialog.status.autoReloadHint': 'Die Seite wird automatisch neu geladen, wenn das Update abgeschlossen ist.', + 'updateDialog.fallback.updateViaTerminal': 'Oder aktualisieren über Terminal:', + 'updateDialog.actions.copyCommand': 'Befehl kopieren', + 'updateDialog.actions.copied': 'Kopiert!', + 'updateDialog.status.downloadingPayload': 'Update-Payload wird heruntergeladen...', + 'updateDialog.actions.downloadUpdate': 'Update herunterladen', + 'updateDialog.status.downloading': 'Herunterladen...', + 'updateDialog.actions.restartToUpdate': 'Neu starten zum Aktualisieren', + 'updateDialog.actions.updateNow': 'Jetzt aktualisieren', + 'updateDialog.actions.openMobileUpdate': 'Update öffnen', + 'updateDialog.status.updating': 'Aktualisierung läuft...', + 'updateDialog.error.updateFailed': 'Aktualisierung fehlgeschlagen', + 'updateDialog.error.takingLonger': 'Die Aktualisierung dauert länger als erwartet. Warten Sie einen Moment und aktualisieren Sie die Seite oder führen Sie folgenden Befehl aus: openchamber update', + 'mobileUpdate.toast.available.title': 'OpenChamber-Update verfügbar', + 'mobileUpdate.toast.available.description': 'Version {version} ist für Android bereit.', + 'mobileUpdate.toast.actions.download': 'Herunterladen', + 'mobileUpdate.toast.actions.dismiss': 'Ablehnen', + 'opencodeUpdate.toast.available.title': 'OpenCode-Update', + 'opencodeUpdate.toast.available.description': 'Version {version} verfügbar.', + 'opencodeUpdate.toast.actions.update': 'Aktualisieren', + 'opencodeUpdate.toast.actions.dismiss': 'Ablehnen', + 'opencodeUpdate.toast.actions.reload': 'OpenCode neu laden', + 'opencodeUpdate.toast.upgrading.title': 'OpenCode wird aktualisiert...', + 'opencodeUpdate.toast.upgrading.description': 'Behalten Sie OpenChamber geöffnet.', + 'opencodeUpdate.toast.updated.title': 'OpenCode aktualisiert', + 'opencodeUpdate.toast.updated.description': 'Update installiert.', + 'opencodeUpdate.toast.updated.descriptionWithVersion': 'Version {version} installiert.', + 'opencodeUpdate.toast.failed.title': 'Konnte OpenCode nicht aktualisieren', + 'opencodeUpdate.toast.failed.description': 'Das OpenCode-Upgrade ist fehlgeschlagen.', + 'opencodeUpdate.toast.reload.message': 'OpenCode wird neu gestartet...', + 'memoryDebugPanel.title': 'Debug Panel', + 'memoryDebugPanel.tabs.memory': 'Speicher', + 'memoryDebugPanel.tabs.streaming': 'Streaming', + 'memoryDebugPanel.section.sessionsInMemory': 'Sitzungen im Speicher', + 'memoryDebugPanel.section.uiStreamingMetrics': 'UI-Streaming-Metriken', + 'memoryDebugPanel.section.vscodeBridgeMetrics': 'VS Code Bridge Metriken', + 'memoryDebugPanel.section.noUiSamples': 'Noch keine UI-Streaming-Proben. Starten Sie einen Stream und lassen Sie dieses Panel geöffnet.', + 'memoryDebugPanel.section.noVscodeSamples': 'Noch keine VS Code Bridge Proben.', + 'memoryDebugPanel.metric.totalMessages': 'Gesamte Nachrichten', + 'memoryDebugPanel.metric.cachedSessions': 'Zwischengespeicherte Sitzungen', + 'memoryDebugPanel.metric.userMessages': 'Benutzernachrichten', + 'memoryDebugPanel.metric.assistantMessages': 'Assistentennachrichten', + 'memoryDebugPanel.metric.viewportWindow': 'Viewport-Fenster', + 'memoryDebugPanel.metric.zombieTimeout': 'Zombie-Timeout', + 'memoryDebugPanel.metric.githubTotalRequests': 'GitHub Gesamtanfragen', + 'memoryDebugPanel.metric.metrics': 'Metriken', + 'memoryDebugPanel.metric.samples': 'Proben', + 'memoryDebugPanel.metric.lastUpdate': 'Letzte Aktualisierung', + 'memoryDebugPanel.metric.uiMetrics': 'UI-Metriken', + 'memoryDebugPanel.metric.vscodeMetrics': 'VS Code Metriken', + 'memoryDebugPanel.metric.messageListRenders': 'MsgList Rendern', + 'memoryDebugPanel.metric.messageListStreamRenders': 'MsgList Stream Rendern', + 'memoryDebugPanel.metric.chatMessageRenders': 'ChatMessage Rendern', + 'memoryDebugPanel.metric.chatMessageStreamRenders': 'ChatMessage Stream Rendern', + 'memoryDebugPanel.metric.chatMessageStaticDuringStream': 'ChatMessage statisch während Stream', + 'memoryDebugPanel.metric.chatMessageStaticOutsideActiveTurn': 'ChatMessage statisch außerhalb aktiver Runde', + 'memoryDebugPanel.metric.messagesValue': '{count} Nachrichten', + 'memoryDebugPanel.metric.minutesValue': '{count} Minuten', + 'memoryDebugPanel.metric.msgsValue': '{count} msgs', + 'memoryDebugPanel.metric.roleMsgsValue': 'U {user} / A {assistant}', + 'memoryDebugPanel.metric.countValue': 'count {value}', + 'memoryDebugPanel.metric.avgValue': 'avg {value}', + 'memoryDebugPanel.metric.maxValue': 'max {value}', + 'memoryDebugPanel.metric.totalValue': 'total {value}', + 'memoryDebugPanel.actions.logState': 'Status protokollieren', + 'memoryDebugPanel.actions.copyJson': 'JSON kopieren', + 'memoryDebugPanel.tooltip.logCurrentState': 'Aktuellen Speicherstatus in die Browser-Konsole protokollieren', + 'memoryDebugPanel.streaming.copy.copied': 'Streaming-Debug-JSON kopiert', + 'memoryDebugPanel.streaming.copy.failed': 'Fehler beim Kopieren der JSON-Datei', + 'memoryDebugPanel.streaming.copy.hint': 'Kopieren exportiert sowohl UI- als auch VS Code-Streaming-Metriken als JSON', + 'memoryDebugPanel.common.idle': 'inaktiv', + 'memoryDebugPanel.common.live': 'live', + 'memoryDebugPanel.common.notAvailable': 'n/a', + 'memoryDebugPanel.common.untitled': 'Unbenannt', + 'directoryTree.field.newDirectoryPlaceholder': 'neues_verzeichnis', + 'textarea.resizeHandleAria': 'Größe des Textbereichs ändern', + 'sidebar.resize.leftPanelAria': 'Größe des linken Panels ändern', + 'sidebar.resize.rightPanelAria': 'Größe des rechten Panels ändern', + 'mainLayout.mobile.closeDrawerAria': 'Drawer schließen', + 'sortableTabsStrip.aria.tabs': 'Tabs', + 'openChamberLogo.aria.logo': 'OpenChamber-Logo', + 'markdownRenderer.code.actions.copyTitle': 'Code kopieren', + 'markdownRenderer.code.actions.copiedTitle': 'Kopiert', + 'markdownRenderer.table.actions.copyTitle': 'Tabelle kopieren', + 'markdownRenderer.code.actions.enableWrapTitle': 'Zeilenumbruch aktivieren', + 'markdownRenderer.code.actions.disableWrapTitle': 'Zeilenumbruch deaktivieren', + 'markdownRenderer.table.actions.downloadTitle': 'Tabelle herunterladen', + 'markdownRenderer.table.toast.downloadedAsFormat': 'Tabelle als {format} heruntergeladen', + 'markdownRenderer.mermaid.actions.copyTitle': 'Kopieren', + 'markdownRenderer.mermaid.actions.copySourceTitle': 'Quelle kopieren', + 'markdownRenderer.mermaid.actions.downloadSvgTitle': 'SVG herunterladen', + 'markdownRenderer.mermaid.actions.zoomInTitle': 'Vergrößern', + 'markdownRenderer.mermaid.actions.zoomOutTitle': 'Verkleinern', + 'markdownRenderer.mermaid.actions.resetViewTitle': 'Ansicht zurücksetzen', + 'markdownRenderer.mermaid.toast.downloadFailed': 'Fehler beim Herunterladen des Diagramms', + 'common.date.today': 'Heute', + 'common.date.yesterday': 'Gestern', + 'common.date.yesterdayWithTime': 'Gestern {time}', + 'common.relative.justNow': 'Gerade jetzt', + 'common.relative.minutesAgoShort': '{count}min her', + 'common.relative.hoursAgoShort': '{count}h her', + 'common.relative.daysAgoShort': '{count}d her', + 'common.relative.weeksAgoShort': '{count}w her', + 'common.relative.yearsAgoShort': '{count}y her', + 'common.relative.daysAgoCompact': '{count}d her', + 'common.relative.weeksAgoCompact': '{count}w her', + 'common.relative.yearsAgoCompact': '{count}y her', + 'contextFileOpen.failure.tooLarge': 'Datei ist zu groß zum Öffnen (>{count} Zeilen)', + 'contextFileOpen.failure.missing': 'Datei nicht gefunden', + 'contextFileOpen.failure.unreadable': 'Fehler beim Öffnen der Datei', + 'quota.window.5h': '5-Stunden-Limit', + 'quota.window.7d': '7-Tage-Limit', + 'quota.window.7dSonnet': '7-Tage Sonnet-Limit', + 'quota.window.7dOpus': '7-Tage Opus-Limit', + 'quota.window.weekly': 'Wöchentliches Limit', + 'quota.window.daily': 'Täglich', + 'quota.window.monthly': 'Monatliches Limit', + 'quota.window.credits': 'Credits', + 'quota.window.creditsBalance': 'Kreditguthaben', + 'quota.window.billingCycle': 'Abrechnungszyklus', + 'quota.window.auto': 'Automatisch', + 'quota.window.api': 'API', + 'quota.window.planLimit': 'Planlimit', + 'quota.window.onDemand': 'On-Demand', + 'quota.window.session': 'Sitzung', + 'quota.window.premium': 'Premium-Interaktionen', + 'quota.window.chat': 'Chat-Anfragen', + 'quota.window.completions': 'Vervollständigungen', + 'quota.window.premiumInteractions': 'Premium-Interaktionen', + 'terminalView.actions.attachSelection': 'Ausgewählte Ausgabe anhängen', + 'terminalView.actions.restart': 'Terminal neu starten', + 'chat.message.terminalContext': '{terminal}, Zeilen {start}-{end}', + 'chat.chatInput.terminalContext': '{terminal}, Zeilen {start}-{end}', + 'chat.chatInput.terminalContextRemove': 'Terminal-Kontext entfernen', + 'chat.chatInput.prCommentContext': 'PR-Kommentare', + 'chat.chatInput.prCommentContextRemove': 'PR-Kommentarkontext entfernen', + 'chat.chatInput.prCheckContext': 'Fehlgeschlagene PR-Prüfungen', + 'chat.chatInput.prCheckContextRemove': 'Kontext der PR-Prüfungen entfernen', + 'mobile.connect.notice.unreachable': '{label} konnte nicht erreicht werden. Prüfen Sie, ob der Server läuft.', + 'mobile.connect.notice.authExpired': 'Der Zugriff auf {label} ist abgelaufen oder wurde widerrufen. Melden Sie sich erneut an.', + 'mobile.connect.recovery.description': 'Verbindung mit dem gespeicherten Server nicht möglich. Prüfen Sie, ob er läuft, oder wählen Sie eine andere Instanz.', + 'mobile.header.openWorkspaceAria': 'Arbeitsbereich öffnen', + 'mobile.menu.terminal': 'Terminal', + 'mobile.sessions.renameSessionAria': '{title} umbenennen', + 'mobile.sessions.renameError': 'Sitzung konnte nicht umbenannt werden', + 'mobile.sessions.deleteSessionAria': '{title} löschen', + 'mobile.sessions.confirmDeleteSessionAria': 'Löschen von {title} bestätigen', + 'mobile.files.editAria': 'Datei bearbeiten', + 'mobile.files.doneEditingAria': 'Bearbeitung abschließen', + 'sessions.scheduledTasks.editor.permissionAutoAccept.label': 'Berechtigungen automatisch akzeptieren', + 'sessions.scheduledTasks.editor.permissionAutoAccept.aria': 'Die Sitzung der Aufgabe mit aktivierter automatischer Berechtigungsannahme starten', + 'sessions.archivePage.allDirectories': 'Alle Verzeichnisse', + 'sessions.sidebar.header.displayMode.stickyHeaders': 'Angeheftete Projektüberschriften', + 'sessions.sidebar.header.grouping.label': 'Sitzungen gruppieren', + 'sessions.sidebar.header.grouping.byWorktree': 'Nach Worktree', + 'sessions.sidebar.header.grouping.flat': 'Flache Liste', + 'sessions.sidebar.project.actions.manageWorktrees': 'Worktrees verwalten', + 'sessions.worktreesPage.title': 'Worktrees in {project}', + 'sessions.worktreesPage.description': 'Git-Worktrees für dieses Projekt erstellen, prüfen und entfernen.', + 'sessions.worktreesPage.closeAria': 'Worktrees schließen', + 'sessions.scheduledTasks.page.closeAria': 'Geplante Aufgaben schließen', + 'sessions.sidebar.nav.archive': 'Archiv', + 'sessions.archivePage.title': 'Archiv', + 'sessions.archivePage.countSingle': '{count} archivierte Sitzung', + 'sessions.archivePage.countPlural': '{count} archivierte Sitzungen', + 'sessions.archivePage.closeAria': 'Archiv schließen', + 'sessions.archivePage.searchPlaceholder': 'Archivierte Sitzungen durchsuchen', + 'sessions.archivePage.empty.noArchived': 'Keine archivierten Sitzungen', + 'sessions.archivePage.empty.noMatches': 'Keine passenden archivierten Sitzungen', + 'sessions.archivePage.otherProjects': 'andere Projekte', + 'sessions.archivePage.deleteProject': 'Alle archivierten Sitzungen in diesem Projekt löschen', + 'sessions.archivePage.deleteProjectAria': 'Alle archivierten Sitzungen in {label} löschen', + 'sessions.archivePage.deleteSessionAria': '{title} löschen', + 'header.sessionActions.openAria': 'Sitzungsaktionen öffnen', + 'sessions.sidebar.session.menu.copyId': 'Sitzungs-ID kopieren', + 'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert', + 'sessions.sidebar.session.copyId.error': 'Sitzungs-ID konnte nicht kopiert werden', + 'sessions.sidebar.session.menu.moveToWorktree': 'In neuen Worktree verschieben', + 'sessions.sidebar.session.moveToWorktree.success': 'Sitzung in einen neuen Worktree verschoben', + 'sessions.sidebar.session.moveToWorktree.failed': 'Sitzung konnte nicht in einen neuen Worktree verschoben werden', + 'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch, überträgt nicht gespeicherte Änderungen und verschiebt diese Sitzung samt Untersitzungen dorthin.', + 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Verfügbar, wenn die Sitzung inaktiv ist. Warten Sie oder beenden Sie die aktuelle Aktivität.', + 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Diese Sitzung wird bereits in einen neuen Worktree verschoben.', + 'sessions.sidebar.session.export.failedLoadHistory': 'Die vollständige Sitzungshistorie konnte nicht geladen werden', + 'sessions.sidebar.session.status.movingToWorktree': 'Sitzung wird in einen neuen Worktree verschoben', + 'gitView.header.updateBranch': 'Branch aktualisieren', + 'gitView.header.openPullRequest': 'Pull Request öffnen', + 'gitView.history.refresh': 'Verlauf aktualisieren', + 'gitView.operation.inProgressTitleManyConflicts': 'Operation {operation} läuft: {count} Konflikte', + 'gitView.operation.inProgressTitleOneConflict': 'Operation {operation} läuft: {count} Konflikt', + 'gitView.operation.readyToContinueHint': 'Bereit zum Fortfahren', + 'gitView.pr.segment.overview': 'Übersicht', + 'gitView.pr.segment.checks': 'Prüfungen', + 'gitView.pr.segment.comments': 'Kommentare', + 'gitView.pr.comments.addAll': 'Alle hinzufügen', + 'contextPanel.mode.pr': 'PR', + 'contextRail.aria.rail': 'Kontextleiste', + 'contextPanel.editorEmpty.title': 'Kein Kontext ausgewählt', + 'contextPanel.editorEmpty.description': 'Wählen Sie etwas aus der Seitenleiste aus, um Kontext anzuzeigen.', + 'contextRail.surface.editor.description': 'Bearbeitungskontext', + 'contextRail.surface.git.description': 'Git-Kontext', + 'contextRail.surface.terminal.description': 'Terminal-Kontext', + 'contextRail.surface.diff.description': 'Diff-Kontext', + 'contextPanel.mode.walkthrough': 'Walkthrough', + 'contextRail.surface.walkthrough.description': 'Walkthrough-Kontext', + 'walkthrough.scope.all': 'Alle', + 'walkthrough.scope.group.workingTree': 'Arbeitsbaum', + 'walkthrough.scope.group.committed': 'Eingecheckt', + 'walkthrough.scope.staged': 'Bereitgestellt', + 'walkthrough.scope.working': 'Arbeitsstand', + 'walkthrough.scope.branch': 'Branch', + 'walkthrough.scope.selectorAria': 'Walkthrough-Bereich auswählen', + 'walkthrough.language.menuLabel': 'Sprache', + 'walkthrough.missing.language': 'Noch kein Walkthrough auf {requested} vorhanden — zeige den Walkthrough auf {shown}.', + 'walkthrough.missing.model': 'Noch kein Walkthrough von {model} vorhanden — zeige den zuletzt hier erstellten.', + 'walkthrough.missing.languageAndModel': 'Noch kein Walkthrough auf dieser Sprache von diesem Modell vorhanden — zeige den zuletzt hier erstellten.', + 'walkthrough.language.selectorAria': 'Sprache auswählen', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': 'Generieren', + 'walkthrough.action.regenerate': 'Erneut generieren', + 'walkthrough.action.cancel': 'Abbrechen', + 'walkthrough.action.next': 'Weiter', + 'walkthrough.action.previous': 'Zurück', + 'walkthrough.action.refresh': 'Aktualisieren', + 'walkthrough.action.open': 'Öffnen', + 'walkthrough.stage.collecting': 'Sammeln', + 'walkthrough.stage.asking': 'Fragen', + 'walkthrough.stage.assembling': 'Zusammenstellen', + 'walkthrough.empty.title': 'Noch nichts vorhanden', + 'walkthrough.empty.description': 'Wählen Sie Inhalte aus, um einen Walkthrough zu erstellen.', + 'walkthrough.stale.banner': 'Der Code hat sich nach diesem Review geändert. Veraltete Schritte: {count}', + 'walkthrough.stop.staleAll': 'Alle veralteten Inhalte stoppen', + 'walkthrough.stop.stalePartial': 'Ein Teil des vom Schritt beschriebenen Codes hat sich geändert. Fehlende Teile: {count}', + 'walkthrough.stop.staleShort': 'Veraltete stoppen', + 'walkthrough.stop.noCode': 'Kein Code vorhanden', + 'walkthrough.uncovered.title': 'Vom Review ausgelassene Änderungen: {count}', + 'walkthrough.uncovered.description': 'Diese Bereiche wurden noch nicht in den Walkthrough aufgenommen.', + 'walkthrough.toc.moreFiles': 'Weitere Dateien: {count}', + 'walkthrough.toc.uncovered': 'Nicht abgedeckt: {count}', + 'walkthrough.toc.resize': 'Größe ändern', + 'walkthrough.importance.critical': 'Kritisch', + 'walkthrough.importance.context': 'Kontext', + 'walkthrough.blocked.noModel.title': 'Kein Modell ausgewählt', + 'walkthrough.blocked.noModel.description': 'Wählen Sie zuerst ein Modell aus.', + 'walkthrough.blocked.emptyDiff.title': 'Kein Diff vorhanden', + 'walkthrough.blocked.emptyDiff.description': 'Es gibt keine Änderungen, die zusammengefasst werden können.', + 'walkthrough.blocked.contextTooSmall.title': 'Kontext zu klein', + 'walkthrough.blocked.contextTooSmall.description': '{model} passt in etwa {available}K Zeichen, und dieser Diff braucht etwa {required}K. Nichts wird abgeschnitten — wähle stattdessen ein Modell mit größerem Kontext.', + 'walkthrough.blocked.structuredOutput.title': 'Strukturierte Ausgabe nicht verfügbar', + 'walkthrough.blocked.structuredOutput.description': '{model} unterstützt die strukturierten Antworten nicht, die ein Walkthrough benötigt.', + 'walkthrough.blocked.chooseModel': 'Modell auswählen', + 'walkthrough.blocked.outputExhausted.title': 'Ausgabelimit erreicht', + 'walkthrough.blocked.outputExhausted.description': '{model} hat sein gesamtes Ausgabelimit fürs Nachdenken verbraucht und nichts zurückgegeben. Denkende Modelle tun das bei großen Diffs oft — ein Modell, das weniger denkt, oder ein schmalerer Review-Bereich reicht eher aus.', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Das kleine Modell hat sein gesamtes Ausgabelimit fürs Nachdenken verbraucht und nichts zurückgegeben. Denkende Modelle tun das bei großen Diffs oft — ein Modell, das weniger denkt, oder ein schmalerer Review-Bereich reicht eher aus.', + 'walkthrough.blocked.onlyGenerated.title': 'Nur generierter Inhalt', + 'walkthrough.blocked.onlyGenerated.description': 'Es ist nur generierter Inhalt vorhanden.', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Das kleine Modell passt in etwa {available}K Zeichen, und dieser Diff braucht etwa {required}K. Nichts wird abgeschnitten — wähle stattdessen ein Modell mit größerem Kontext.', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Das kleine Modell unterstützt die strukturierten Antworten nicht, die ein Walkthrough benötigt.', + 'contextRail.surface.plan.description': 'Plankontext', + 'contextRail.surface.pr.description': 'PR-Kontext', + 'contextRail.surface.notes.description': 'Notizkontext', + 'contextRail.surface.context.description': 'Allgemeiner Kontext', + 'contextRail.surface.browser.description': 'Browserkontext', + 'contextRail.surface.preview.description': 'Vorschaukontext', + 'contextRail.surface.chat.description': 'Chatkontext', + 'contextRail.surface.notes': 'Notizen', + 'contextRail.editorTree.toggle': 'Editorbaum umschalten', + 'sidebarFilesTree.actions.collapseAllTitle': 'Alle einklappen', + 'filesView.editor.cannotPreviewBinary': 'Binärdatei kann nicht in der Vorschau angezeigt werden', + 'filesView.editor.binaryFileDescription': 'Diese Datei ist binär und kann nicht als Text angezeigt werden.', + 'chat.goal.dialog.evaluationModelLabel': 'Bewertungsmodell', + 'terminalView.quickKeys.altLabel': 'Alt', + 'terminalView.quickKeys.altModifierAria': 'Alt-Taste', + 'chat.draftPresets.scheduleTask.label': 'Aufgabe planen', + 'chat.statusRow.modelStatus': '{model} ist {status}', + 'chat.commandAutocomplete.command.scheduleTaskDescription': 'Eine geplante Aufgabe erstellen', + 'chat.chatInput.toast.scheduleTaskFailed': 'Aufgabe konnte nicht geplant werden', + 'chat.container.sessionLoadError.title': 'Sitzung konnte nicht geladen werden', + 'chat.container.sessionLoadError.description': 'Die Sitzung konnte nicht geladen werden.', + 'chat.container.sessionLoadError.retry': 'Erneut versuchen', + 'sessions.sidebar.group.empty.loadingSessions': 'Sitzungen werden geladen...', + 'sessions.sidebar.group.empty.loadFailed': 'Sitzungen konnten nicht geladen werden', + 'sessions.sidebar.group.empty.retry': 'Erneut versuchen', + 'chat.messageBody.actions.pinContext': 'Kontext anheften', + 'chat.messageBody.actions.unpinContext': 'Kontext lösen', + 'chat.messageBody.actions.contextPinFailed': 'Kontext konnte nicht angeheftet werden', + 'chat.chatInput.toast.unsupportedAttachmentModalities': '{model} unterstützt den von {files} benötigten Eingabetyp {modalities} nicht. Du kannst die Nachricht trotzdem senden, aber diese Anhänge werden möglicherweise ignoriert.', + 'projectActions.actions.autoDiscoverTooltip': 'Automatisch erkannte Aktionen', + 'onboarding.localSetup.windows.stepInstallWslSuffix': 'WSL installieren', + 'chat.goal.toast.distillFallback': 'Das Ziel überschritt das Limit und konnte nicht verdichtet werden — für die Auswertung wird eine gekürzte Version verwendet', + 'onboarding.localSetup.actions.checkAndContinue': 'Installation abgeschlossen, prüfen und fortfahren', + 'onboarding.localSetup.status.autoContinue': 'Wir fahren automatisch fort, sobald die Installation erkannt wurde.', + 'updateDialog.changelog.title': 'Neuigkeiten', +}; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index ecd4d1ad..924965d1 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1604,6 +1604,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': 'Use default small model', 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Use default small model', 'settings.openchamber.defaults.smallModel.overrideModel': 'Override model', + 'settings.openchamber.defaults.walkthroughModel.title': 'Changes Walkthrough Model', + 'settings.openchamber.defaults.walkthroughModel.description': 'The AI review of your changes needs structured output and room for a whole diff, which a cheap small model often cannot give. Models the catalog reports as unable to produce structured output are hidden from this picker. Leave it unset and the small model is used.', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Walkthrough model', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Small model', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'Open previewable files in preview mode', 'settings.openchamber.defaults.field.openFilesPreview': 'Open previewable files in preview mode', 'settings.openchamber.defaults.option.default': 'Default', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index a8bdb155..7b63e05e 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -14,6 +14,7 @@ export const dict = { 'common.loading': 'Loading...', 'common.unavailable': 'Unavailable', 'common.language.english': 'English', + 'common.language.german': 'German', 'common.language.french': 'French', 'common.language.simplifiedChinese': 'Chinese (Simplified)', 'common.language.traditionalChinese': 'Chinese (Traditional)', @@ -90,7 +91,6 @@ export const dict = { 'mobile.nav.changes': 'Changes', 'mobile.nav.settings': 'Settings', 'mobile.surface.closeAria': 'Close', - 'mobile.header.openMenuAria': 'Open menu', 'mobile.header.openWorkspaceAria': 'Open workspace panel', 'mobile.header.openMetadataAria': 'Open session metadata', 'mobile.header.metadata.context': 'Context', @@ -834,12 +834,15 @@ export const dict = { 'gitView.operation.abort': 'Abort', 'gitView.operation.continue': 'Continue', 'gitView.operation.inProgressTitle': '{operation} in progress', + 'gitView.operation.inProgressTitleManyConflicts': '{operation} in progress: {count} conflicts', + 'gitView.operation.inProgressTitleOneConflict': '{operation} in progress: {count} conflict', 'gitView.operation.merge': 'Merge', 'gitView.operation.mergeInProgressWithHead': 'Merging {head}', 'gitView.operation.mergingMessage': 'Merging: {message}', 'gitView.operation.rebase': 'Rebase', 'gitView.operation.rebaseInProgress': 'Rebase in progress', 'gitView.operation.rebasingOnto': 'Rebasing {headName} onto {onto}', + 'gitView.operation.readyToContinueHint': 'All conflicts resolved. Continue to finish the operation.', 'gitView.operation.resolveConflictsHint': 'Resolve conflicts, then continue the operation.', 'gitView.operation.resolveWithAi': 'Resolve with AI', 'gitView.stash.confirmButton': '{operation}', @@ -1095,6 +1098,61 @@ export const dict = { 'contextRail.surface.git.description': 'Commits, branches, and pull requests', 'contextRail.surface.terminal.description': 'Built-in terminal', 'contextRail.surface.diff.description': 'Review working changes', + 'contextPanel.mode.walkthrough': 'Walkthrough', + 'contextRail.surface.walkthrough.description': 'An AI-guided walkthrough of your changes', + 'walkthrough.scope.all': 'All uncommitted', + 'walkthrough.scope.group.workingTree': 'Working tree', + 'walkthrough.scope.group.committed': 'Committed', + 'walkthrough.scope.staged': 'Staged', + 'walkthrough.scope.working': 'Unstaged', + 'walkthrough.scope.branch': 'This branch', + 'walkthrough.scope.selectorAria': 'Select what to review', + 'walkthrough.language.menuLabel': 'Walkthrough language', + 'walkthrough.missing.language': 'No walkthrough in {requested} yet — showing the one in {shown}.', + 'walkthrough.missing.model': 'No walkthrough from {model} yet — showing the last one generated here.', + 'walkthrough.missing.languageAndModel': 'No walkthrough in this language from this model yet — showing the last one generated here.', + 'walkthrough.language.selectorAria': 'Select the walkthrough language', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': 'Generate walkthrough', + 'walkthrough.action.regenerate': 'Regenerate', + 'walkthrough.action.cancel': 'Cancel', + 'walkthrough.action.next': 'Next step', + 'walkthrough.action.previous': 'Previous step', + 'walkthrough.action.refresh': 'Refresh', + 'walkthrough.action.open': 'Walkthrough', + 'walkthrough.stage.collecting': 'Collecting the changes', + 'walkthrough.stage.asking': 'Waiting on the model', + 'walkthrough.stage.assembling': 'Assembling the walkthrough', + 'walkthrough.empty.title': 'No walkthrough yet', + 'walkthrough.empty.description': 'Build a guided reading path through these changes. This calls the small model and costs tokens, so it only ever runs when you ask.', + 'walkthrough.stale.banner': 'The code changed after this review. Outdated steps: {count}', + 'walkthrough.stop.staleAll': 'All of the code this step described has changed.', + 'walkthrough.stop.stalePartial': 'Part of the code this step described has changed. Missing pieces: {count}', + 'walkthrough.stop.staleShort': 'Outdated', + 'walkthrough.stop.noCode': 'None of the code for this step is in the current diff.', + 'walkthrough.uncovered.title': 'Changes the review left out: {count}', + 'walkthrough.uncovered.description': 'The review treated these as routine. Expand to check them yourself.', + 'walkthrough.toc.moreFiles': 'More files: {count}', + 'walkthrough.toc.uncovered': 'Not covered: {count}', + 'walkthrough.toc.resize': 'Resize the contents column', + 'walkthrough.importance.critical': 'Critical', + 'walkthrough.importance.context': 'Context', + 'walkthrough.blocked.noModel.title': 'No small model available', + 'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.', + 'walkthrough.blocked.emptyDiff.title': 'Nothing to review', + 'walkthrough.blocked.emptyDiff.description': 'There are no changes in this scope yet.', + 'walkthrough.blocked.contextTooSmall.title': 'This diff is too large for the current model', + 'walkthrough.blocked.contextTooSmall.description': '{model} fits about {available}K characters and this diff needs about {required}K. Nothing gets truncated — pick a model with a larger context instead.', + 'walkthrough.blocked.structuredOutput.title': 'This model cannot produce structured output', + 'walkthrough.blocked.structuredOutput.description': '{model} does not support the structured responses a walkthrough needs.', + 'walkthrough.blocked.chooseModel': 'Choose the small model', + 'walkthrough.blocked.outputExhausted.title': 'This model ran out of answer budget', + 'walkthrough.blocked.outputExhausted.description': '{model} spent its whole output allowance on reasoning and returned nothing. Reasoning models often do this on large diffs — a model that thinks less, or reviewing a narrower scope, will get through.', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'The small model spent its whole output allowance on reasoning and returned nothing. Reasoning models often do this on large diffs — a model that thinks less, or reviewing a narrower scope, will get through.', + 'walkthrough.blocked.onlyGenerated.title': 'Only generated files changed', + 'walkthrough.blocked.onlyGenerated.description': 'Every change here is a lockfile or other tool-produced output, which the review deliberately skips.', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'The small model fits about {available}K characters and this diff needs about {required}K. Nothing gets truncated — pick a model with a larger context instead.', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'The small model does not support the structured responses a walkthrough needs.', 'contextRail.surface.plan.description': 'View the current plan', 'contextRail.surface.pr.description': 'Create, review, and merge the pull request for the current branch', 'contextRail.surface.notes.description': 'Notes, todos, and plans for the project', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 958f1caf..88f12bfe 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1579,6 +1579,10 @@ export const settingsDict = { "settings.openchamber.defaults.smallModel.useDefault": "Usar el modelo pequeño predeterminado", "settings.openchamber.defaults.smallModel.useDefaultAria": "Usar el modelo pequeño predeterminado", "settings.openchamber.defaults.smallModel.overrideModel": "Modelo de anulación", + "settings.openchamber.defaults.walkthroughModel.title": "Modelo del recorrido de cambios", + "settings.openchamber.defaults.walkthroughModel.description": "La revisión con IA de tus cambios necesita salida estructurada y espacio para un diff completo, algo que un modelo pequeño y barato a menudo no ofrece. Los modelos que el catálogo declara incapaces de producir salida estructurada se ocultan aquí. Si no eliges nada, se usa el modelo pequeño.", + "settings.openchamber.defaults.walkthroughModel.overrideModel": "Modelo del recorrido", + "settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Modelo pequeño", "settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación", "settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir archivos previsualizables en modo vista previa", "settings.openchamber.defaults.field.openFilesPreview": "Abrir archivos previsualizables en modo vista previa", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 12f47d20..08a3f727 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -15,6 +15,7 @@ export const dict: Record = { "common.loading": "Cargando...", "common.unavailable": "No disponible", "common.language.english": "Inglés", + "common.language.german": "Alemán", "common.language.french": "Francés", "common.language.simplifiedChinese": "Chino (simplificado)", "common.language.traditionalChinese": "Chino (tradicional)", @@ -91,7 +92,6 @@ export const dict: Record = { "mobile.nav.changes": "Cambios", "mobile.nav.settings": "Ajustes", "mobile.surface.closeAria": "Cerrar", - "mobile.header.openMenuAria": "Abrir menú", "mobile.header.openWorkspaceAria": "Abrir panel de trabajo", "mobile.header.openMetadataAria": "Abrir metadatos de la sesión", "mobile.header.metadata.context": "Contexto", @@ -835,12 +835,15 @@ export const dict: Record = { "gitView.operation.abort": "Abortar", "gitView.operation.continue": "Continuar", "gitView.operation.inProgressTitle": "{operation} en progreso", + "gitView.operation.inProgressTitleManyConflicts": "{operation} en curso: {count} conflictos", + "gitView.operation.inProgressTitleOneConflict": "{operation} en curso: {count} conflicto", "gitView.operation.merge": "Merge", "gitView.operation.mergeInProgressWithHead": "Haciendo merge de {head}", "gitView.operation.mergingMessage": "Haciendo merge: {message}", "gitView.operation.rebase": "Rebase", "gitView.operation.rebaseInProgress": "Rebase en progreso", "gitView.operation.rebasingOnto": "Haciendo rebase de {headName} sobre {onto}", + "gitView.operation.readyToContinueHint": "Todos los conflictos están resueltos. Continúa para finalizar la operación.", "gitView.operation.resolveConflictsHint": "Resuelve los conflictos y luego continúa la operación.", "gitView.operation.resolveWithAi": "Resolver con IA", "gitView.stash.confirmButton": "Guardar cambios y continuar", @@ -1096,6 +1099,61 @@ export const dict: Record = { "contextRail.surface.git.description": "Commits, ramas y pull requests", "contextRail.surface.terminal.description": "Terminal integrada", "contextRail.surface.diff.description": "Revisar cambios en curso", + "contextPanel.mode.walkthrough": "Recorrido", + "contextRail.surface.walkthrough.description": "Un recorrido por tus cambios guiado por IA", + "walkthrough.scope.all": "Todo sin confirmar", + "walkthrough.scope.group.workingTree": "Árbol de trabajo", + "walkthrough.scope.group.committed": "Confirmado", + "walkthrough.scope.staged": "Preparados", + "walkthrough.scope.working": "Sin preparar", + "walkthrough.scope.branch": "Esta rama", + "walkthrough.scope.selectorAria": "Elegir qué revisar", + "walkthrough.language.menuLabel": "Idioma del recorrido", + "walkthrough.missing.language": "Aún no hay un recorrido en {requested}: se muestra el de {shown}.", + "walkthrough.missing.model": "Aún no hay un recorrido de {model}: se muestra el último generado aquí.", + "walkthrough.missing.languageAndModel": "Aún no hay un recorrido en este idioma con este modelo: se muestra el último generado aquí.", + "walkthrough.language.selectorAria": "Elegir el idioma del recorrido", + "walkthrough.scope.pullRequest": "PR n.º {number}", + "walkthrough.action.generate": "Generar recorrido", + "walkthrough.action.regenerate": "Regenerar", + "walkthrough.action.cancel": "Cancelar", + "walkthrough.action.next": "Paso siguiente", + "walkthrough.action.previous": "Paso anterior", + "walkthrough.action.refresh": "Actualizar", + "walkthrough.action.open": "Recorrido con IA", + "walkthrough.stage.collecting": "Reuniendo los cambios", + "walkthrough.stage.asking": "Esperando al modelo", + "walkthrough.stage.assembling": "Montando el recorrido", + "walkthrough.empty.title": "Todavía no hay recorrido", + "walkthrough.empty.description": "Crea una ruta de lectura guiada por estos cambios. Esto llama al modelo pequeño y consume tokens, así que solo se ejecuta cuando lo pides.", + "walkthrough.stale.banner": "El código cambió después de esta revisión. Pasos desactualizados: {count}", + "walkthrough.stop.staleAll": "Todo el código que describía este paso ha cambiado.", + "walkthrough.stop.stalePartial": "Parte del código que describía este paso ha cambiado. Fragmentos perdidos: {count}", + "walkthrough.stop.staleShort": "Desactualizado", + "walkthrough.stop.noCode": "El código de este paso no está en el diff actual.", + "walkthrough.uncovered.title": "Cambios que la revisión omitió: {count}", + "walkthrough.uncovered.description": "La revisión los consideró rutinarios. Despliega para revisarlos tú mismo.", + "walkthrough.toc.moreFiles": "Más archivos: {count}", + "walkthrough.toc.uncovered": "Sin cubrir: {count}", + "walkthrough.toc.resize": "Cambiar el ancho de la columna de contenidos", + "walkthrough.importance.critical": "Crítico", + "walkthrough.importance.context": "Contexto", + "walkthrough.blocked.noModel.title": "No hay ningún modelo pequeño disponible", + "walkthrough.blocked.noModel.description": "Inicia sesión en un proveedor de modelos para generar una revisión.", + "walkthrough.blocked.emptyDiff.title": "Nada que revisar", + "walkthrough.blocked.emptyDiff.description": "Todavía no hay cambios en este ámbito.", + "walkthrough.blocked.contextTooSmall.title": "Este diff es demasiado grande para el modelo actual", + "walkthrough.blocked.contextTooSmall.description": "{model} admite unos {available} mil caracteres y este diff necesita unos {required} mil. No se recorta nada: elige un modelo con más contexto.", + "walkthrough.blocked.structuredOutput.title": "Este modelo no puede generar salida estructurada", + "walkthrough.blocked.structuredOutput.description": "{model} no admite las respuestas estructuradas que necesita un recorrido.", + "walkthrough.blocked.chooseModel": "Elige el modelo pequeño", + "walkthrough.blocked.outputExhausted.title": "El modelo se quedó sin presupuesto de respuesta", + "walkthrough.blocked.outputExhausted.description": "{model} gastó todo su margen de salida razonando y no devolvió nada. Los modelos de razonamiento suelen hacerlo con diffs grandes: prueba con un modelo que razone menos o revisa un ámbito más reducido.", + "walkthrough.blocked.outputExhausted.descriptionUnknownModel": "El modelo pequeño gastó todo su margen de salida razonando y no devolvió nada. Los modelos de razonamiento suelen hacerlo con diffs grandes: prueba con un modelo que razone menos o revisa un ámbito más reducido.", + "walkthrough.blocked.onlyGenerated.title": "Solo cambiaron archivos generados", + "walkthrough.blocked.onlyGenerated.description": "Todos los cambios son archivos de bloqueo u otra salida generada por herramientas, que la revisión omite a propósito.", + "walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "El modelo pequeño admite unos {available} mil caracteres y este diff necesita unos {required} mil. No se recorta nada: elige un modelo con más contexto.", + "walkthrough.blocked.structuredOutput.descriptionUnknownModel": "El modelo pequeño no admite las respuestas estructuradas que necesita un recorrido.", "contextRail.surface.plan.description": "Ver el plan actual", "contextRail.surface.pr.description": "Crea, revisa y fusiona el pull request de la rama actual", "contextRail.surface.notes.description": "Notas, tareas y planes del proyecto", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 64f87249..30cfe251 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1500,6 +1500,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': 'Utiliser le petit modèle par défaut', 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Utiliser le petit modèle par défaut', 'settings.openchamber.defaults.smallModel.overrideModel': 'Modèle de remplacement', + 'settings.openchamber.defaults.walkthroughModel.title': 'Modèle du parcours des modifications', + 'settings.openchamber.defaults.walkthroughModel.description': 'La revue par IA de vos modifications exige une sortie structurée et de la place pour un diff entier, ce qu’un petit modèle économique ne fournit pas toujours. Les modèles que le catalogue déclare incapables de sortie structurée sont masqués ici. Sans choix explicite, le petit modèle est utilisé.', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Modèle du parcours', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Petit modèle', 'settings.openchamber.defaults.field.showDeletionDialog': 'Afficher la boîte de dialogue de suppression', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'Ouvrir les fichiers prévisualisables en mode aperçu', 'settings.openchamber.defaults.field.openFilesPreview': 'Ouvrir les fichiers prévisualisables en mode aperçu', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 8574e4b9..2f99e4f4 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -14,6 +14,7 @@ export const dict = { 'common.loading': 'Chargement...', 'common.unavailable': 'Indisponible', 'common.language.english': 'Anglais', + 'common.language.german': 'Allemand', 'common.language.french': 'Français', 'common.language.simplifiedChinese': 'Chinois (simplifié)', 'common.language.traditionalChinese': 'Chinois (traditionnel)', @@ -661,12 +662,15 @@ export const dict = { 'gitView.operation.abort': 'Avorter', 'gitView.operation.continue': 'Continuer', 'gitView.operation.inProgressTitle': '{operation} en cours', + 'gitView.operation.inProgressTitleManyConflicts': '{operation} en cours : {count} conflits', + 'gitView.operation.inProgressTitleOneConflict': '{operation} en cours : {count} conflit', 'gitView.operation.merge': 'Fusionner', 'gitView.operation.mergeInProgressWithHead': 'Fusion de {head}', 'gitView.operation.mergingMessage': 'Fusion : {message}', 'gitView.operation.rebase': 'Rebase', 'gitView.operation.rebaseInProgress': 'Rebase en cours', 'gitView.operation.rebasingOnto': 'Rebaser {headName} sur {onto}', + 'gitView.operation.readyToContinueHint': 'Tous les conflits sont résolus. Poursuivez pour terminer l’opération.', 'gitView.operation.resolveConflictsHint': 'Résolvez les conflits, puis poursuivez l\'opération.', 'gitView.operation.resolveWithAi': 'Résoudre avec l\'IA', 'gitView.stash.confirmButton': 'Confirmer', @@ -919,6 +923,61 @@ export const dict = { 'contextRail.surface.git.description': 'Commits, branches et pull requests', 'contextRail.surface.terminal.description': 'Terminal intégré', 'contextRail.surface.diff.description': 'Passer en revue les modifications', + 'contextPanel.mode.walkthrough': 'Parcours', + 'contextRail.surface.walkthrough.description': 'Un parcours de vos modifications guidé par l’IA', + 'walkthrough.scope.all': 'Tout non validé', + 'walkthrough.scope.group.workingTree': 'Copie de travail', + 'walkthrough.scope.group.committed': 'Validé', + 'walkthrough.scope.staged': 'Indexées', + 'walkthrough.scope.working': 'Non indexées', + 'walkthrough.scope.branch': 'Cette branche', + 'walkthrough.scope.selectorAria': 'Choisir ce qui est examiné', + 'walkthrough.language.menuLabel': 'Langue du parcours', + 'walkthrough.missing.language': 'Pas encore de parcours en {requested} — voici celui en {shown}.', + 'walkthrough.missing.model': 'Pas encore de parcours généré par {model} — voici le dernier généré ici.', + 'walkthrough.missing.languageAndModel': 'Pas encore de parcours dans cette langue avec ce modèle — voici le dernier généré ici.', + 'walkthrough.language.selectorAria': 'Choisir la langue du parcours', + 'walkthrough.scope.pullRequest': 'PR n° {number}', + 'walkthrough.action.generate': 'Générer le parcours', + 'walkthrough.action.regenerate': 'Régénérer', + 'walkthrough.action.cancel': 'Annuler', + 'walkthrough.action.next': 'Étape suivante', + 'walkthrough.action.previous': 'Étape précédente', + 'walkthrough.action.refresh': 'Actualiser', + 'walkthrough.action.open': 'Parcours', + 'walkthrough.stage.collecting': 'Collecte des modifications', + 'walkthrough.stage.asking': 'En attente du modèle', + 'walkthrough.stage.assembling': 'Assemblage du parcours', + 'walkthrough.empty.title': 'Aucun parcours pour l’instant', + 'walkthrough.empty.description': 'Construisez un parcours de lecture guidé à travers ces modifications. Cela appelle le petit modèle et consomme des jetons, donc rien ne se lance sans votre demande.', + 'walkthrough.stale.banner': 'Le code a changé après cette revue. Étapes obsolètes : {count}', + 'walkthrough.stop.staleAll': 'Tout le code décrit par cette étape a changé.', + 'walkthrough.stop.stalePartial': 'Une partie du code décrit par cette étape a changé. Fragments manquants : {count}', + 'walkthrough.stop.staleShort': 'Obsolète', + 'walkthrough.stop.noCode': 'Le code de cette étape est absent du diff actuel.', + 'walkthrough.uncovered.title': 'Modifications non traitées par la revue : {count}', + 'walkthrough.uncovered.description': 'La revue les a jugées courantes. Dépliez pour les vérifier vous-même.', + 'walkthrough.toc.moreFiles': 'Autres fichiers : {count}', + 'walkthrough.toc.uncovered': 'Non traité : {count}', + 'walkthrough.toc.resize': 'Redimensionner la colonne du sommaire', + 'walkthrough.importance.critical': 'Critique', + 'walkthrough.importance.context': 'Contexte', + 'walkthrough.blocked.noModel.title': 'Aucun petit modèle disponible', + 'walkthrough.blocked.noModel.description': 'Connectez-vous à un fournisseur de modèles pour générer une revue.', + 'walkthrough.blocked.emptyDiff.title': 'Rien à examiner', + 'walkthrough.blocked.emptyDiff.description': 'Il n’y a encore aucune modification dans cette portée.', + 'walkthrough.blocked.contextTooSmall.title': 'Ce diff est trop volumineux pour le modèle actuel', + 'walkthrough.blocked.contextTooSmall.description': '{model} accepte environ {available} k caractères et ce diff en demande environ {required} k. Rien n’est tronqué : choisissez un modèle au contexte plus large.', + 'walkthrough.blocked.structuredOutput.title': 'Ce modèle ne produit pas de sortie structurée', + 'walkthrough.blocked.structuredOutput.description': '{model} ne prend pas en charge les réponses structurées nécessaires à un parcours.', + 'walkthrough.blocked.chooseModel': 'Choisissez le petit modèle', + 'walkthrough.blocked.outputExhausted.title': 'Le modèle a épuisé son budget de réponse', + 'walkthrough.blocked.outputExhausted.description': '{model} a dépensé toute sa marge de sortie en raisonnement et n’a rien renvoyé. Les modèles de raisonnement le font souvent sur de gros diffs : essayez un modèle qui réfléchit moins, ou une portée plus étroite.', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Le petit modèle a dépensé toute sa marge de sortie en raisonnement et n’a rien renvoyé. Les modèles de raisonnement le font souvent sur de gros diffs : essayez un modèle qui réfléchit moins, ou une portée plus étroite.', + 'walkthrough.blocked.onlyGenerated.title': 'Seuls des fichiers générés ont changé', + 'walkthrough.blocked.onlyGenerated.description': 'Toutes les modifications concernent des fichiers de verrouillage ou d’autres sorties générées, que la revue ignore délibérément.', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Le petit modèle accepte environ {available} k caractères et ce diff en demande environ {required} k. Rien n’est tronqué : choisissez un modèle au contexte plus large.', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Le petit modèle ne prend pas en charge les réponses structurées nécessaires à un parcours.', 'contextRail.surface.plan.description': 'Voir le plan actuel', 'contextRail.surface.pr.description': 'Créer, relire et fusionner la pull request de la branche actuelle', 'contextRail.surface.notes.description': 'Notes, tâches et plans du projet', @@ -2649,7 +2708,6 @@ export const dict = { 'mobile.nav.changes': 'Modifications', 'mobile.nav.settings': 'Paramètres', 'mobile.surface.closeAria': 'Fermer', - 'mobile.header.openMenuAria': 'Ouvrir le menu', 'mobile.header.openWorkspaceAria': 'Ouvrir le panneau de travail', 'mobile.header.openMetadataAria': 'Ouvrir les métadonnées de session', 'mobile.header.metadata.context': 'Contexte', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index f7550a83..6ad20690 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1612,6 +1612,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': 'デフォルトの小型モデルを使用', 'settings.openchamber.defaults.smallModel.useDefaultAria': 'デフォルトの小型モデルを使用', 'settings.openchamber.defaults.smallModel.overrideModel': '上書きモデル', + 'settings.openchamber.defaults.walkthroughModel.title': '変更ウォークスルーのモデル', + 'settings.openchamber.defaults.walkthroughModel.description': '変更の AI レビューには構造化出力と差分全体が収まる余裕が必要で、安価なスモールモデルでは足りないことがあります。カタログが構造化出力に非対応と示すモデルは、この一覧から除外されます。 未選択のままならスモールモデルが使われます。', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': 'ウォークスルーのモデル', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'スモールモデル', 'settings.openchamber.defaults.field.showDeletionDialog': '削除ダイアログを表示', 'settings.openchamber.defaults.field.openFilesPreviewAria': 'プレビュー可能なファイルをプレビューモードで開く', 'settings.openchamber.defaults.field.openFilesPreview': 'プレビュー可能なファイルをプレビューモードで開く', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 2a33f0fb..e7c7d111 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -15,6 +15,7 @@ export const dict: Record = { 'common.loading': '読み込み中...', 'common.unavailable': '利用できません', 'common.language.english': '英語', + 'common.language.german': 'ドイツ語', 'common.language.french': 'フランス語', 'common.language.simplifiedChinese': '中国語(簡体字)', 'common.language.traditionalChinese': '中国語(繁体字)', @@ -92,7 +93,6 @@ export const dict: Record = { 'mobile.instances.confirmDeleteAria': '{label} の削除を確定', 'mobile.instances.cancelDeleteAria': '{label} を残す', 'mobile.surface.closeAria': '閉じる', - 'mobile.header.openMenuAria': 'メニューを開く', 'mobile.header.openWorkspaceAria': 'ワークスペースパネルを開く', 'mobile.header.openMetadataAria': 'セッションメタデータを開く', 'mobile.header.metadata.context': 'コンテキスト', @@ -831,12 +831,15 @@ export const dict: Record = { 'gitView.operation.abort': '中止', 'gitView.operation.continue': '続行', 'gitView.operation.inProgressTitle': '{operation}進行中', + 'gitView.operation.inProgressTitleManyConflicts': '{operation} 進行中: 競合 {count} 件', + 'gitView.operation.inProgressTitleOneConflict': '{operation} 進行中: 競合 {count} 件', 'gitView.operation.merge': 'マージ', 'gitView.operation.mergeInProgressWithHead': '{head}をマージ中', 'gitView.operation.mergingMessage': 'マージ中: {message}', 'gitView.operation.rebase': 'リベース', 'gitView.operation.rebaseInProgress': 'リベース進行中', 'gitView.operation.rebasingOnto': '{headName}を{onto}にリベース中', + 'gitView.operation.readyToContinueHint': 'すべての競合が解決されました。続行して操作を完了してください。', 'gitView.operation.resolveConflictsHint': '競合を解決してから操作を続行してください。', 'gitView.operation.resolveWithAi': 'AIで解決', 'gitView.stash.confirmButton': '{operation}', @@ -1092,6 +1095,61 @@ export const dict: Record = { 'contextRail.surface.git.description': 'コミット・ブランチ・プルリクエスト', 'contextRail.surface.terminal.description': '内蔵ターミナル', 'contextRail.surface.diff.description': '作業中の変更をレビュー', + 'contextPanel.mode.walkthrough': 'ウォークスルー', + 'contextRail.surface.walkthrough.description': 'AI による変更のガイド付きウォークスルー', + 'walkthrough.scope.all': '未コミットすべて', + 'walkthrough.scope.group.workingTree': '作業ツリー', + 'walkthrough.scope.group.committed': 'コミット済み', + 'walkthrough.scope.staged': 'ステージ済み', + 'walkthrough.scope.working': '未ステージ', + 'walkthrough.scope.branch': 'このブランチ', + 'walkthrough.scope.selectorAria': 'レビュー対象を選択', + 'walkthrough.language.menuLabel': 'ウォークスルーの言語', + 'walkthrough.missing.language': '{requested}のウォークスルーはまだありません。{shown}のものを表示しています。', + 'walkthrough.missing.model': '{model} が生成したウォークスルーはまだありません。ここで最後に生成されたものを表示しています。', + 'walkthrough.missing.languageAndModel': 'この言語・このモデルのウォークスルーはまだありません。ここで最後に生成されたものを表示しています。', + 'walkthrough.language.selectorAria': 'ウォークスルーの言語を選択', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': 'ウォークスルーを生成', + 'walkthrough.action.regenerate': '再生成', + 'walkthrough.action.cancel': 'キャンセル', + 'walkthrough.action.next': '次のステップ', + 'walkthrough.action.previous': '前のステップ', + 'walkthrough.action.refresh': '更新', + 'walkthrough.action.open': 'ウォークスルー', + 'walkthrough.stage.collecting': '変更を集めています', + 'walkthrough.stage.asking': 'モデルの応答を待っています', + 'walkthrough.stage.assembling': 'ウォークスルーを組み立てています', + 'walkthrough.empty.title': 'ウォークスルーはまだありません', + 'walkthrough.empty.description': 'この変更を読み進めるためのガイドを作成します。スモールモデルを呼び出してトークンを消費するため、実行は明示的な操作時のみです。', + 'walkthrough.stale.banner': 'このレビュー後にコードが変更されました。古くなったステップ: {count}', + 'walkthrough.stop.staleAll': 'このステップが説明していたコードはすべて変更されました。', + 'walkthrough.stop.stalePartial': 'このステップが説明していたコードの一部が変更されました。失われた箇所: {count}', + 'walkthrough.stop.staleShort': '古い内容', + 'walkthrough.stop.noCode': 'このステップのコードは現在の差分にありません。', + 'walkthrough.uncovered.title': 'レビューが扱わなかった変更: {count}', + 'walkthrough.uncovered.description': 'レビューは定型的と判断しました。展開してご自身で確認できます。', + 'walkthrough.toc.moreFiles': 'その他のファイル: {count}', + 'walkthrough.toc.uncovered': '未対応: {count}', + 'walkthrough.toc.resize': '目次の列幅を変更', + 'walkthrough.importance.critical': '重要', + 'walkthrough.importance.context': '補足', + 'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません', + 'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。', + 'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません', + 'walkthrough.blocked.emptyDiff.description': 'この範囲にはまだ変更がありません。', + 'walkthrough.blocked.contextTooSmall.title': 'この差分は現在のモデルには大きすぎます', + 'walkthrough.blocked.contextTooSmall.description': '{model} が扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。', + 'walkthrough.blocked.structuredOutput.title': 'このモデルは構造化出力に対応していません', + 'walkthrough.blocked.structuredOutput.description': '{model} はウォークスルーに必要な構造化応答をサポートしていません。', + 'walkthrough.blocked.chooseModel': 'スモールモデルを選択', + 'walkthrough.blocked.outputExhausted.title': 'モデルの出力枠が尽きました', + 'walkthrough.blocked.outputExhausted.description': '{model} は出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'スモールモデルは出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。', + 'walkthrough.blocked.onlyGenerated.title': '生成ファイルのみが変更されています', + 'walkthrough.blocked.onlyGenerated.description': 'ここでの変更はロックファイルなどツールが生成した出力だけで、レビューは意図的にこれらを対象外にしています。', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'スモールモデルが扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'スモールモデルはウォークスルーに必要な構造化応答をサポートしていません。', 'contextRail.surface.plan.description': '現在のプランを表示', 'contextRail.surface.pr.description': '現在のブランチのプルリクエストを作成・確認・マージ', 'contextRail.surface.notes.description': 'プロジェクトのノート・ToDo・プラン', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index a80e7970..a46f1b2e 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1579,6 +1579,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': '기본 소형 모델 사용', 'settings.openchamber.defaults.smallModel.useDefaultAria': '기본 소형 모델 사용', 'settings.openchamber.defaults.smallModel.overrideModel': '재정의 모델', + 'settings.openchamber.defaults.walkthroughModel.title': '변경 워크스루 모델', + 'settings.openchamber.defaults.walkthroughModel.description': '변경 사항의 AI 리뷰에는 구조화된 출력과 전체 diff를 담을 여유가 필요한데, 저렴한 스몰 모델로는 부족할 때가 많습니다. 카탈로그가 구조화된 출력을 지원하지 않는다고 알리는 모델은 이 목록에서 숨겨집니다. 선택하지 않으면 스몰 모델이 사용됩니다.', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': '워크스루 모델', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '스몰 모델', 'settings.openchamber.defaults.field.showDeletionDialog': '삭제 확인 대화상자 표시', 'settings.openchamber.defaults.field.openFilesPreviewAria': '미리보기 가능한 파일을 미리보기 모드로 열기', 'settings.openchamber.defaults.field.openFilesPreview': '미리보기 가능한 파일을 미리보기 모드로 열기', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 4087bfe5..5e2983be 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -15,6 +15,7 @@ export const dict: Record = { 'common.loading': '로딩 중...', 'common.unavailable': '사용할 수 없음', 'common.language.english': '영어', + 'common.language.german': '독일어', 'common.language.french': '프랑스어', 'common.language.simplifiedChinese': '중국어(간체)', 'common.language.traditionalChinese': '중국어(번체)', @@ -91,7 +92,6 @@ export const dict: Record = { 'mobile.nav.changes': '변경사항', 'mobile.nav.settings': '설정', 'mobile.surface.closeAria': '닫기', - 'mobile.header.openMenuAria': '메뉴 열기', 'mobile.header.openWorkspaceAria': '작업 공간 패널 열기', 'mobile.header.openMetadataAria': '세션 메타데이터 열기', 'mobile.header.metadata.context': '컨텍스트', @@ -835,12 +835,15 @@ export const dict: Record = { 'gitView.operation.abort': '중단', 'gitView.operation.continue': '계속', 'gitView.operation.inProgressTitle': '{operation} 진행 중', + 'gitView.operation.inProgressTitleManyConflicts': '{operation} 진행 중: 충돌 {count}개', + 'gitView.operation.inProgressTitleOneConflict': '{operation} 진행 중: 충돌 {count}개', 'gitView.operation.merge': '병합', 'gitView.operation.mergeInProgressWithHead': '{head} 병합 진행 중', 'gitView.operation.mergingMessage': '병합 중: {message}', 'gitView.operation.rebase': '리베이스', 'gitView.operation.rebaseInProgress': '리베이스 진행 중', 'gitView.operation.rebasingOnto': '{headName}을(를) {onto} 위로 리베이스 중', + 'gitView.operation.readyToContinueHint': '모든 충돌이 해결되었습니다. 계속하여 작업을 완료하세요.', 'gitView.operation.resolveConflictsHint': '충돌을 해결한 뒤 작업을 계속하세요.', 'gitView.operation.resolveWithAi': 'AI로 해결', 'gitView.stash.confirmButton': '확인', @@ -1096,6 +1099,61 @@ export const dict: Record = { 'contextRail.surface.git.description': '커밋, 브랜치, 풀 리퀘스트', 'contextRail.surface.terminal.description': '내장 터미널', 'contextRail.surface.diff.description': '작업 중인 변경 사항 검토', + 'contextPanel.mode.walkthrough': '워크스루', + 'contextRail.surface.walkthrough.description': 'AI가 안내하는 변경 사항 워크스루', + 'walkthrough.scope.all': '커밋되지 않은 전체', + 'walkthrough.scope.group.workingTree': '작업 트리', + 'walkthrough.scope.group.committed': '커밋됨', + 'walkthrough.scope.staged': '스테이지됨', + 'walkthrough.scope.working': '스테이지 안 됨', + 'walkthrough.scope.branch': '이 브랜치', + 'walkthrough.scope.selectorAria': '리뷰 대상 선택', + 'walkthrough.language.menuLabel': '워크스루 언어', + 'walkthrough.missing.language': '{requested} 워크스루가 아직 없어 {shown} 워크스루를 표시합니다.', + 'walkthrough.missing.model': '{model}(으)로 생성한 워크스루가 아직 없어 마지막으로 생성된 것을 표시합니다.', + 'walkthrough.missing.languageAndModel': '이 언어와 이 모델로 생성한 워크스루가 아직 없어 마지막으로 생성된 것을 표시합니다.', + 'walkthrough.language.selectorAria': '워크스루 언어 선택', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': '워크스루 생성', + 'walkthrough.action.regenerate': '다시 생성', + 'walkthrough.action.cancel': '취소', + 'walkthrough.action.next': '다음 단계', + 'walkthrough.action.previous': '이전 단계', + 'walkthrough.action.refresh': '새로 고침', + 'walkthrough.action.open': '워크스루', + 'walkthrough.stage.collecting': '변경 사항을 모으는 중', + 'walkthrough.stage.asking': '모델을 기다리는 중', + 'walkthrough.stage.assembling': '워크스루를 조립하는 중', + 'walkthrough.empty.title': '아직 워크스루가 없습니다', + 'walkthrough.empty.description': '이 변경 사항을 따라 읽을 수 있는 경로를 만듭니다. 스몰 모델을 호출해 토큰을 사용하므로 요청할 때만 실행됩니다.', + 'walkthrough.stale.banner': '이 리뷰 이후에 코드가 바뀌었습니다. 오래된 단계: {count}', + 'walkthrough.stop.staleAll': '이 단계가 설명하던 코드가 모두 바뀌었습니다.', + 'walkthrough.stop.stalePartial': '이 단계가 설명하던 코드 일부가 바뀌었습니다. 사라진 부분: {count}', + 'walkthrough.stop.staleShort': '오래됨', + 'walkthrough.stop.noCode': '이 단계의 코드는 현재 diff에 없습니다.', + 'walkthrough.uncovered.title': '리뷰가 다루지 않은 변경: {count}', + 'walkthrough.uncovered.description': '리뷰가 일상적인 변경으로 판단했습니다. 펼쳐서 직접 확인하세요.', + 'walkthrough.toc.moreFiles': '다른 파일: {count}', + 'walkthrough.toc.uncovered': '미포함: {count}', + 'walkthrough.toc.resize': '목차 열 너비 조절', + 'walkthrough.importance.critical': '중요', + 'walkthrough.importance.context': '참고', + 'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다', + 'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.', + 'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다', + 'walkthrough.blocked.emptyDiff.description': '이 범위에는 아직 변경 사항이 없습니다.', + 'walkthrough.blocked.contextTooSmall.title': '이 diff는 현재 모델에 너무 큽니다', + 'walkthrough.blocked.contextTooSmall.description': '{model}은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.', + 'walkthrough.blocked.structuredOutput.title': '이 모델은 구조화된 출력을 지원하지 않습니다', + 'walkthrough.blocked.structuredOutput.description': '{model}은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.', + 'walkthrough.blocked.chooseModel': '스몰 모델 선택', + 'walkthrough.blocked.outputExhausted.title': '모델의 응답 예산이 바닥났습니다', + 'walkthrough.blocked.outputExhausted.description': '{model}이(가) 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '스몰 모델이 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.', + 'walkthrough.blocked.onlyGenerated.title': '생성된 파일만 변경되었습니다', + 'walkthrough.blocked.onlyGenerated.description': '여기의 변경은 모두 잠금 파일이거나 도구가 만든 산출물이며, 리뷰는 이런 파일을 의도적으로 건너뜁니다.', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '스몰 모델은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '스몰 모델은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.', 'contextRail.surface.plan.description': '현재 계획 보기', 'contextRail.surface.pr.description': '현재 브랜치의 풀 리퀘스트를 생성, 검토, 병합', 'contextRail.surface.notes.description': '프로젝트의 노트, 할 일, 계획', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index d2458540..93a9e901 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -739,6 +739,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': 'Używaj domyślnego małego modelu', 'settings.openchamber.defaults.smallModel.useDefaultAria': 'Używaj domyślnego małego modelu', 'settings.openchamber.defaults.smallModel.overrideModel': 'Model zastępczy', + 'settings.openchamber.defaults.walkthroughModel.title': 'Model przewodnika po zmianach', + 'settings.openchamber.defaults.walkthroughModel.description': 'Przegląd zmian przez AI wymaga ustrukturyzowanej odpowiedzi i miejsca na całe różnice, czego tani mały model często nie zapewnia. Modele, które katalog zgłasza jako niezdolne do ustrukturyzowanej odpowiedzi, są tu ukryte. Bez wyboru używany jest mały model.', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Model przewodnika', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Mały model', 'settings.openchamber.defaults.field.thinkingPlaceholder': 'Myślenie', 'settings.openchamber.defaults.option.default': 'Domyślne', 'settings.openchamber.defaults.option.defaultLowercase': 'domyślne', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index de3803be..f4129c47 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -16,6 +16,7 @@ export const dict: Record = { 'common.loading': 'Ładowanie...', 'common.unavailable': 'Niedostępne', 'common.language.english': 'Angielski', + 'common.language.german': 'Niemiecki', 'common.language.french': 'Francuski', 'common.language.simplifiedChinese': 'Chiński (Uproszczony)', 'common.language.traditionalChinese': 'Chiński (Tradycyjny)', @@ -92,7 +93,6 @@ export const dict: Record = { 'mobile.nav.changes': 'Zmiany', 'mobile.nav.settings': 'Ustawienia', 'mobile.surface.closeAria': 'Zamknij', - 'mobile.header.openMenuAria': 'Otwórz menu', 'mobile.header.openWorkspaceAria': 'Otwórz panel roboczy', 'mobile.header.openMetadataAria': 'Otwórz metadane sesji', 'mobile.header.metadata.context': 'Kontekst', @@ -1411,6 +1411,61 @@ export const dict: Record = { 'contextRail.surface.git.description': 'Commity, gałęzie i pull requesty', 'contextRail.surface.terminal.description': 'Wbudowany terminal', 'contextRail.surface.diff.description': 'Przeglądaj bieżące zmiany', + 'contextPanel.mode.walkthrough': 'Przewodnik', + 'contextRail.surface.walkthrough.description': 'Przewodnik po zmianach prowadzony przez AI', + 'walkthrough.scope.all': 'Wszystko niezatwierdzone', + 'walkthrough.scope.group.workingTree': 'Drzewo robocze', + 'walkthrough.scope.group.committed': 'Zatwierdzone', + 'walkthrough.scope.staged': 'W poczekalni', + 'walkthrough.scope.working': 'Poza poczekalnią', + 'walkthrough.scope.branch': 'Ta gałąź', + 'walkthrough.scope.selectorAria': 'Wybierz, co przejrzeć', + 'walkthrough.language.menuLabel': 'Język przewodnika', + 'walkthrough.missing.language': 'Nie ma jeszcze przewodnika w języku {requested} — pokazujemy ten w języku {shown}.', + 'walkthrough.missing.model': 'Nie ma jeszcze przewodnika od modelu {model} — pokazujemy ostatni wygenerowany tutaj.', + 'walkthrough.missing.languageAndModel': 'Nie ma jeszcze przewodnika w tym języku od tego modelu — pokazujemy ostatni wygenerowany tutaj.', + 'walkthrough.language.selectorAria': 'Wybierz język przewodnika', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': 'Wygeneruj przewodnik', + 'walkthrough.action.regenerate': 'Wygeneruj ponownie', + 'walkthrough.action.cancel': 'Anuluj', + 'walkthrough.action.next': 'Następny krok', + 'walkthrough.action.previous': 'Poprzedni krok', + 'walkthrough.action.refresh': 'Odśwież', + 'walkthrough.action.open': 'Przewodnik', + 'walkthrough.stage.collecting': 'Zbieram zmiany', + 'walkthrough.stage.asking': 'Czekam na model', + 'walkthrough.stage.assembling': 'Składam przewodnik', + 'walkthrough.empty.title': 'Nie ma jeszcze przewodnika', + 'walkthrough.empty.description': 'Zbuduj prowadzoną ścieżkę czytania tych zmian. Wywołuje to mały model i zużywa tokeny, więc uruchamia się tylko na Twoje żądanie.', + 'walkthrough.stale.banner': 'Kod zmienił się po tym przeglądzie. Nieaktualne kroki: {count}', + 'walkthrough.stop.staleAll': 'Cały kod opisywany przez ten krok uległ zmianie.', + 'walkthrough.stop.stalePartial': 'Część kodu opisywanego przez ten krok uległa zmianie. Brakujące fragmenty: {count}', + 'walkthrough.stop.staleShort': 'Nieaktualne', + 'walkthrough.stop.noCode': 'Kodu z tego kroku nie ma w bieżących różnicach.', + 'walkthrough.uncovered.title': 'Zmiany pominięte w przeglądzie: {count}', + 'walkthrough.uncovered.description': 'Przegląd uznał je za rutynowe. Rozwiń, aby sprawdzić je samodzielnie.', + 'walkthrough.toc.moreFiles': 'Więcej plików: {count}', + 'walkthrough.toc.uncovered': 'Nieuwzględnione: {count}', + 'walkthrough.toc.resize': 'Zmień szerokość kolumny spisu treści', + 'walkthrough.importance.critical': 'Krytyczne', + 'walkthrough.importance.context': 'Kontekst', + 'walkthrough.blocked.noModel.title': 'Brak dostępnego małego modelu', + 'walkthrough.blocked.noModel.description': 'Zaloguj się u dostawcy modeli, aby wygenerować przegląd.', + 'walkthrough.blocked.emptyDiff.title': 'Nie ma czego przeglądać', + 'walkthrough.blocked.emptyDiff.description': 'W tym zakresie nie ma jeszcze zmian.', + 'walkthrough.blocked.contextTooSmall.title': 'Te różnice są za duże dla bieżącego modelu', + 'walkthrough.blocked.contextTooSmall.description': '{model} mieści około {available} tys. znaków, a te różnice potrzebują około {required} tys. Nic nie jest obcinane — wybierz model z większym kontekstem.', + 'walkthrough.blocked.structuredOutput.title': 'Ten model nie tworzy ustrukturyzowanych odpowiedzi', + 'walkthrough.blocked.structuredOutput.description': '{model} nie obsługuje ustrukturyzowanych odpowiedzi wymaganych przez przewodnik.', + 'walkthrough.blocked.chooseModel': 'Wybierz mały model', + 'walkthrough.blocked.outputExhausted.title': 'Modelowi zabrakło budżetu na odpowiedź', + 'walkthrough.blocked.outputExhausted.description': '{model} zużył cały limit wyjścia na rozumowanie i nic nie zwrócił. Modele rozumujące często tak robią przy dużych różnicach — pomoże model mniej „myślący” albo węższy zakres przeglądu.', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Mały model zużył cały limit wyjścia na rozumowanie i nic nie zwrócił. Modele rozumujące często tak robią przy dużych różnicach — pomoże model mniej „myślący” albo węższy zakres przeglądu.', + 'walkthrough.blocked.onlyGenerated.title': 'Zmieniły się tylko pliki generowane', + 'walkthrough.blocked.onlyGenerated.description': 'Wszystkie zmiany to pliki blokad lub inne wyniki pracy narzędzi, które przegląd celowo pomija.', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Mały model mieści około {available} tys. znaków, a te różnice potrzebują około {required} tys. Nic nie jest obcinane — wybierz model z większym kontekstem.', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Mały model nie obsługuje ustrukturyzowanych odpowiedzi wymaganych przez przewodnik.', 'contextRail.surface.plan.description': 'Zobacz bieżący plan', 'contextRail.surface.pr.description': 'Twórz, przeglądaj i scalaj pull request bieżącej gałęzi', 'contextRail.surface.notes.description': 'Notatki, zadania i plany projektu', @@ -1974,12 +2029,15 @@ export const dict: Record = { 'gitView.operation.abort': 'Przerwij', 'gitView.operation.continue': 'Kontynuuj', 'gitView.operation.inProgressTitle': 'Operacja w toku', + 'gitView.operation.inProgressTitleManyConflicts': '{operation} w toku: {count} konfliktów', + 'gitView.operation.inProgressTitleOneConflict': '{operation} w toku: {count} konflikt', 'gitView.operation.merge': 'Scal', 'gitView.operation.mergeInProgressWithHead': 'Trwa scalanie z HEAD', 'gitView.operation.mergingMessage': 'Trwa scalanie', 'gitView.operation.rebase': 'Rebase', 'gitView.operation.rebaseInProgress': 'Trwa rebase', 'gitView.operation.rebasingOnto': 'Wykonywanie rebase na', + 'gitView.operation.readyToContinueHint': 'Wszystkie konflikty rozwiązane. Kontynuuj, aby zakończyć operację.', 'gitView.operation.resolveConflictsHint': 'Rozwiąż konflikty, aby kontynuować.', 'gitView.operation.resolveWithAi': 'Rozwiąż za pomocą AI', 'gitView.pr.actions.add': 'Dodaj', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 0bd88f08..eebdc398 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1579,6 +1579,10 @@ export const settingsDict = { "settings.openchamber.defaults.smallModel.useDefault": "Usar o modelo pequeno padrão", "settings.openchamber.defaults.smallModel.useDefaultAria": "Usar o modelo pequeno padrão", "settings.openchamber.defaults.smallModel.overrideModel": "Modelo de substituição", + "settings.openchamber.defaults.walkthroughModel.title": "Modelo do percurso de mudanças", + "settings.openchamber.defaults.walkthroughModel.description": "A revisão das suas mudanças por IA precisa de saída estruturada e espaço para um diff inteiro, o que um modelo pequeno e barato muitas vezes não oferece. Modelos que o catálogo indica como incapazes de saída estruturada ficam ocultos aqui. Sem escolha, o modelo pequeno é usado.", + "settings.openchamber.defaults.walkthroughModel.overrideModel": "Modelo do percurso", + "settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Modelo pequeno", "settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación", "settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir arquivos com visualização em modo de prévia", "settings.openchamber.defaults.field.openFilesPreview": "Abrir arquivos com visualização em modo de prévia", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 68096fae..a9c3bca9 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -15,6 +15,7 @@ export const dict: Record = { "common.loading": "Carregando...", "common.unavailable": "Indisponível", "common.language.english": "Inglês", + "common.language.german": "Alemão", "common.language.french": "Francês", "common.language.simplifiedChinese": "Chinês (simplificado)", "common.language.traditionalChinese": "Chinês (tradicional)", @@ -91,7 +92,6 @@ export const dict: Record = { "mobile.nav.changes": "Alterações", "mobile.nav.settings": "Configurações", "mobile.surface.closeAria": "Fechar", - "mobile.header.openMenuAria": "Abrir menu", "mobile.header.openWorkspaceAria": "Abrir painel de trabalho", "mobile.header.openMetadataAria": "Abrir metadados da sessão", "mobile.header.metadata.context": "Contexto", @@ -835,12 +835,15 @@ export const dict: Record = { "gitView.operation.abort": "Abortar", "gitView.operation.continue": "Continuar", "gitView.operation.inProgressTitle": "{operation} em andamento", + "gitView.operation.inProgressTitleManyConflicts": "{operation} em andamento: {count} conflitos", + "gitView.operation.inProgressTitleOneConflict": "{operation} em andamento: {count} conflito", "gitView.operation.merge": "Merge", "gitView.operation.mergeInProgressWithHead": "Fazendo merge de {head}", "gitView.operation.mergingMessage": "Fazendo merge: {message}", "gitView.operation.rebase": "Rebase", "gitView.operation.rebaseInProgress": "Rebase em andamento", "gitView.operation.rebasingOnto": "Fazendo rebase de {headName} sobre {onto}", + "gitView.operation.readyToContinueHint": "Todos os conflitos foram resolvidos. Continue para finalizar a operação.", "gitView.operation.resolveConflictsHint": "Resolva os conflitos e depois continue a operação.", "gitView.operation.resolveWithAi": "Resolver com IA", "gitView.stash.confirmButton": "Salvar alterações e continuar", @@ -1096,6 +1099,61 @@ export const dict: Record = { "contextRail.surface.git.description": "Commits, branches e pull requests", "contextRail.surface.terminal.description": "Terminal integrado", "contextRail.surface.diff.description": "Revisar alterações em andamento", + "contextPanel.mode.walkthrough": "Percurso", + "contextRail.surface.walkthrough.description": "Um percurso pelas suas mudanças guiado por IA", + "walkthrough.scope.all": "Tudo sem commit", + "walkthrough.scope.group.workingTree": "Árvore de trabalho", + "walkthrough.scope.group.committed": "Com commit", + "walkthrough.scope.staged": "No stage", + "walkthrough.scope.working": "Fora do stage", + "walkthrough.scope.branch": "Este branch", + "walkthrough.scope.selectorAria": "Escolher o que revisar", + "walkthrough.language.menuLabel": "Idioma do percurso", + "walkthrough.missing.language": "Ainda não há um percurso em {requested} — exibindo o de {shown}.", + "walkthrough.missing.model": "Ainda não há um percurso gerado por {model} — exibindo o último gerado aqui.", + "walkthrough.missing.languageAndModel": "Ainda não há um percurso neste idioma com este modelo — exibindo o último gerado aqui.", + "walkthrough.language.selectorAria": "Escolher o idioma do percurso", + "walkthrough.scope.pullRequest": "PR nº {number}", + "walkthrough.action.generate": "Gerar percurso", + "walkthrough.action.regenerate": "Gerar novamente", + "walkthrough.action.cancel": "Cancelar", + "walkthrough.action.next": "Próxima etapa", + "walkthrough.action.previous": "Etapa anterior", + "walkthrough.action.refresh": "Atualizar", + "walkthrough.action.open": "Percurso com IA", + "walkthrough.stage.collecting": "Reunindo as mudanças", + "walkthrough.stage.asking": "Aguardando o modelo", + "walkthrough.stage.assembling": "Montando o percurso", + "walkthrough.empty.title": "Ainda não há percurso", + "walkthrough.empty.description": "Monte um percurso de leitura por estas mudanças. Isso chama o modelo pequeno e consome tokens, então só roda quando você pede.", + "walkthrough.stale.banner": "O código mudou depois desta revisão. Etapas desatualizadas: {count}", + "walkthrough.stop.staleAll": "Todo o código que esta etapa descrevia mudou.", + "walkthrough.stop.stalePartial": "Parte do código que esta etapa descrevia mudou. Trechos perdidos: {count}", + "walkthrough.stop.staleShort": "Desatualizado", + "walkthrough.stop.noCode": "O código desta etapa não está no diff atual.", + "walkthrough.uncovered.title": "Mudanças que a revisão deixou de fora: {count}", + "walkthrough.uncovered.description": "A revisão considerou essas mudanças rotineiras. Expanda para conferir você mesmo.", + "walkthrough.toc.moreFiles": "Mais arquivos: {count}", + "walkthrough.toc.uncovered": "Sem cobertura: {count}", + "walkthrough.toc.resize": "Redimensionar a coluna de conteúdo", + "walkthrough.importance.critical": "Crítico", + "walkthrough.importance.context": "Contexto", + "walkthrough.blocked.noModel.title": "Nenhum modelo pequeno disponível", + "walkthrough.blocked.noModel.description": "Entre em um provedor de modelos para gerar uma revisão.", + "walkthrough.blocked.emptyDiff.title": "Nada para revisar", + "walkthrough.blocked.emptyDiff.description": "Ainda não há mudanças neste escopo.", + "walkthrough.blocked.contextTooSmall.title": "Este diff é grande demais para o modelo atual", + "walkthrough.blocked.contextTooSmall.description": "{model} comporta cerca de {available} mil caracteres e este diff precisa de cerca de {required} mil. Nada é cortado — escolha um modelo com contexto maior.", + "walkthrough.blocked.structuredOutput.title": "Este modelo não produz saída estruturada", + "walkthrough.blocked.structuredOutput.description": "{model} não suporta as respostas estruturadas que um percurso exige.", + "walkthrough.blocked.chooseModel": "Escolha o modelo pequeno", + "walkthrough.blocked.outputExhausted.title": "O modelo ficou sem orçamento de resposta", + "walkthrough.blocked.outputExhausted.description": "{model} gastou toda a margem de saída raciocinando e não devolveu nada. Modelos de raciocínio costumam fazer isso em diffs grandes — escolha um modelo que raciocine menos ou revise um escopo menor.", + "walkthrough.blocked.outputExhausted.descriptionUnknownModel": "O modelo pequeno gastou toda a margem de saída raciocinando e não devolveu nada. Modelos de raciocínio costumam fazer isso em diffs grandes — escolha um modelo que raciocine menos ou revise um escopo menor.", + "walkthrough.blocked.onlyGenerated.title": "Só mudaram arquivos gerados", + "walkthrough.blocked.onlyGenerated.description": "Todas as mudanças são arquivos de lock ou outra saída gerada por ferramentas, que a revisão ignora de propósito.", + "walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "O modelo pequeno comporta cerca de {available} mil caracteres e este diff precisa de cerca de {required} mil. Nada é cortado — escolha um modelo com contexto maior.", + "walkthrough.blocked.structuredOutput.descriptionUnknownModel": "O modelo pequeno não suporta as respostas estruturadas que um percurso exige.", "contextRail.surface.plan.description": "Ver o plano atual", "contextRail.surface.pr.description": "Crie, revise e faça merge do pull request do branch atual", "contextRail.surface.notes.description": "Notas, tarefas e planos do projeto", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index cddc2bbf..b7f18d2e 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1579,6 +1579,10 @@ export const settingsDict = { "settings.openchamber.defaults.smallModel.useDefault": "Використовувати типову малу модель", "settings.openchamber.defaults.smallModel.useDefaultAria": "Використовувати типову малу модель", "settings.openchamber.defaults.smallModel.overrideModel": "Модель заміни", + "settings.openchamber.defaults.walkthroughModel.title": "Модель для розбору змін", + "settings.openchamber.defaults.walkthroughModel.description": "AI-розбір ваших змін потребує structured output і місця під увесь diff, чого дешева small model часто дати не може. Моделі, які за каталогом не вміють structured output, у цьому списку приховані. Якщо нічого не обрано, використовується small model.", + "settings.openchamber.defaults.walkthroughModel.overrideModel": "Модель для розбору", + "settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Small model", "settings.openchamber.defaults.field.showDeletionDialog": "Показати діалогове вікно видалення", "settings.openchamber.defaults.field.openFilesPreviewAria": "Відкривати файли з підтримкою перегляду в режимі попереднього перегляду", "settings.openchamber.defaults.field.openFilesPreview": "Відкривати файли з підтримкою перегляду в режимі попереднього перегляду", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 97ab6b7f..c1c3d7cc 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -15,6 +15,7 @@ export const dict: Record = { "common.loading": "Завантаження...", "common.unavailable": "Недоступно", "common.language.english": "англійська", + "common.language.german": "Німецька", "common.language.french": "Французька", "common.language.simplifiedChinese": "Китайська (спрощена)", "common.language.traditionalChinese": "Китайська (традиційна)", @@ -91,7 +92,6 @@ export const dict: Record = { "mobile.nav.changes": "Зміни", "mobile.nav.settings": "Налаштування", "mobile.surface.closeAria": "Закрити", - "mobile.header.openMenuAria": "Відкрити меню", "mobile.header.openWorkspaceAria": "Відкрити робочу панель", "mobile.header.openMetadataAria": "Відкрити метадані сесії", "mobile.header.metadata.context": "Контекст", @@ -835,12 +835,15 @@ export const dict: Record = { "gitView.operation.abort": "Перервати", "gitView.operation.continue": "Продовжити", "gitView.operation.inProgressTitle": "Виконується {operation}", + "gitView.operation.inProgressTitleManyConflicts": "{operation} у процесі: {count} конфліктів", + "gitView.operation.inProgressTitleOneConflict": "{operation} у процесі: {count} конфлікт", "gitView.operation.merge": "Злити", "gitView.operation.mergeInProgressWithHead": "Виконується злиття з {head}", "gitView.operation.mergingMessage": "Виконується злиття: {message}", "gitView.operation.rebase": "Перебазувати", "gitView.operation.rebaseInProgress": "Виконується перебазування", "gitView.operation.rebasingOnto": "Перебазування {headName} на {onto}", + "gitView.operation.readyToContinueHint": "Усі конфлікти вирішено. Продовжте, щоб завершити операцію.", "gitView.operation.resolveConflictsHint": "Вирішіть конфлікти, потім продовжіть операцію.", "gitView.operation.resolveWithAi": "Вирішити за допомогою ШІ", "gitView.stash.confirmButton": "Підтвердити", @@ -1096,6 +1099,61 @@ export const dict: Record = { "contextRail.surface.git.description": "Коміти, гілки та pull request-и", "contextRail.surface.terminal.description": "Вбудований термінал", "contextRail.surface.diff.description": "Перегляд поточних змін", + "contextPanel.mode.walkthrough": "Розбір", + "contextRail.surface.walkthrough.description": "Покроковий розбір ваших змін за допомогою AI", + "walkthrough.scope.all": "Усе незакомічене", + "walkthrough.scope.group.workingTree": "Робоче дерево", + "walkthrough.scope.group.committed": "Закомічене", + "walkthrough.scope.staged": "В індексі", + "walkthrough.scope.working": "Поза індексом", + "walkthrough.scope.branch": "Ця гілка", + "walkthrough.scope.selectorAria": "Обрати, що розбирати", + "walkthrough.language.menuLabel": "Мова розбору", + "walkthrough.missing.language": "Розбору мовою {requested} ще немає — показано той, що мовою {shown}.", + "walkthrough.missing.model": "Розбору від {model} ще немає — показано останній згенерований тут.", + "walkthrough.missing.languageAndModel": "Розбору цією мовою від цієї моделі ще немає — показано останній згенерований тут.", + "walkthrough.language.selectorAria": "Обрати мову розбору", + "walkthrough.scope.pullRequest": "PR #{number}", + "walkthrough.action.generate": "Створити розбір", + "walkthrough.action.regenerate": "Створити заново", + "walkthrough.action.cancel": "Скасувати", + "walkthrough.action.next": "Наступний крок", + "walkthrough.action.previous": "Попередній крок", + "walkthrough.action.refresh": "Оновити", + "walkthrough.action.open": "AI-розбір", + "walkthrough.stage.collecting": "Збираю зміни", + "walkthrough.stage.asking": "Чекаю на модель", + "walkthrough.stage.assembling": "Складаю розбір", + "walkthrough.empty.title": "Розбору ще немає", + "walkthrough.empty.description": "Побудуйте маршрут читання цих змін. Це звертається до small model і витрачає токени, тому запускається лише на ваш запит.", + "walkthrough.stale.banner": "Код змінився після цього розбору. Застарілих кроків: {count}", + "walkthrough.stop.staleAll": "Увесь код, який описував цей крок, змінився.", + "walkthrough.stop.stalePartial": "Частина коду, який описував цей крок, змінилася. Втрачених фрагментів: {count}", + "walkthrough.stop.staleShort": "Застаріле", + "walkthrough.stop.noCode": "Коду для цього кроку немає в поточному diff.", + "walkthrough.uncovered.title": "Зміни, які розбір не описав: {count}", + "walkthrough.uncovered.description": "Розбір визнав їх рутинними. Розгорніть, щоб переглянути самостійно.", + "walkthrough.toc.moreFiles": "Ще файлів: {count}", + "walkthrough.toc.uncovered": "Не описано: {count}", + "walkthrough.toc.resize": "Змінити ширину колонки змісту", + "walkthrough.importance.critical": "Критично", + "walkthrough.importance.context": "Контекст", + "walkthrough.blocked.noModel.title": "Немає доступної small model", + "walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.", + "walkthrough.blocked.emptyDiff.title": "Немає що оглядати", + "walkthrough.blocked.emptyDiff.description": "У цій області поки що немає змін.", + "walkthrough.blocked.contextTooSmall.title": "Цей diff завеликий для поточної моделі", + "walkthrough.blocked.contextTooSmall.description": "{model} вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.", + "walkthrough.blocked.structuredOutput.title": "Ця модель не вміє structured output", + "walkthrough.blocked.structuredOutput.description": "{model} не підтримує структуровані відповіді, потрібні для розбору.", + "walkthrough.blocked.chooseModel": "Оберіть small model", + "walkthrough.blocked.outputExhausted.title": "Моделі забракло бюджету на відповідь", + "walkthrough.blocked.outputExhausted.description": "{model} витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.", + "walkthrough.blocked.outputExhausted.descriptionUnknownModel": "Small model витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.", + "walkthrough.blocked.onlyGenerated.title": "Змінились лише згенеровані файли", + "walkthrough.blocked.onlyGenerated.description": "Усі зміни тут — це lock-файли чи інший результат роботи інструментів, які розбір свідомо пропускає.", + "walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "Small model вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.", + "walkthrough.blocked.structuredOutput.descriptionUnknownModel": "Small model не підтримує структуровані відповіді, потрібні для розбору.", "contextRail.surface.plan.description": "Перегляд поточного плану", "contextRail.surface.pr.description": "Створюйте, переглядайте та зливайте pull request поточної гілки", "contextRail.surface.notes.description": "Нотатки, задачі та плани проєкту", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 96d48d44..8fe889c7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1579,6 +1579,10 @@ export const settingsDict = { 'settings.openchamber.defaults.smallModel.useDefault': '使用默认小模型', 'settings.openchamber.defaults.smallModel.useDefaultAria': '使用默认小模型', 'settings.openchamber.defaults.smallModel.overrideModel': '覆盖模型', + 'settings.openchamber.defaults.walkthroughModel.title': '改动导读模型', + 'settings.openchamber.defaults.walkthroughModel.description': 'AI 评审改动需要结构化输出和容纳整份差异的空间,廉价的小模型往往做不到。目录中标注为不支持结构化输出的模型不会出现在此列表里。 未选择时将使用小模型。', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': '导读模型', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '小模型', 'settings.openchamber.defaults.field.showDeletionDialog': '显示删除对话框', 'settings.openchamber.defaults.field.openFilesPreviewAria': '以预览模式打开可预览文件', 'settings.openchamber.defaults.field.openFilesPreview': '以预览模式打开可预览文件', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index ba7502ed..9fef7f48 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -15,6 +15,7 @@ export const dict: Record = { 'common.loading': '加载中...', 'common.unavailable': '不可用', 'common.language.english': 'English', + 'common.language.german': '德语', 'common.language.french': '法语', 'common.language.simplifiedChinese': '简体中文', 'common.language.traditionalChinese': '繁体中文', @@ -91,7 +92,6 @@ export const dict: Record = { 'mobile.nav.changes': '更改', 'mobile.nav.settings': '设置', 'mobile.surface.closeAria': '关闭', - 'mobile.header.openMenuAria': '打开菜单', 'mobile.header.openWorkspaceAria': '打开工作区面板', 'mobile.header.openMetadataAria': '打开会话元数据', 'mobile.header.metadata.context': '上下文', @@ -835,12 +835,15 @@ export const dict: Record = { 'gitView.operation.abort': '中止', 'gitView.operation.continue': '继续', 'gitView.operation.inProgressTitle': '{operation} 进行中', + 'gitView.operation.inProgressTitleManyConflicts': '{operation} 进行中:{count} 个冲突', + 'gitView.operation.inProgressTitleOneConflict': '{operation} 进行中:{count} 个冲突', 'gitView.operation.merge': '合并', 'gitView.operation.mergeInProgressWithHead': '合并进行中({head})', 'gitView.operation.mergingMessage': '正在合并:{message}', 'gitView.operation.rebase': '变基', 'gitView.operation.rebaseInProgress': '变基进行中', 'gitView.operation.rebasingOnto': '正在将 {headName} 变基到 {onto}', + 'gitView.operation.readyToContinueHint': '所有冲突已解决。继续以完成操作。', 'gitView.operation.resolveConflictsHint': '解决冲突,然后继续操作。', 'gitView.operation.resolveWithAi': '使用 AI 解决', 'gitView.stash.confirmButton': '储藏并{operation}', @@ -1096,6 +1099,61 @@ export const dict: Record = { 'contextRail.surface.git.description': '提交、分支和拉取请求', 'contextRail.surface.terminal.description': '内置终端', 'contextRail.surface.diff.description': '查看工作区更改', + 'contextPanel.mode.walkthrough': '导读', + 'contextRail.surface.walkthrough.description': '由 AI 引导的改动导读', + 'walkthrough.scope.all': '全部未提交', + 'walkthrough.scope.group.workingTree': '工作区', + 'walkthrough.scope.group.committed': '已提交', + 'walkthrough.scope.staged': '已暂存', + 'walkthrough.scope.working': '未暂存', + 'walkthrough.scope.branch': '当前分支', + 'walkthrough.scope.selectorAria': '选择评审范围', + 'walkthrough.language.menuLabel': '导读语言', + 'walkthrough.missing.language': '尚无{requested}导读,当前显示的是{shown}版本。', + 'walkthrough.missing.model': '尚无由 {model} 生成的导读,当前显示最近一次生成的版本。', + 'walkthrough.missing.languageAndModel': '尚无使用该语言和该模型生成的导读,当前显示最近一次生成的版本。', + 'walkthrough.language.selectorAria': '选择导读语言', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': '生成导读', + 'walkthrough.action.regenerate': '重新生成', + 'walkthrough.action.cancel': '取消', + 'walkthrough.action.next': '下一步', + 'walkthrough.action.previous': '上一步', + 'walkthrough.action.refresh': '刷新', + 'walkthrough.action.open': '导读', + 'walkthrough.stage.collecting': '正在收集改动', + 'walkthrough.stage.asking': '正在等待模型', + 'walkthrough.stage.assembling': '正在组装导读', + 'walkthrough.empty.title': '还没有导读', + 'walkthrough.empty.description': '为这些改动生成一条引导式阅读路线。该操作会调用小模型并消耗 token,因此只在你主动请求时运行。', + 'walkthrough.stale.banner': '本次评审之后代码有变动。过时的步骤:{count}', + 'walkthrough.stop.staleAll': '该步骤描述的代码已全部变动。', + 'walkthrough.stop.stalePartial': '该步骤描述的部分代码已变动。缺失片段:{count}', + 'walkthrough.stop.staleShort': '已过时', + 'walkthrough.stop.noCode': '当前差异中没有该步骤的代码。', + 'walkthrough.uncovered.title': '评审未涵盖的改动:{count}', + 'walkthrough.uncovered.description': '评审认为这些改动较常规。展开可自行查看。', + 'walkthrough.toc.moreFiles': '其他文件:{count}', + 'walkthrough.toc.uncovered': '未涵盖:{count}', + 'walkthrough.toc.resize': '调整目录栏宽度', + 'walkthrough.importance.critical': '关键', + 'walkthrough.importance.context': '背景', + 'walkthrough.blocked.noModel.title': '没有可用的小模型', + 'walkthrough.blocked.noModel.description': '请登录模型提供方后再生成评审。', + 'walkthrough.blocked.emptyDiff.title': '没有可评审的内容', + 'walkthrough.blocked.emptyDiff.description': '该范围内暂无改动。', + 'walkthrough.blocked.contextTooSmall.title': '当前模型无法容纳这份差异', + 'walkthrough.blocked.contextTooSmall.description': '{model} 大约可容纳 {available} 千字符,而这份差异约需 {required} 千字符。我们不会截断内容,请改选上下文更大的模型。', + 'walkthrough.blocked.structuredOutput.title': '该模型不支持结构化输出', + 'walkthrough.blocked.structuredOutput.description': '{model} 不支持导读所需的结构化响应。', + 'walkthrough.blocked.chooseModel': '选择小模型', + 'walkthrough.blocked.outputExhausted.title': '模型的回答额度已用尽', + 'walkthrough.blocked.outputExhausted.description': '{model} 把全部输出额度用在了推理上,没有返回结果。推理模型在大差异上经常如此——可以换一个少推理的模型,或缩小评审范围。', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部输出额度用在了推理上,没有返回结果。推理模型在大差异上经常如此——可以换一个少推理的模型,或缩小评审范围。', + 'walkthrough.blocked.onlyGenerated.title': '只有生成文件发生了改动', + 'walkthrough.blocked.onlyGenerated.description': '这里的改动全部是锁文件或其他工具生成的产物,评审会有意跳过它们。', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大约可容纳 {available} 千字符,而这份差异约需 {required} 千字符。我们不会截断内容,请改选上下文更大的模型。', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支持导读所需的结构化响应。', 'contextRail.surface.plan.description': '查看当前计划', 'contextRail.surface.pr.description': '创建、审查并合并当前分支的拉取请求', 'contextRail.surface.notes.description': '项目的笔记、待办和计划', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 7b7886de..a7405089 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1485,6 +1485,10 @@ 'settings.openchamber.defaults.smallModel.useDefault': '使用預設小模型', 'settings.openchamber.defaults.smallModel.useDefaultAria': '使用預設小模型', 'settings.openchamber.defaults.smallModel.overrideModel': '覆寫模型', + 'settings.openchamber.defaults.walkthroughModel.title': '變更導讀模型', + 'settings.openchamber.defaults.walkthroughModel.description': 'AI 審閱變更需要結構化輸出與容納整份差異的空間,便宜的小模型常常做不到。目錄標示為不支援結構化輸出的模型不會出現在這份清單中。 未選擇時將使用小模型。', + 'settings.openchamber.defaults.walkthroughModel.overrideModel': '導讀模型', + 'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '小模型', 'settings.openchamber.defaults.field.showDeletionDialog': '顯示刪除對話方塊', 'settings.openchamber.defaults.field.openFilesPreviewAria': '以預覽模式開啟可預覽檔案', 'settings.openchamber.defaults.field.openFilesPreview': '以預覽模式開啟可預覽檔案', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index a48f4f65..2615292a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -15,6 +15,7 @@ export const dict: Record = { 'common.loading': '載入中...', 'common.unavailable': '無法使用', 'common.language.english': 'English', + 'common.language.german': '德語', 'common.language.french': '法語', 'common.language.simplifiedChinese': '簡體中文', 'common.language.traditionalChinese': '繁體中文', @@ -91,7 +92,6 @@ export const dict: Record = { 'mobile.nav.changes': '變更', 'mobile.nav.settings': '設定', 'mobile.surface.closeAria': '關閉', - 'mobile.header.openMenuAria': '開啟選單', 'mobile.header.openWorkspaceAria': '開啟工作區面板', 'mobile.header.openMetadataAria': '開啟工作階段中繼資料', 'mobile.header.metadata.context': '上下文', @@ -847,12 +847,15 @@ export const dict: Record = { 'gitView.operation.abort': '中止', 'gitView.operation.continue': '繼續', 'gitView.operation.inProgressTitle': '{operation} 進行中', + 'gitView.operation.inProgressTitleManyConflicts': '{operation} 進行中:{count} 個衝突', + 'gitView.operation.inProgressTitleOneConflict': '{operation} 進行中:{count} 個衝突', 'gitView.operation.merge': '合併', 'gitView.operation.mergeInProgressWithHead': '合併進行中({head})', 'gitView.operation.mergingMessage': '正在合併:{message}', 'gitView.operation.rebase': 'Rebase', 'gitView.operation.rebaseInProgress': 'Rebase 進行中', 'gitView.operation.rebasingOnto': '正在將 {headName} Rebase 到 {onto}', + 'gitView.operation.readyToContinueHint': '所有衝突已解決。繼續以完成操作。', 'gitView.operation.resolveConflictsHint': '解決衝突,然後繼續操作。', 'gitView.operation.resolveWithAi': '使用 AI 解決', 'gitView.stash.confirmButton': 'Stash 並 {operation}', @@ -1108,6 +1111,61 @@ export const dict: Record = { 'contextRail.surface.git.description': '提交、分支與拉取請求', 'contextRail.surface.terminal.description': '內建終端機', 'contextRail.surface.diff.description': '檢視工作區變更', + 'contextPanel.mode.walkthrough': '導讀', + 'contextRail.surface.walkthrough.description': '由 AI 引導的變更導讀', + 'walkthrough.scope.all': '全部未提交', + 'walkthrough.scope.group.workingTree': '工作區', + 'walkthrough.scope.group.committed': '已提交', + 'walkthrough.scope.staged': '已暫存', + 'walkthrough.scope.working': '未暫存', + 'walkthrough.scope.branch': '目前分支', + 'walkthrough.scope.selectorAria': '選擇審閱範圍', + 'walkthrough.language.menuLabel': '導讀語言', + 'walkthrough.missing.language': '尚無{requested}導讀,目前顯示的是{shown}版本。', + 'walkthrough.missing.model': '尚無由 {model} 產生的導讀,目前顯示最近一次產生的版本。', + 'walkthrough.missing.languageAndModel': '尚無使用該語言與該模型產生的導讀,目前顯示最近一次產生的版本。', + 'walkthrough.language.selectorAria': '選擇導讀語言', + 'walkthrough.scope.pullRequest': 'PR #{number}', + 'walkthrough.action.generate': '產生導讀', + 'walkthrough.action.regenerate': '重新產生', + 'walkthrough.action.cancel': '取消', + 'walkthrough.action.next': '下一步', + 'walkthrough.action.previous': '上一步', + 'walkthrough.action.refresh': '重新整理', + 'walkthrough.action.open': '導讀', + 'walkthrough.stage.collecting': '正在收集變更', + 'walkthrough.stage.asking': '正在等待模型', + 'walkthrough.stage.assembling': '正在組裝導讀', + 'walkthrough.empty.title': '尚未有導讀', + 'walkthrough.empty.description': '為這些變更建立一條引導式閱讀路線。此操作會呼叫小模型並消耗 token,因此只在你主動要求時執行。', + 'walkthrough.stale.banner': '本次審閱之後程式碼有變動。過時的步驟:{count}', + 'walkthrough.stop.staleAll': '此步驟描述的程式碼已全部變動。', + 'walkthrough.stop.stalePartial': '此步驟描述的部分程式碼已變動。缺少的片段:{count}', + 'walkthrough.stop.staleShort': '已過時', + 'walkthrough.stop.noCode': '目前差異中沒有此步驟的程式碼。', + 'walkthrough.uncovered.title': '審閱未涵蓋的變更:{count}', + 'walkthrough.uncovered.description': '審閱認為這些變更較為例行。展開即可自行檢視。', + 'walkthrough.toc.moreFiles': '其他檔案:{count}', + 'walkthrough.toc.uncovered': '未涵蓋:{count}', + 'walkthrough.toc.resize': '調整目錄欄寬度', + 'walkthrough.importance.critical': '關鍵', + 'walkthrough.importance.context': '背景', + 'walkthrough.blocked.noModel.title': '沒有可用的小模型', + 'walkthrough.blocked.noModel.description': '請先登入模型供應商再產生審閱。', + 'walkthrough.blocked.emptyDiff.title': '沒有可審閱的內容', + 'walkthrough.blocked.emptyDiff.description': '此範圍目前沒有變更。', + 'walkthrough.blocked.contextTooSmall.title': '目前模型無法容納這份差異', + 'walkthrough.blocked.contextTooSmall.description': '{model} 大約可容納 {available} 千字元,而這份差異約需 {required} 千字元。我們不會截斷內容,請改選上下文更大的模型。', + 'walkthrough.blocked.structuredOutput.title': '此模型不支援結構化輸出', + 'walkthrough.blocked.structuredOutput.description': '{model} 不支援導讀所需的結構化回應。', + 'walkthrough.blocked.chooseModel': '選擇小模型', + 'walkthrough.blocked.outputExhausted.title': '模型的回答額度已用完', + 'walkthrough.blocked.outputExhausted.description': '{model} 把全部輸出額度用在推理上,沒有回傳結果。推理模型在大型差異上經常如此——可以改用較少推理的模型,或縮小審閱範圍。', + 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部輸出額度用在推理上,沒有回傳結果。推理模型在大型差異上經常如此——可以改用較少推理的模型,或縮小審閱範圍。', + 'walkthrough.blocked.onlyGenerated.title': '只有產生的檔案有變動', + 'walkthrough.blocked.onlyGenerated.description': '這裡的變更全部是鎖定檔或其他工具產生的輸出,審閱會刻意略過它們。', + 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大約可容納 {available} 千字元,而這份差異約需 {required} 千字元。我們不會截斷內容,請改選上下文更大的模型。', + 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支援導讀所需的結構化回應。', 'contextRail.surface.plan.description': '檢視目前計畫', 'contextRail.surface.pr.description': '建立、審查並合併目前分支的提取請求', 'contextRail.surface.notes.description': '專案的筆記、待辦與計畫', diff --git a/packages/ui/src/lib/i18n/runtime.ts b/packages/ui/src/lib/i18n/runtime.ts index a80ad5a0..3fcd25ec 100644 --- a/packages/ui/src/lib/i18n/runtime.ts +++ b/packages/ui/src/lib/i18n/runtime.ts @@ -1,10 +1,10 @@ -export type Locale = 'en' | 'fr' | 'zh-CN' | 'zh-TW' | 'uk' | 'es' | 'pt-BR' | 'ko' | 'pl' | 'ja'; +export type Locale = 'en' | 'de' | 'fr' | 'zh-CN' | 'zh-TW' | 'uk' | 'es' | 'pt-BR' | 'ko' | 'pl' | 'ja'; -export const LOCALES = ['en', 'fr', 'zh-CN', 'zh-TW', 'uk', 'es', 'pt-BR', 'ko', 'pl', 'ja'] as const satisfies readonly Locale[]; +export const LOCALES = ['en', 'de', 'fr', 'zh-CN', 'zh-TW', 'uk', 'es', 'pt-BR', 'ko', 'pl', 'ja'] as const satisfies readonly Locale[]; export const DEFAULT_LOCALE: Locale = 'en'; -export const LOCALE_LABEL_KEYS: Record = { +export const LOCALE_LABEL_KEYS: Record = { en: 'common.language.english', fr: 'common.language.french', 'zh-CN': 'common.language.simplifiedChinese', @@ -14,6 +14,7 @@ export const LOCALE_LABEL_KEYS: Record { ? await import('./messages/ko') as { dict: I18nDictionary } : locale === 'pl' ? await import('./messages/pl') as { dict: I18nDictionary } - : locale === 'ja' - ? await import('./messages/ja') as { dict: I18nDictionary } - : { dict: enDict }; + : locale === 'de' + ? await import('./messages/de') as { dict: I18nDictionary } + : locale === 'ja' + ? await import('./messages/ja') as { dict: I18nDictionary } + : { dict: enDict }; dictionaries.set(locale, mod.dict); return mod.dict; } diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index e89cddbf..5dcc40ab 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1169,6 +1169,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.smallModelOverride === 'string' && candidate.smallModelOverride.length > 0) { result.smallModelOverride = candidate.smallModelOverride; } + if (typeof candidate.walkthroughModelOverride === 'string' && candidate.walkthroughModelOverride.length > 0) { + result.walkthroughModelOverride = candidate.walkthroughModelOverride; + } if (typeof candidate.autoCreateWorktree === 'boolean') { result.autoCreateWorktree = candidate.autoCreateWorktree; } diff --git a/packages/ui/src/lib/preview/screenshot-capture.ts b/packages/ui/src/lib/preview/screenshot-capture.ts index 000802d7..19851c6d 100644 --- a/packages/ui/src/lib/preview/screenshot-capture.ts +++ b/packages/ui/src/lib/preview/screenshot-capture.ts @@ -1,5 +1,3 @@ -import { snapdom } from '@zumer/snapdom'; -import { getFontEmbedCSS, toJpeg } from 'html-to-image'; import { invokeDesktop } from '@/lib/desktop'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -826,6 +824,9 @@ async function captureIframeSnapdomScreenshot( // Defensive: undo any nested-scroll drift from asset inlining before capture. nestedScroll.reapply(); + // Lazy-load snapDOM: only needed when actually capturing a preview + // annotation screenshot, so keep it out of the eager app shell. + const { snapdom } = await import('@zumer/snapdom'); const snapdomOptions = { backgroundColor: getCaptureBackgroundColor(document), cache: 'disabled' as const, @@ -925,6 +926,8 @@ async function captureIframeDomScreenshot( await document.fonts?.ready.catch(() => undefined); restoreAssets = await inlineIframeCaptureAssets(document, viewportWidth, viewportHeight, { applyLayoutWorkarounds: true }); frameWindow.scrollTo(scrollX, scrollY); + // Lazy-load html-to-image: only the fallback DOM-capture path needs it. + const { getFontEmbedCSS, toJpeg } = await import('html-to-image'); const fontEmbedCSS = await getFontEmbedCSS(root).catch(() => ''); dataUrl = await toJpeg(root, { diff --git a/packages/ui/src/lib/sessionEvents.ts b/packages/ui/src/lib/sessionEvents.ts index c57434fa..331383b6 100644 --- a/packages/ui/src/lib/sessionEvents.ts +++ b/packages/ui/src/lib/sessionEvents.ts @@ -17,7 +17,7 @@ export type SessionCreateRequest = { type DeleteListener = (request: SessionDeleteRequest) => void; type CreateListener = (request: SessionCreateRequest) => void; type DirectoryListener = () => void; -type GitRefreshHint = { directory: string }; +type GitRefreshHint = { directory: string; paths?: string[] }; type GitRefreshListener = (hint: GitRefreshHint) => void; const deleteListeners = new Set(); diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 556742bc..eecca22c 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -378,6 +378,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ descriptionKey: 'settings.openchamber.defaults.smallModel.description', keywords: ['small model', 'utility', 'summary', 'recap', 'cheap', 'override'], }, + { + id: 'sessions.walkthrough-model', + page: 'sessions', + titleKey: 'settings.openchamber.defaults.walkthroughModel.title', + descriptionKey: 'settings.openchamber.defaults.walkthroughModel.description', + keywords: ['walkthrough', 'diff', 'review', 'changes', 'structured output', 'model', 'override'], + }, { id: 'sessions.auto-cleanup', page: 'sessions', diff --git a/packages/ui/src/lib/surfaces/registry.ts b/packages/ui/src/lib/surfaces/registry.ts index 57aa8744..0be406bb 100644 --- a/packages/ui/src/lib/surfaces/registry.ts +++ b/packages/ui/src/lib/surfaces/registry.ts @@ -7,6 +7,7 @@ export type ContextSurfaceId = | 'git' | 'pr' | 'diff' + | 'walkthrough' | 'terminal' | 'plan' | 'notes' @@ -61,7 +62,7 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [ descriptionKey: 'contextRail.surface.pr.description', defaultWidthFraction: 0.45, mode: 'pr', - icon: 'git-pull-request', + icon: 'github', labelKey: 'contextPanel.mode.pr', availability: 'always', }, @@ -74,12 +75,21 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [ labelKey: 'contextPanel.mode.diff', availability: 'always', }, + { + id: 'walkthrough', + descriptionKey: 'contextRail.surface.walkthrough.description', + defaultWidthFraction: 3 / 5, + mode: 'walkthrough', + icon: 'route', + labelKey: 'contextPanel.mode.walkthrough', + availability: 'always', + }, { id: 'editor', descriptionKey: 'contextRail.surface.editor.description', defaultWidthFraction: 3 / 5, mode: 'file', - icon: 'file-code', + icon: 'braces', labelKey: 'contextPanel.mode.files', availability: 'always', }, diff --git a/packages/ui/src/lib/tabletLayout.test.ts b/packages/ui/src/lib/tabletLayout.test.ts new file mode 100644 index 00000000..1881b1a0 --- /dev/null +++ b/packages/ui/src/lib/tabletLayout.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, test } from 'bun:test'; + +import { readTabletLayout, type TabletLayout } from './device'; + +// No module mocking here on purpose: mock.module is process-global and would +// leak into every other test file. Outside a Capacitor shell isIPadApp() is +// already false, so a bare viewport stub isolates the geometry rules. +const originalWindow = globalThis.window; + +const setViewport = (width: number, height: number) => { + (globalThis as { window?: unknown }).window = { + innerWidth: width, + innerHeight: height, + // isIPadApp() reaches for the Capacitor markers; a plain web location + // keeps it on its `false` path without mocking the module. + location: { protocol: 'https:', search: '' }, + }; +}; + +const withViewport = (width: number, height: number): TabletLayout => { + setViewport(width, height); + return readTabletLayout(); +}; + +afterEach(() => { + (globalThis as { window?: unknown }).window = originalWindow; +}); + +describe('readTabletLayout', () => { + test('a phone stays a phone in both orientations', () => { + expect(withViewport(390, 844).enabled).toBe(false); + // The long side alone must never qualify — this is the case a plain + // width threshold gets wrong. + expect(withViewport(844, 390).enabled).toBe(false); + }); + + test('a tablet qualifies in both orientations', () => { + expect(withViewport(834, 1194).enabled).toBe(true); + expect(withViewport(1194, 834).enabled).toBe(true); + }); + + test('side panels need real width, so a tablet in portrait keeps the drawer', () => { + expect(withViewport(834, 1194).roomyForPanels).toBe(false); + expect(withViewport(1194, 834).roomyForPanels).toBe(true); + }); + + test('an unfolded foldable is a tablet but never roomy enough for panels', () => { + // Book foldables are near-square: the long side is barely wider than a + // tablet's short one, so both orientations keep the portrait layout. + expect(withViewport(690, 840)).toEqual({ enabled: true, roomyForPanels: false }); + expect(withViewport(840, 690)).toEqual({ enabled: true, roomyForPanels: false }); + }); + + test('folding shut drops back to the phone layout', () => { + expect(withViewport(370, 900).enabled).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/theme/themes/openchamber-light.json b/packages/ui/src/lib/theme/themes/openchamber-light.json index ec46ddea..50b9780e 100644 --- a/packages/ui/src/lib/theme/themes/openchamber-light.json +++ b/packages/ui/src/lib/theme/themes/openchamber-light.json @@ -131,9 +131,9 @@ } }, "markdown": { - "heading1": "#1e754f", - "heading2": "#1e754f", - "heading3": "#1e754f", + "heading1": "#393a34", + "heading2": "#393a34", + "heading3": "#393a34", "heading4": "#393a34", "link": "#2e808f", "linkHover": "#296aa3", @@ -145,7 +145,7 @@ }, "chat": { "userMessage": "#393a34", - "userMessageBackground": "#fbf4f0", + "userMessageBackground": "#f7f2ee", "assistantMessage": "#393a34", "assistantMessageBackground": "#fdfcfa", "timestamp": "#6b6b63", diff --git a/packages/ui/src/lib/walkthrough/api.ts b/packages/ui/src/lib/walkthrough/api.ts new file mode 100644 index 00000000..344bf853 --- /dev/null +++ b/packages/ui/src/lib/walkthrough/api.ts @@ -0,0 +1,108 @@ +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { + WalkthroughError, + type WalkthroughResult, + type WalkthroughSource, + type WalkthroughStage, +} from './types'; + +const BASE = '/api/walkthrough'; + +interface ErrorPayload { + error?: unknown; + code?: unknown; + model?: unknown; + requiredChars?: unknown; + availableChars?: unknown; +} + +// An authoritative read that fails must never look like "there is nothing +// here" — the caller would clear a perfectly good walkthrough off the screen. +const throwFromResponse = async (response: Response, fallback: string): Promise => { + const payload = (await response.json().catch(() => null)) as ErrorPayload | null; + throw new WalkthroughError(typeof payload?.error === 'string' ? payload.error : fallback, { + code: typeof payload?.code === 'string' ? (payload.code as WalkthroughError['code']) : undefined, + model: (payload?.model as WalkthroughResult['model']) ?? undefined, + requiredChars: typeof payload?.requiredChars === 'number' ? payload.requiredChars : undefined, + availableChars: typeof payload?.availableChars === 'number' ? payload.availableChars : undefined, + }); +}; + +export async function fetchWalkthrough( + directory: string, + source: WalkthroughSource, + options: { model?: string; language?: string; signal?: AbortSignal } = {} +): Promise { + const response = await runtimeFetch(BASE, { + query: { + directory, + source: JSON.stringify(source), + ...(options.model ? { model: options.model } : {}), + ...(options.language ? { language: options.language } : {}), + }, + signal: options.signal, + }); + if (!response.ok) { + return throwFromResponse(response, 'Failed to load walkthrough'); + } + return response.json(); +} + +export async function generateWalkthrough( + directory: string, + source: WalkthroughSource, + options: { force?: boolean; model?: string; language?: string; signal?: AbortSignal } = {} +): Promise { + const response = await runtimeFetch(`${BASE}/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + directory, + source, + force: options.force === true, + ...(options.model ? { model: options.model } : {}), + ...(options.language ? { language: options.language } : {}), + }), + signal: options.signal, + }); + if (!response.ok) { + return throwFromResponse(response, 'Failed to generate walkthrough'); + } + return response.json(); +} + +/** + * Stop a running generation. Explicit, because merely leaving the page must not + * throw away work the user is paying for. + */ +export async function cancelWalkthroughGeneration( + directory: string, + source: WalkthroughSource +): Promise { + const response = await runtimeFetch(`${BASE}/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory, source }), + }); + if (!response.ok) { + await throwFromResponse(response, 'Failed to cancel walkthrough generation'); + } +} + +/** + * Current stage of a running generation. Reads server memory only, so this is + * safe to poll — unlike the full read, which re-runs the whole git pipeline. + */ +export async function fetchWalkthroughStage( + directory: string, + source: WalkthroughSource, + signal?: AbortSignal +): Promise { + const response = await runtimeFetch(`${BASE}/progress`, { + query: { directory, source: JSON.stringify(source) }, + signal, + }); + if (!response.ok) return null; + const payload = (await response.json().catch(() => null)) as { stage?: unknown } | null; + return typeof payload?.stage === 'string' ? (payload.stage as WalkthroughStage) : null; +} diff --git a/packages/ui/src/lib/walkthrough/model.test.ts b/packages/ui/src/lib/walkthrough/model.test.ts new file mode 100644 index 00000000..8492fc12 --- /dev/null +++ b/packages/ui/src/lib/walkthrough/model.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test as it } from 'bun:test'; +import { buildWalkthroughView, groupHunksByFile, mergeRunPatch, summarizeHunkFiles } from './model'; +import type { WalkthroughHunk, WalkthroughResult } from './types'; + +const hunk = (id: string, path: string, overrides: Partial = {}): WalkthroughHunk => ({ + id, + path, + oldPath: null, + status: 'modified', + scope: 'working', + header: '@@ -1,2 +1,3 @@', + newStart: 1, + added: 1, + deleted: 0, + patch: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1,2 +1,3 @@\n+line\n`, + ...overrides, +}); + +const result = (overrides: Partial = {}): WalkthroughResult => ({ + source: { kind: 'working-tree', scope: 'all' }, + walkthrough: { + title: 'Change', + focus: 'why', + chapters: [ + { + id: 'chapter-1', + title: 'Data', + icon: 'doc', + blurb: '', + stops: [ + { id: 'stop-1-1', title: 'First', hunkIds: ['a', 'b'], importance: 'critical', prose: 'p1' }, + { id: 'stop-1-2', title: 'Second', hunkIds: ['c'], importance: 'normal', prose: 'p2' }, + ], + }, + ], + }, + hunks: [hunk('a', 'src/a.ts'), hunk('b', 'src/a.ts'), hunk('c', 'src/b.ts')], + hunkCount: 3, + ...overrides, +}); + +describe('buildWalkthroughView', () => { + it('resolves stops and numbers them globally', () => { + const view = buildWalkthroughView(result())!; + + expect(view.stops).toHaveLength(2); + expect(view.stops.map((stop) => stop.position)).toEqual([1, 2]); + expect(view.stops[0].hunks.map((h) => h.id)).toEqual(['a', 'b']); + expect(view.isStale).toBe(false); + expect(view.uncoveredHunks).toEqual([]); + }); + + it('marks only the stops whose code changed', () => { + const view = buildWalkthroughView(result({ + hunks: [hunk('a', 'src/a.ts'), hunk('c', 'src/b.ts')], + }))!; + + expect(view.stops[0].isStale).toBe(true); + expect(view.stops[0].missingHunkIds).toEqual(['b']); + expect(view.stops[0].hunks.map((h) => h.id)).toEqual(['a']); + expect(view.stops[1].isStale).toBe(false); + expect(view.staleStopCount).toBe(1); + expect(view.isStale).toBe(true); + }); + + it('surfaces hunks no stop covers instead of dropping them', () => { + const view = buildWalkthroughView(result({ + hunks: [hunk('a', 'src/a.ts'), hunk('b', 'src/a.ts'), hunk('c', 'src/b.ts'), hunk('d', 'src/c.ts')], + }))!; + + expect(view.uncoveredHunks.map((h) => h.id)).toEqual(['d']); + }); + + it('returns null without a walkthrough', () => { + expect(buildWalkthroughView(null)).toBeNull(); + expect(buildWalkthroughView(result({ walkthrough: null }))).toBeNull(); + }); +}); + +describe('groupHunksByFile', () => { + it('coalesces consecutive hunks from the same file only', () => { + const runs = groupHunksByFile([ + hunk('a', 'src/a.ts'), + hunk('b', 'src/a.ts'), + hunk('c', 'src/b.ts'), + hunk('d', 'src/a.ts'), + ]); + + expect(runs.map((run) => [run.path, run.hunks.length])).toEqual([ + ['src/a.ts', 2], + ['src/b.ts', 1], + ['src/a.ts', 1], + ]); + }); +}); + +describe('mergeRunPatch', () => { + it('keeps a single patch untouched', () => { + const single = hunk('a', 'src/a.ts'); + expect(mergeRunPatch([single])).toBe(single.patch); + }); + + it('joins hunks under one file header', () => { + const first = hunk('a', 'src/a.ts'); + const second = hunk('b', 'src/a.ts', { + patch: 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -10,2 +11,3 @@\n+second\n', + }); + + const merged = mergeRunPatch([first, second]); + + expect(merged.match(/^diff --git/gm)).toHaveLength(1); + expect(merged.match(/^@@/gm)).toHaveLength(2); + expect(merged).toContain('+line'); + expect(merged).toContain('+second'); + }); + + it('returns nothing for an empty run', () => { + expect(mergeRunPatch([])).toBe(''); + }); +}); + +describe('summarizeHunkFiles', () => { + it('totals per file in first-appearance order', () => { + const files = summarizeHunkFiles([ + hunk('a', 'src/b.ts', { added: 2, deleted: 1 }), + hunk('b', 'src/a.ts', { added: 1, deleted: 0 }), + hunk('c', 'src/b.ts', { added: 3, deleted: 4 }), + ]); + + expect(files).toEqual([ + { path: 'src/b.ts', added: 5, deleted: 5 }, + { path: 'src/a.ts', added: 1, deleted: 0 }, + ]); + }); +}); diff --git a/packages/ui/src/lib/walkthrough/model.ts b/packages/ui/src/lib/walkthrough/model.ts new file mode 100644 index 00000000..ed84f626 --- /dev/null +++ b/packages/ui/src/lib/walkthrough/model.ts @@ -0,0 +1,151 @@ +import type { + Walkthrough, + WalkthroughChapter, + WalkthroughHunk, + WalkthroughResult, + WalkthroughStop, +} from './types'; + +/** + * Flattens a walkthrough plus the current hunk index into the ordered stream + * the view renders: every stop with its resolved hunks, followed by everything + * the walkthrough did not cover. + * + * Resolution is pure id matching — the server owns hunk identity, so a stop + * whose hunks are missing is not a bug to paper over, it is a stop whose code + * has changed. + */ + +export interface WalkthroughStopView { + stop: WalkthroughStop; + chapter: WalkthroughChapter; + chapterIndex: number; + /** Global 1-based position, used for the stepper and keyboard navigation. */ + position: number; + hunks: WalkthroughHunk[]; + /** Anchors that no longer resolve: their code changed or was removed. */ + missingHunkIds: string[]; + isStale: boolean; +} + +export interface WalkthroughView { + walkthrough: Walkthrough; + stops: WalkthroughStopView[]; + chapters: Array<{ chapter: WalkthroughChapter; stops: WalkthroughStopView[] }>; + /** Current hunks no stop covers. Never dropped — rendered as a collapsed tail. */ + uncoveredHunks: WalkthroughHunk[]; + staleStopCount: number; + isStale: boolean; +} + +export const buildWalkthroughView = (result: WalkthroughResult | null): WalkthroughView | null => { + if (!result?.walkthrough) return null; + + const index = new Map(result.hunks.map((hunk) => [hunk.id, hunk])); + const covered = new Set(); + const stops: WalkthroughStopView[] = []; + const chapters: WalkthroughView['chapters'] = []; + + for (const [chapterIndex, chapter] of result.walkthrough.chapters.entries()) { + const chapterStops: WalkthroughStopView[] = []; + + for (const stop of chapter.stops) { + const hunks: WalkthroughHunk[] = []; + const missingHunkIds: string[] = []; + + for (const id of stop.hunkIds) { + const hunk = index.get(id); + if (hunk) { + hunks.push(hunk); + covered.add(id); + } else { + missingHunkIds.push(id); + } + } + + const view: WalkthroughStopView = { + stop, + chapter, + chapterIndex, + position: stops.length + 1, + hunks, + missingHunkIds, + isStale: missingHunkIds.length > 0, + }; + stops.push(view); + chapterStops.push(view); + } + + chapters.push({ chapter, stops: chapterStops }); + } + + return { + walkthrough: result.walkthrough, + stops, + chapters, + uncoveredHunks: result.hunks.filter((hunk) => !covered.has(hunk.id)), + staleStopCount: stops.filter((stop) => stop.isStale).length, + isStale: stops.some((stop) => stop.isStale), + }; +}; + +/** + * Files touched by a set of hunks, in first-appearance order, with their + * per-file totals. Used for the table of contents rows. + */ +export const summarizeHunkFiles = ( + hunks: WalkthroughHunk[] +): Array<{ path: string; added: number; deleted: number }> => { + const byPath = new Map(); + for (const hunk of hunks) { + const existing = byPath.get(hunk.path); + if (existing) { + existing.added += hunk.added; + existing.deleted += hunk.deleted; + continue; + } + byPath.set(hunk.path, { path: hunk.path, added: hunk.added, deleted: hunk.deleted }); + } + return [...byPath.values()]; +}; + +/** + * Consecutive hunks from the same file are rendered as one diff block so the + * reader sees continuous code instead of a stack of one-hunk cards. + */ +export const groupHunksByFile = ( + hunks: WalkthroughHunk[] +): Array<{ path: string; hunks: WalkthroughHunk[] }> => { + const runs: Array<{ path: string; hunks: WalkthroughHunk[] }> = []; + for (const hunk of hunks) { + const last = runs.at(-1); + if (last && last.path === hunk.path) { + last.hunks.push(hunk); + continue; + } + runs.push({ path: hunk.path, hunks: [hunk] }); + } + return runs; +}; + +/** + * Merge a file's hunk patches back into one patch so a run renders as a single + * diff. All hunks in a run share a file header, so only the first one's header + * is kept. + */ +export const mergeRunPatch = (hunks: WalkthroughHunk[]): string => { + if (hunks.length === 0) return ''; + if (hunks.length === 1) return hunks[0].patch; + + const first = hunks[0].patch; + const headerEnd = first.indexOf('\n@@'); + if (headerEnd === -1) return first; + + const header = first.slice(0, headerEnd); + const bodies = hunks.map((hunk) => { + const start = hunk.patch.indexOf('\n@@'); + return start === -1 ? '' : hunk.patch.slice(start + 1); + }); + + return `${header}\n${bodies.join('')}`; +}; diff --git a/packages/ui/src/lib/walkthrough/types.ts b/packages/ui/src/lib/walkthrough/types.ts new file mode 100644 index 00000000..8c6b5a23 --- /dev/null +++ b/packages/ui/src/lib/walkthrough/types.ts @@ -0,0 +1,137 @@ +/** + * Contract for the AI diff walkthrough, mirrored from + * `packages/web/server/lib/walkthrough`. + * + * Hunk ids are opaque here on purpose: the server owns how they are derived, + * and the client only ever matches them against the index it is handed. + */ + +export type WalkthroughWorkingTreeScope = 'all' | 'staged' | 'working'; + +export type WalkthroughSource = + | { kind: 'working-tree'; scope: WalkthroughWorkingTreeScope } + | { kind: 'branch'; baseRef: string; headRef: string } + | { kind: 'pr'; number: number }; + +export type WalkthroughChapterIcon = 'bug' | 'wrench' | 'path' | 'flask' | 'doc' | 'gear'; +export type WalkthroughStopImportance = 'critical' | 'normal' | 'context'; + +export interface WalkthroughStop { + id: string; + title: string; + hunkIds: string[]; + importance: WalkthroughStopImportance; + prose: string; +} + +export interface WalkthroughChapter { + id: string; + title: string; + icon: WalkthroughChapterIcon; + blurb: string; + stops: WalkthroughStop[]; +} + +export interface Walkthrough { + title: string; + focus: string; + chapters: WalkthroughChapter[]; +} + +export interface WalkthroughHunk { + id: string; + path: string; + oldPath: string | null; + status: 'added' | 'deleted' | 'modified' | 'renamed'; + scope: string; + header: string; + newStart: number; + added: number; + deleted: number; + /** Standalone patch for this hunk, including the file header. */ + patch: string; +} + +export interface WalkthroughModel { + providerID: string; + modelID: string; + source?: string; +} + +export interface WalkthroughResult { + source: WalkthroughSource; + walkthrough: Walkthrough | null; + model?: WalkthroughModel; + /** + * Language the prose on screen is written in — not necessarily the one being + * asked for now. Null for an entry written before the setting existed. + */ + language?: string | null; + generatedAt?: string; + fromCache?: boolean; + hunks: WalkthroughHunk[]; + hunkCount: number; + /** True when at least one stop points at code that has since changed. */ + isStale?: boolean; + missingHunkIds?: string[]; + staleStopIds?: string[]; + /** Hunks in the current diff that no stop covers. Rendered as a tail. */ + uncoveredHunkIds?: string[]; + /** Whether generating is possible at all, computed from the same diff. */ + readiness?: WalkthroughReadiness; + /** A generation is already running on the server for this source. */ + generating?: boolean; +} + +/** + * Only phases a person can wait on. Building the digest and reading the cache + * take milliseconds; naming them would imply progress that is not happening. + */ +export type WalkthroughStage = 'collecting' | 'asking' | 'retrying' | 'assembling'; + +export type WalkthroughBlockedReason = + | 'no-model' + | 'empty-diff' + | 'only-generated' + | 'context-too-small' + | 'structured-output-unsupported' + | 'output-exhausted'; + +export interface WalkthroughReadiness { + ready: boolean; + reason?: WalkthroughBlockedReason; + model?: WalkthroughModel & { + inputCharBudget?: number; + contextTokens?: number; + structuredOutput?: boolean | null; + }; + requiredChars?: number; + availableChars?: number; + hunkCount?: number; + fileCount?: number; + generatedFileCount?: number; +} + +export class WalkthroughError extends Error { + readonly code?: WalkthroughBlockedReason | 'invalid-walkthrough' | 'github-not-connected' | 'no-github-remote'; + readonly model?: WalkthroughModel; + readonly requiredChars?: number; + readonly availableChars?: number; + + constructor( + message: string, + details: { + code?: WalkthroughError['code']; + model?: WalkthroughModel; + requiredChars?: number; + availableChars?: number; + } = {} + ) { + super(message); + this.name = 'WalkthroughError'; + this.code = details.code; + this.model = details.model; + this.requiredChars = details.requiredChars; + this.availableChars = details.availableChars; + } +} diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 045fa43f..bca5d525 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -56,6 +56,7 @@ User-visible session ordering is also not owned by the global cache array order. Global refresh rules: +- The OpenCode `archived` list flag means "also include archived sessions": the server only drops its `time_archived IS NULL` condition. `listGlobalSessionPages` therefore narrows archived requests to records carrying `time.archived`, at the data boundary, so the archived cache never holds active sessions and no consumer has to re-derive that. Pagination progress stays measured on the raw response, so a page that is full upstream but filtered out here is not mistaken for the last page. - Per-directory refresh is bounded to two requests across callers and prioritizes the current directory. - Each directory is an independent completeness scope. A failed directory preserves its previous sessions while successful directories reconcile normally. - Fetch failure must remain distinguishable from a successful empty list; failed scopes cannot destructively clear cached sessions. @@ -251,7 +252,10 @@ Expected model: - `GitView` / `DiffView` ensure current-directory Git state when visible - explicit Git actions refresh status/branches/log as needed -- successful file-mutating tools can issue a one-shot Git refresh hint +- a mounted file-mutating tool issues a one-shot Git refresh hint when it transitions from active to successfully finalized; remounting historical completed tools does not replay the hint +- a successful dirty save from the in-app file editor issues a path-scoped Git refresh hint; clean autosave checks remain no-ops +- refresh hints with authoritative file paths invalidate only those cached and currently rendered diffs before status refresh; pathless tools request status reconciliation without broadly remounting DiffView +- targeted diff remounts preserve the user's current file-section anchor and intra-file offset before paint instead of resetting the stacked view to the top - no root-level background Git polling ### PR diff --git a/packages/ui/src/stores/globalSessions.test.ts b/packages/ui/src/stores/globalSessions.test.ts index 118a94b4..1319568d 100644 --- a/packages/ui/src/stores/globalSessions.test.ts +++ b/packages/ui/src/stores/globalSessions.test.ts @@ -96,6 +96,164 @@ describe('listGlobalSessionPages', () => { expect(sessions.map((session) => session.id)).toEqual(['ses_root', 'ses_child_1', 'ses_child_2']) }) + test('returns only archived sessions when archived pages are requested', async () => { + const apiClient = { + experimental: { + session: { + list: async () => ({ + // The server treats `archived: true` as "include archived", so the + // response mixes active and archived records. + data: [ + { id: 'ses_active', time: { created: 1, updated: 20 } }, + { id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } }, + ], + response: { headers: new Headers() }, + }), + }, + }, + } as unknown as OpencodeClient + + const sessions = await listGlobalSessionPages(apiClient, { archived: true, pageSize: 500 }) + + expect(sessions.map((session) => session.id)).toEqual(['ses_archived']) + }) + + test('keeps every record when active pages are requested', async () => { + const apiClient = { + experimental: { + session: { + list: async () => ({ + data: [ + { id: 'ses_active_1', time: { updated: 20 } }, + { id: 'ses_active_2', time: { updated: 10 } }, + ], + response: { headers: new Headers() }, + }), + }, + }, + } as unknown as OpencodeClient + + const sessions = await listGlobalSessionPages(apiClient, { archived: false, pageSize: 500 }) + + expect(sessions.map((session) => session.id)).toEqual(['ses_active_1', 'ses_active_2']) + }) + + test('keeps paginating archived pages that are full of non-archived records', async () => { + const calls: Array> = [] + const apiClient = { + experimental: { + session: { + list: async (options: Record) => { + calls.push(options) + if (options.cursor === undefined) { + return { + data: [ + { id: 'ses_active_1', time: { updated: 30 } }, + { id: 'ses_active_2', time: { updated: 20 } }, + ], + response: { headers: new Headers({ 'x-next-cursor': '20' }) }, + } + } + return { + data: [ + { id: 'ses_archived', time: { updated: 10, archived: 12 } }, + ], + response: { headers: new Headers() }, + } + }, + }, + }, + } as unknown as OpencodeClient + + const sessions = await listGlobalSessionPages(apiClient, { archived: true, pageSize: 2 }) + + // A page that is full upstream but fully filtered out here must not be + // mistaken for the last page: pagination progress is measured on the raw + // response, not on the accepted records. + expect(calls).toHaveLength(2) + expect(sessions.map((session) => session.id)).toEqual(['ses_archived']) + }) + + test('reports only accepted records to onPage for archived pages', async () => { + const pages: string[][] = [] + const apiClient = { + experimental: { + session: { + list: async () => ({ + data: [ + { id: 'ses_active', time: { updated: 20 } }, + { id: 'ses_archived', time: { updated: 10, archived: 12 } }, + ], + response: { headers: new Headers() }, + }), + }, + }, + } as unknown as OpencodeClient + + await listGlobalSessionPages(apiClient, { + archived: true, + pageSize: 500, + onPage: (sessions) => pages.push(sessions.map((session) => session.id)), + }) + + expect(pages).toEqual([['ses_archived']]) + }) + + test('does not notify onPage for an archived page with no archived records', async () => { + const pages: string[][] = [] + const apiClient = { + experimental: { + session: { + list: async () => ({ + data: [{ id: 'ses_active', time: { updated: 20 } }], + response: { headers: new Headers() }, + }), + }, + }, + } as unknown as OpencodeClient + + const sessions = await listGlobalSessionPages(apiClient, { + archived: true, + pageSize: 500, + onPage: (page) => pages.push(page.map((session) => session.id)), + }) + + expect(sessions).toEqual([]) + expect(pages).toEqual([]) + }) + + test('dedupes archived records by id and stops when a page repeats known ids', async () => { + const calls: Array> = [] + const page = [ + { id: 'ses_archived_1', time: { updated: 30, archived: 31 } }, + { id: 'ses_archived_2', time: { updated: 20, archived: 21 } }, + ] + const apiClient = { + experimental: { + session: { + list: async (options: Record) => { + calls.push(options) + return { + data: page, + response: { + headers: new Headers({ + 'x-next-cursor': options.cursor === undefined ? '20' : '10', + }), + }, + } + }, + }, + }, + } as unknown as OpencodeClient + + const sessions = await listGlobalSessionPages(apiClient, { archived: true, pageSize: 2 }) + + // The second page repeats ids already seen, so the dedupe guard stops the + // loop and no record is returned twice. + expect(calls).toHaveLength(2) + expect(sessions.map((session) => session.id)).toEqual(['ses_archived_1', 'ses_archived_2']) + }) + test('retries SDK error responses before treating the load as failed', async () => { let calls = 0 const apiClient = { diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts index 4a6b3bc1..f27a825e 100644 --- a/packages/ui/src/stores/globalSessions.ts +++ b/packages/ui/src/stores/globalSessions.ts @@ -75,6 +75,15 @@ const unwrapSessionList = ( return result.data as GlobalSessionRecord[]; }; +/** + * OpenCode's `archived` query flag means "also include archived sessions", not + * "return only archived sessions": the server simply drops its + * `time_archived IS NULL` condition. Callers that ask for the archived list + * expect archived-only records, so narrow the response here, at the data + * boundary, instead of leaving every consumer to re-derive it. + */ +const isArchivedSession = (session: GlobalSessionRecord): boolean => Boolean(session.time?.archived); + export async function listGlobalSessionPages( apiClient: OpencodeClient, options: { @@ -131,15 +140,22 @@ export async function listGlobalSessionPages( }); if (payload.length === 0) break; + // `appended` tracks pagination progress over the raw response, while + // `accepted` holds the records this call actually returns. Filtering + // must not feed the pagination guards below, otherwise a page that is + // full upstream but mostly non-archived would look like a last page. let appended = 0; + const accepted: GlobalSessionRecord[] = []; for (const session of payload) { if (!session?.id || seenIds.has(session.id)) continue; seenIds.add(session.id); - all.push(session); appended += 1; + if (options.archived && !isArchivedSession(session)) continue; + all.push(session); + accepted.push(session); } - if (appended > 0) { - options.onPage?.(payload); + if (accepted.length > 0) { + options.onPage?.(accepted); } // Stop on partial page — nothing more to fetch. diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index a8c1d3f4..4fe82ee2 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -441,6 +441,7 @@ interface ModelsDevModelEntry { reasoning?: boolean; temperature?: boolean; attachment?: boolean; + structured_output?: boolean; modalities?: { input?: string[]; output?: string[]; @@ -577,6 +578,8 @@ const transformModelsDevResponse = (payload: unknown): Map { expect(useGitStore.getState().getDiff('/repo', 'stale.ts')).toBe(null); }); + test('clears cached file contents when a git refresh hint invalidates diffs', () => { + setDirectoryStatus(createStatus( + { 'src/index.ts': { insertions: 1, deletions: 1 } }, + [{ path: 'src/index.ts', index: ' ', working_dir: 'M' }], + )); + useGitStore.getState().setDiff('/repo', 'src/index.ts', { original: 'old', modified: 'stale' }); + + useGitStore.getState().clearDiffCache('/repo'); + + expect(useGitStore.getState().getDiff('/repo', 'src/index.ts')).toBe(null); + }); + + test('invalidates only the requested cached file contents', () => { + setDirectoryStatus(createStatus(undefined, [ + { path: 'src/first.ts', index: ' ', working_dir: 'M' }, + { path: 'src/second.ts', index: ' ', working_dir: 'M' }, + ])); + useGitStore.getState().setDiff('/repo', 'src/first.ts', { original: 'a', modified: 'b' }); + useGitStore.getState().setDiff('/repo', 'src/second.ts', { original: 'c', modified: 'd' }); + + useGitStore.getState().clearDiffCache('/repo', ['src/first.ts']); + + expect(useGitStore.getState().getDiff('/repo', 'src/first.ts')).toBe(null); + expect(useGitStore.getState().getDiff('/repo', 'src/second.ts')?.modified).toBe('d'); + }); + test('keeps the newest branch request when completions are reversed', async () => { const requests = [createDeferred>>(), createDeferred>>()]; let index = 0; diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index d066f1e8..aec942ba 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -71,7 +71,7 @@ interface GitStore { getDiff: (directory: string, filePath: string) => { original: string; modified: string; fetchedAt: number; isBinary?: boolean } | null; setDiff: (directory: string, filePath: string, diff: { original: string; modified: string; isBinary?: boolean }, expectedRuntimeKey?: string) => void; - clearDiffCache: (directory: string) => void; + clearDiffCache: (directory: string, filePaths?: string[]) => void; fetchAllDiffs: (directory: string, git: GitAPI) => Promise; prefetchDiffs: (directory: string, git: GitAPI, filePaths: string[], options?: { maxFiles?: number }) => Promise; @@ -974,15 +974,25 @@ export const useGitStore = create()( set({ directories: evictGlobalDiffCachesIfNeeded(newDirectories) }); }, - clearDiffCache: (directory) => { + clearDiffCache: (directory, filePaths) => { bumpDiffFetchGeneration(directory); startRequest(directory, 'diff'); const newDirectories = new Map(get().directories); const dirState = newDirectories.get(directory); - if (dirState) { - newDirectories.set(directory, { ...dirState, diffCache: new Map() }); - set({ directories: newDirectories }); + if (!dirState || dirState.diffCache.size === 0) return; + + const nextDiffCache = new Map(dirState.diffCache); + if (filePaths) { + for (const filePath of filePaths) { + nextDiffCache.delete(filePath); + } + } else { + nextDiffCache.clear(); } + if (nextDiffCache.size === dirState.diffCache.size) return; + + newDirectories.set(directory, { ...dirState, diffCache: nextDiffCache }); + set({ directories: newDirectories }); }, fetchAllDiffs: async (directory, git) => { diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 12914aee..d00bb6ff 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -14,7 +14,7 @@ import { isWindowsArm64 } from '@/lib/platform'; export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram'; export type PendingDiffScope = 'working' | 'staged' | 'turn'; -export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal'; +export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal'; export type MermaidRenderingMode = 'svg' | 'ascii'; export type UserMessageRenderingMode = 'markdown' | 'plain'; export type ChatRenderMode = 'sorted' | 'live'; @@ -288,7 +288,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => { touchedAt?: unknown; }; - if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') { + if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') { continue; } @@ -654,6 +654,8 @@ interface UIStore { diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side'; diffFileLayout: Record; diffWrapLines: boolean; + /** Width of the walkthrough table of contents, in pixels. */ + walkthroughTocWidth: number; gitChangesViewMode: 'flat' | 'tree'; isTimelineDialogOpen: boolean; isPromptNavigatorPanelOpen: boolean; @@ -825,6 +827,7 @@ interface UIStore { setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void; setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void; setDiffWrapLines: (wrap: boolean) => void; + setWalkthroughTocWidth: (width: number) => void; setGitChangesViewMode: (mode: 'flat' | 'tree') => void; setMultiRunLauncherOpen: (open: boolean) => void; setTimelineDialogOpen: (open: boolean) => void; @@ -966,6 +969,7 @@ export const useUIStore = create()( diffLayoutPreference: 'inline', diffFileLayout: {}, diffWrapLines: false, + walkthroughTocWidth: 224, gitChangesViewMode: 'flat', isTimelineDialogOpen: false, isPromptNavigatorPanelOpen: false, @@ -1828,6 +1832,10 @@ export const useUIStore = create()( set({ diffWrapLines: wrap }); }, + setWalkthroughTocWidth: (width) => { + set({ walkthroughTocWidth: Math.round(width) }); + }, + setGitChangesViewMode: (mode) => { set({ gitChangesViewMode: mode }); }, @@ -2434,6 +2442,7 @@ export const useUIStore = create()( recentEfforts: state.recentEfforts, diffLayoutPreference: state.diffLayoutPreference, diffWrapLines: state.diffWrapLines, + walkthroughTocWidth: state.walkthroughTocWidth, gitChangesViewMode: state.gitChangesViewMode, nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, diff --git a/packages/ui/src/stores/useWalkthroughStore.test.ts b/packages/ui/src/stores/useWalkthroughStore.test.ts new file mode 100644 index 00000000..892d07de --- /dev/null +++ b/packages/ui/src/stores/useWalkthroughStore.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { WalkthroughResult, WalkthroughSource } from '@/lib/walkthrough/types'; + +const SOURCE: WalkthroughSource = { kind: 'working-tree', scope: 'all' }; + +const result = (overrides: Partial = {}): WalkthroughResult => ({ + source: SOURCE, + walkthrough: null, + hunks: [], + hunkCount: 0, + ...overrides, +}); + +const finished = result({ + walkthrough: { + title: 'Change', + focus: '', + chapters: [{ + id: 'chapter-1', + title: 'Data', + icon: 'doc', + blurb: '', + stops: [{ id: 'stop-1-1', title: 'A', hunkIds: ['h'], importance: 'normal', prose: 'p' }], + }], + }, + generatedAt: '2026-08-02T00:00:00.000Z', +}); + +// Plain closures rather than mock helpers: bun's `mock()` is not typed with +// vitest's `mockResolvedValue` family, and the repo already prefers this style. +let readResult: WalkthroughResult = result(); +let generateCalls = 0; +let releaseGeneration: (() => void) | undefined; +let lastReadModel: string | undefined; +let lastGenerateModel: string | undefined; +let lastReadLanguage: string | undefined; +let lastGenerateLanguage: string | undefined; + +mock.module('@/lib/walkthrough/api', () => ({ + fetchWalkthrough: async ( + _directory: string, + _source: WalkthroughSource, + options: { model?: string; language?: string } = {}, + ) => { + lastReadModel = options.model; + lastReadLanguage = options.language; + return readResult; + }, + generateWalkthrough: async ( + _directory: string, + _source: WalkthroughSource, + options: { model?: string; language?: string } = {}, + ) => { + generateCalls += 1; + lastGenerateModel = options.model; + lastGenerateLanguage = options.language; + return new Promise((resolve) => { + releaseGeneration = () => resolve(finished); + }); + }, + cancelWalkthroughGeneration: async () => {}, + // The store imports this for its progress poller. Leaving it out of the mock + // makes the whole module fail to load, which reads as an unrelated crash. + fetchWalkthroughStage: async () => null, +})); +mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'local' })); + +const { useWalkthroughStore } = await import('./useWalkthroughStore'); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('useWalkthroughStore — reattaching to a running generation', () => { + beforeEach(() => { + useWalkthroughStore.getState().reset(); + readResult = result(); + generateCalls = 0; + releaseGeneration = undefined; + }); + + afterEach(() => { + useWalkthroughStore.getState().reset(); + }); + + test('a reload that finds work in progress ends up showing the finished result', async () => { + // What a refresh looks like: the read says a job is running, and the + // generation the client re-attaches to finishes a moment later. + readResult = result({ generating: true }); + + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + + expect(generateCalls).toBe(1); + expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('generating'); + + releaseGeneration?.(); + await flush(); + + const entry = useWalkthroughStore.getState().getEntry('/repo', SOURCE); + expect(entry.status).toBe('ready'); + expect(entry.result?.walkthrough?.title).toBe('Change'); + }); + + test('does not re-attach when nothing is running', async () => { + readResult = result({ generating: false }); + + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + + expect(generateCalls).toBe(0); + expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('ready'); + }); + + test('a load while generating does not overwrite the pending state', async () => { + readResult = result({ generating: true }); + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + + expect(generateCalls).toBe(1); + expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('generating'); + }); +}); + +describe('useWalkthroughStore — model selection', () => { + beforeEach(() => { + useWalkthroughStore.getState().reset(); + readResult = result(); + generateCalls = 0; + lastReadModel = undefined; + lastGenerateModel = undefined; + }); + + afterEach(() => { + useWalkthroughStore.getState().reset(); + }); + + test('sends the chosen model with both the read and the generation', async () => { + useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5'); + + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + expect(lastReadModel).toBe('anthropic/claude-haiku-4-5'); + + void useWalkthroughStore.getState().generate('/repo', SOURCE); + await flush(); + expect(lastGenerateModel).toBe('anthropic/claude-haiku-4-5'); + releaseGeneration?.(); + await flush(); + }); + + test('clearing the choice falls back to whatever the server resolves', async () => { + useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5'); + useWalkthroughStore.getState().selectModel('/repo', SOURCE, null); + + await useWalkthroughStore.getState().load('/repo', SOURCE); + await flush(); + + expect(lastReadModel).toBe(undefined); + }); + + test('keeps choices apart per source', async () => { + const branch: WalkthroughSource = { kind: 'branch', baseRef: 'main', headRef: 'feature' }; + useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5'); + + expect(useWalkthroughStore.getState().getSelectedModel("/repo", branch)).toBe(undefined); + expect(useWalkthroughStore.getState().getSelectedModel('/repo', SOURCE)) + .toBe('anthropic/claude-haiku-4-5'); + }); +}); + +describe('useWalkthroughStore — walkthrough language', () => { + beforeEach(() => { + useWalkthroughStore.getState().reset(); + readResult = result(); + generateCalls = 0; + lastReadLanguage = undefined; + lastGenerateLanguage = undefined; + }); + + afterEach(() => { + useWalkthroughStore.getState().reset(); + }); + + // The read carries it too: readiness is an answer about a specific request, + // and the language instruction is part of that request. + test('sends the resolved language with both the read and the generation', async () => { + await useWalkthroughStore.getState().load('/repo', SOURCE, { language: 'uk' }); + await flush(); + expect(lastReadLanguage).toBe('uk'); + + void useWalkthroughStore.getState().generate('/repo', SOURCE, { language: 'uk' }); + await flush(); + expect(lastGenerateLanguage).toBe('uk'); + releaseGeneration?.(); + await flush(); + }); + + test('keeps an explicit choice apart per source', () => { + const branch: WalkthroughSource = { kind: 'branch', baseRef: 'main', headRef: 'feature' }; + useWalkthroughStore.getState().selectLanguage('/repo', SOURCE, 'ja'); + + expect(useWalkthroughStore.getState().getSelectedLanguage('/repo', branch)).toBe(undefined); + expect(useWalkthroughStore.getState().getSelectedLanguage('/repo', SOURCE)).toBe('ja'); + }); + + test('clearing the choice returns to no explicit language', () => { + useWalkthroughStore.getState().selectLanguage('/repo', SOURCE, 'ja'); + useWalkthroughStore.getState().selectLanguage('/repo', SOURCE, null); + + expect(useWalkthroughStore.getState().getSelectedLanguage('/repo', SOURCE)).toBe(undefined); + }); + + test('a re-attach after a reload still names the language it would ask for', async () => { + readResult = result({ generating: true }); + + await useWalkthroughStore.getState().load('/repo', SOURCE, { language: 'pl' }); + await flush(); + + expect(lastGenerateLanguage).toBe('pl'); + releaseGeneration?.(); + await flush(); + }); +}); diff --git a/packages/ui/src/stores/useWalkthroughStore.ts b/packages/ui/src/stores/useWalkthroughStore.ts new file mode 100644 index 00000000..dbb6206d Binary files /dev/null and b/packages/ui/src/stores/useWalkthroughStore.ts differ diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 7170c9c3..f4a165e6 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -619,10 +619,14 @@ the keyboard classes remain as fallbacks for keyboard-up states that do not go through the pill (and for mobile browsers). Instant show/hide (no squish animation); the title's own keyboard compensation (.oc-draft-center below) - carries the smooth motion. */ -:root.oc-composer-expanded .oc-draft-starters, -:root.oc-capacitor-app.oc-keyboard-open .oc-draft-starters, -:root.oc-browser-keyboard-open .oc-draft-starters { + carries the smooth motion. + + A tablet has room to keep them: `oc-keep-draft-starters` is set by the shell + (portrait, or any orientation with a hardware keyboard — see MobileApp) and + opts the whole draft screen out of the hiding below. */ +:root:not(.oc-keep-draft-starters).oc-composer-expanded .oc-draft-starters, +:root:not(.oc-keep-draft-starters).oc-capacitor-app.oc-keyboard-open .oc-draft-starters, +:root:not(.oc-keep-draft-starters).oc-browser-keyboard-open .oc-draft-starters { display: none; } diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index a48479ba..93a475ff 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -213,10 +213,43 @@ Examples of global-store updates performed in `session-actions.ts`: - `createSession()` -> `upsertSession(session)` - `updateSessionTitle()` -> `upsertSession(result.data)` - `shareSession()` / `unshareSession()` -> `upsertSession(result.data)` -- `archiveSession()` -> waits for server confirmation, then upserts the archived session -- `deleteSession()` -> waits for server confirmation or `404`, then removes the session and its persisted state +- `archiveSession()` / `archiveSessions()` -> wait for server confirmation, then upsert each archived session +- `deleteSession()` / `deleteSessions()` -> wait for server confirmation or `404`, then remove the session and its persisted state - `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index +Archive and delete actions capture the active runtime key when they start and +recheck it before every store reconciliation, so a response +produced by the previous runtime is rejected instead of mutating the current +runtime's live or global session state. A guarded batch stops at the first +observed runtime change: sessions the server already confirmed remain archived +or deleted and stay in `archivedIds`/`deletedIds`, while every ID not confirmed +on the captured runtime is returned in `failedIds` so existing partial-failure +feedback stays truthful. +Callers whose confirmation can span a runtime switch may pass an +`expectedRuntimeKey` captured earlier; ordinary callers are guarded by default. + +Deletion needs this guard more than archiving does. Session IDs are not unique +across runtimes, and a committed deletion does more than hide a row: it evicts +the session from every live store, removes it from the global cache, clears the +current-session pointer, and calls `cleanupPersistedSessionState`, which erases +that session's queued messages, todos, folder membership, inline-comment drafts, +chat draft, and pins. Committing a stale deletion can therefore destroy user +state belonging to an unrelated session on the new runtime. + +`cleanupPersistedSessionState` already refuses an identity whose runtime is no +longer active, so `finalizeConfirmedSessionDeletion` must forward the **captured** +runtime key. Passing the live key would make that check compare a value with +itself and always pass. The in-memory live, global, and UI stores it mutates are +not runtime-scoped, so the calling action must reject a stale runtime before +committing rather than relying on that helper alone. + +A `404` still means "already deleted" and commits cleanup, but only while the +captured runtime is active. After a runtime change the `404` describes either +the previous runtime or one this session never belonged to, so the action +reports failure instead of committing. The deletion already accepted by the +server stays deleted there; its persisted state is left as harmless stale +metadata and the next authoritative load reconciles it. + ## The golden rule When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly. diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index cfd4f9f0..85da9ffc 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -14,6 +14,8 @@ let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {} let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] } let sessionDeleteError: unknown | null = null +let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null +let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null const globalUpsertedSessions: unknown[] = [] const globalRemovedSessionIds: string[] = [] const deletedCleanupIdentities: Array<{ runtimeKey: string; directory: string; sessionId: string }> = [] @@ -145,10 +147,16 @@ mock.module("@/lib/opencode/client", () => ({ }), updateSession: mock((sessionId: string, changes: Record, directory?: string | null) => { replyCalls.push({ method: "session.update", params: { sessionID: sessionId, ...changes, directory } }) + // Lets a test mutate global runtime state while the SDK call is in flight, + // so the action observes the switch only after awaiting the response. + beforeSessionUpdateResolve?.(sessionId) return Promise.resolve(sessionUpdateResult.data) }), deleteSession: mock((sessionId: string, directory?: string | null) => { replyCalls.push({ method: "session.delete", params: { sessionID: sessionId, directory } }) + // Lets a test switch runtime while the delete is in flight, so the action + // observes the change only after awaiting (or catching) the response. + beforeSessionDeleteResolve?.(sessionId) if (sessionDeleteError) throw sessionDeleteError return Promise.resolve(true) }), @@ -368,6 +376,8 @@ describe("confirmed session removal", () => { deletedCleanupIdentities.length = 0 sessionDeleteError = null sessionUpdateResult = {} + beforeSessionUpdateResolve = null + beforeSessionDeleteResolve = null }) test("does not remove live or persisted state when delete fails", async () => { @@ -401,6 +411,107 @@ describe("confirmed session removal", () => { }).toEqual({ directory: "/test/project", sessionId: "session-a" }) }) + test("scopes persisted cleanup to the runtime captured when the delete started", async () => { + const source = createStore({}, { + session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session], + }) + const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://delete-scope.test", runtimeKey: "delete-scope" }) + const { deleteSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + expect(await deleteSession("session-a")).toBe(true) + // The cleanup identity must carry the captured runtime, which is what lets + // cleanupPersistedSessionState reject a stale identity instead of comparing + // the live runtime key with itself. + expect(deletedCleanupIdentities[0]?.runtimeKey).toBe("delete-scope") + expect(deletedCleanupIdentities[0]?.runtimeKey).toBe(getRuntimeKey()) + }) + + test("rejects a delete response that arrives after a runtime switch", async () => { + const source = createStore({}, { + session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session], + }) + const { switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://delete-runtime-a.test", runtimeKey: "delete-runtime-a" }) + beforeSessionDeleteResolve = () => { + switchRuntimeEndpoint({ apiBaseUrl: "http://delete-runtime-b.test", runtimeKey: "delete-runtime-b" }) + } + const { deleteSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + expect(await deleteSession("session-a")).toBe(false) + // Session IDs are not unique across runtimes: committing here could evict an + // unrelated session and erase its queue, todos, drafts, folders, and pins. + expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"]) + expect(globalRemovedSessionIds).toEqual([]) + expect(deletedCleanupIdentities).toEqual([]) + }) + + test("does not treat a 404 as an already-completed deletion after a runtime switch", async () => { + sessionDeleteError = Object.assign(new Error("not found"), { status: 404 }) + const source = createStore({}, { + session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session], + }) + const { switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://delete-404-a.test", runtimeKey: "delete-404-a" }) + beforeSessionDeleteResolve = () => { + switchRuntimeEndpoint({ apiBaseUrl: "http://delete-404-b.test", runtimeKey: "delete-404-b" }) + } + const { deleteSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + // A 404 only proves "already deleted" for the captured runtime. After a + // switch it describes the wrong runtime, so it must not commit cleanup. + expect(await deleteSession("session-a")).toBe(false) + expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"]) + expect(globalRemovedSessionIds).toEqual([]) + expect(deletedCleanupIdentities).toEqual([]) + }) + + test("still treats a 404 as an already-completed deletion while the runtime is stable", async () => { + sessionDeleteError = Object.assign(new Error("not found"), { status: 404 }) + const source = createStore({}, { + session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session], + }) + const { deleteSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + expect(await deleteSession("session-a")).toBe(true) + expect(source.getState().session).toEqual([]) + expect(globalRemovedSessionIds).toEqual(["session-a"]) + expect(deletedCleanupIdentities).toHaveLength(1) + }) + + test("keeps committed deletions and fails the rest when the runtime changes mid-batch", async () => { + const source = createStore({}, { + session: [ + { id: "session-a", directory: "/test/project", time: { created: 1 } } as Session, + { id: "session-b", directory: "/test/project", time: { created: 1 } } as Session, + { id: "session-c", directory: "/test/project", time: { created: 1 } } as Session, + ], + }) + const { switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://delete-batch-a.test", runtimeKey: "delete-batch-a" }) + beforeSessionDeleteResolve = (sessionId) => { + if (sessionId === "session-b") { + switchRuntimeEndpoint({ apiBaseUrl: "http://delete-batch-b.test", runtimeKey: "delete-batch-b" }) + } + } + const { deleteSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await deleteSessions(["session-a", "session-b", "session-c"]) + + // session-a was committed before the switch; session-b's response is stale + // and session-c is never attempted, so both are reported as failures. + expect(result).toEqual({ deletedIds: ["session-a"], failedIds: ["session-b", "session-c"] }) + expect(source.getState().session.map((item) => item.id)).toEqual(["session-b", "session-c"]) + expect(globalRemovedSessionIds).toEqual(["session-a"]) + expect(replyCalls.filter((call) => call.method === "session.delete").map((call) => call.params.sessionID)) + .toEqual(["session-a", "session-b"]) + }) + test("does not archive locally until the server returns the archived session", async () => { const source = createStore({}, { session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session], @@ -427,6 +538,84 @@ describe("confirmed session removal", () => { expect(source.getState().session).toEqual([]) expect((globalUpsertedSessions[0] as Session)?.time?.archived).toBe(2) }) + + test("rejects an archive response that arrives after a runtime switch", async () => { + sessionUpdateResult = { + data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 2 } } as Session, + } + const source = createStore({}, { + session: [{ id: "session-a", directory: "/test/project", time: { created: 1 } } as Session], + }) + const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://archive-runtime-a.test", runtimeKey: "archive-runtime-a" }) + beforeSessionUpdateResolve = () => { + switchRuntimeEndpoint({ apiBaseUrl: "http://archive-runtime-b.test", runtimeKey: "archive-runtime-b" }) + } + const { archiveSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + expect(await archiveSession("session-a")).toBe(false) + expect(getRuntimeKey()).toBe("archive-runtime-b") + // The stale response must not reconcile the runtime the user switched to. + expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"]) + expect(globalUpsertedSessions).toEqual([]) + }) + + test("keeps confirmed sessions and fails the rest when the runtime changes mid-batch", async () => { + sessionUpdateResult = { + data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 2 } } as Session, + } + const source = createStore({}, { + session: [ + { id: "session-a", directory: "/test/project", time: { created: 1 } } as Session, + { id: "session-b", directory: "/test/project", time: { created: 1 } } as Session, + { id: "session-c", directory: "/test/project", time: { created: 1 } } as Session, + ], + }) + const { switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://archive-batch-a.test", runtimeKey: "archive-batch-a" }) + beforeSessionUpdateResolve = (sessionId) => { + if (sessionId === "session-b") { + switchRuntimeEndpoint({ apiBaseUrl: "http://archive-batch-b.test", runtimeKey: "archive-batch-b" }) + } + } + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a", "session-b", "session-c"]) + + // session-a was confirmed before the switch and stays archived; session-b's + // response is stale and session-c is never attempted, so both are reported + // as failures instead of being silently dropped. + expect(result).toEqual({ archivedIds: ["session-a"], failedIds: ["session-b", "session-c"] }) + expect(source.getState().session.map((item) => item.id)).toEqual(["session-b", "session-c"]) + expect(globalUpsertedSessions).toHaveLength(1) + // session-c must not reach the SDK after the runtime changed. + expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID)) + .toEqual(["session-a", "session-b"]) + }) + + test("archives every session when the runtime stays stable", async () => { + sessionUpdateResult = { + data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 2 } } as Session, + } + const source = createStore({}, { + session: [ + { id: "session-a", directory: "/test/project", time: { created: 1 } } as Session, + { id: "session-b", directory: "/test/project", time: { created: 1 } } as Session, + ], + }) + const { getRuntimeKey } = await import("../lib/runtime-switch") + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a", "session-b"], { + expectedRuntimeKey: getRuntimeKey(), + }) + + expect(result).toEqual({ archivedIds: ["session-a", "session-b"], failedIds: [] }) + expect(source.getState().session).toEqual([]) + }) }) describe("fetchMessagesForSession startup race", () => { diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index c25ecb1e..e310f3a0 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -654,15 +654,39 @@ export async function createSession( } } +/** + * True when a caller captured a runtime key before an asynchronous mutation and + * that runtime is no longer the active one. Callers pass `undefined` when they + * do not participate in runtime-scoped guarding, which keeps the previous + * unguarded behavior. + */ +function isStaleRuntime(expectedRuntimeKey: string | undefined): boolean { + return expectedRuntimeKey !== undefined && getRuntimeKey() !== expectedRuntimeKey +} + +/** + * Read a session, apply `updater` to its metadata, and persist the result. + * + * `expectedRuntimeKey` is optional here and unguarded when omitted, unlike the + * archive and delete actions. When supplied, the runtime is rechecked before + * the read, before the write, and before the global store is updated; a change + * at any of those points **throws** `"runtime changed"` rather than returning a + * value, because this function must resolve to a `Session`. Callers that pass a + * key must therefore be prepared to catch that rejection. + */ export async function patchSessionMetadata( sessionId: string, directory: string | null | undefined, updater: (metadata: SessionMetadataRecord) => SessionMetadataRecord, + expectedRuntimeKey?: string, ): Promise { + if (isStaleRuntime(expectedRuntimeKey)) throw new Error("runtime changed") const targetDirectory = directory ?? getSessionDirectory(sessionId) const current = await opencodeClient.getSession(sessionId, targetDirectory) + if (isStaleRuntime(expectedRuntimeKey)) throw new Error("runtime changed") const nextMetadata = updater(getSessionMetadata(current)) const updated = await opencodeClient.updateSession(sessionId, { metadata: nextMetadata }, targetDirectory) + if (isStaleRuntime(expectedRuntimeKey)) throw new Error("runtime changed") useGlobalSessionsStore.getState().upsertSession(updated) const sessionDirectory = (updated as { directory?: string | null }).directory ?? targetDirectory if (sessionDirectory) registerSessionDirectory(updated.id, sessionDirectory) @@ -682,19 +706,26 @@ export async function setContextObligatoryMessage( return updated } -async function cleanupReviewMetadataBeforeDelete(sessionId: string, directory?: string | null): Promise { +async function cleanupReviewMetadataBeforeDelete( + sessionId: string, + directory?: string | null, + expectedRuntimeKey?: string, +): Promise { + if (isStaleRuntime(expectedRuntimeKey)) return let session: Session try { session = await opencodeClient.getSession(sessionId, directory ?? getSessionDirectory(sessionId)) } catch { return } + if (isStaleRuntime(expectedRuntimeKey)) return if (!isReviewSession(session)) return const originalSessionID = getOriginalSessionID(session) if (!originalSessionID) return try { await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), (metadata) => withoutReviewSessionLink(metadata, sessionId), + expectedRuntimeKey, ) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -743,7 +774,21 @@ function cleanupSessionWorktreeMetadata(sessionId: string): void { useSessionUIStore.getState().setWorktreeMetadata(sessionId, null) } -function finalizeConfirmedSessionDeletion(sessionId: string, sessionDirectory?: string): void { +/** + * Commit a server-confirmed deletion. + * + * `expectedRuntimeKey` is the runtime the deletion was confirmed on. It is + * forwarded to `cleanupPersistedSessionState`, which rejects an identity whose + * runtime is no longer active. Passing the live `getRuntimeKey()` here would + * make that existing check a tautology, so the captured key is required to keep + * it meaningful. Callers must still reject a stale runtime themselves, because + * the in-memory live/global/UI stores mutated below are not runtime-scoped. + */ +function finalizeConfirmedSessionDeletion( + sessionId: string, + sessionDirectory?: string, + expectedRuntimeKey = getRuntimeKey(), +): void { const snapshots = removeSessionFromLiveStores(sessionId, sessionDirectory) invalidateSessionLoads(sessionId, [...snapshots.map((snapshot) => snapshot.directory), sessionDirectory]) useGlobalSessionsStore.getState().removeSessions([sessionId]) @@ -752,23 +797,50 @@ function finalizeConfirmedSessionDeletion(sessionId: string, sessionDirectory?: cleanupSessionWorktreeMetadata(sessionId) if (sessionDirectory) { cleanupPersistedSessionState({ - runtimeKey: getRuntimeKey(), + runtimeKey: expectedRuntimeKey, directory: sessionDirectory, sessionId, }) } } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export async function deleteSession(sessionId: string, _options?: Record): Promise { +export type DeleteSessionOptions = { + /** + * Runtime key the deletion is scoped to. Defaults to the active runtime when + * the action starts; callers may supply a key captured earlier when + * confirmation spans a runtime switch. + */ + expectedRuntimeKey?: string +} + +/** + * Delete one session. + * + * The runtime is rechecked before the request and again before any store is + * reconciled, so a response produced by the previous runtime cannot mutate the + * current runtime's state. Session IDs are not unique across runtimes, so + * committing a stale deletion could otherwise evict an unrelated session and + * erase its persisted queue, todos, drafts, folders, and pins. + * + * A `404` is treated as an already-completed deletion, but only when it is + * still authoritative for the captured runtime. After a runtime change the + * `404` describes either the previous runtime or a runtime this session never + * belonged to; neither justifies committing cleanup here, so the action reports + * failure and leaves reconciliation to the next authoritative load. + */ +export async function deleteSession(sessionId: string, options?: DeleteSessionOptions): Promise { + const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey() + if (isStaleRuntime(expectedRuntimeKey)) return false const sessionDirectory = getSessionDirectory(sessionId) try { - await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory) + await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey) + if (isStaleRuntime(expectedRuntimeKey)) return false const deleted = await opencodeClient.deleteSession(sessionId, sessionDirectory) + if (isStaleRuntime(expectedRuntimeKey)) return false if (deleted !== true) { throw new Error("session.delete failed: server did not confirm deletion") } - finalizeConfirmedSessionDeletion(sessionId, sessionDirectory) + finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey) return true } catch (error) { console.error("[session-actions] deleteSession failed", error) @@ -776,7 +848,8 @@ export async function deleteSession(sessionId: string, _options?: Record { +export async function deleteSessionInDirectory( + sessionId: string, + directory: string, + expectedRuntimeKey = getRuntimeKey(), +): Promise { + if (isStaleRuntime(expectedRuntimeKey)) return false try { - await cleanupReviewMetadataBeforeDelete(sessionId, directory) + await cleanupReviewMetadataBeforeDelete(sessionId, directory, expectedRuntimeKey) + if (isStaleRuntime(expectedRuntimeKey)) return false const deleted = await opencodeClient.deleteSession(sessionId, directory) + if (isStaleRuntime(expectedRuntimeKey)) return false if (deleted !== true) { throw new Error("session.delete failed: server did not confirm deletion") } - finalizeConfirmedSessionDeletion(sessionId, directory) + finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey) return true } catch (error) { console.error("[session-actions] deleteSessionInDirectory failed", error) if ((error as { status?: number })?.status === 404) { - finalizeConfirmedSessionDeletion(sessionId, directory) + if (isStaleRuntime(expectedRuntimeKey)) return false + finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey) return true } return false } } -export async function archiveSession(sessionId: string): Promise { +export type DeleteSessionsOptions = { + /** + * Runtime key captured when the batch was confirmed. When supplied, the batch + * stops as soon as the active runtime differs. + */ + expectedRuntimeKey?: string +} + +/** + * Delete several sessions sequentially, preserving partial results. + * + * One failed session never blocks or erases the others: it is reported in + * `failedIds` while the remaining IDs are still attempted. When the runtime + * changes mid-batch, the sessions already committed on the captured runtime + * stay in `deletedIds` and every ID that was not committed there is reported in + * `failedIds`, so existing partial-failure feedback stays truthful. + */ +export async function deleteSessions( + ids: string[], + options?: DeleteSessionsOptions, +): Promise<{ deletedIds: string[]; failedIds: string[] }> { + const deletedIds: string[] = [] + const failedIds: string[] = [] + const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey() + + for (const [index, id] of ids.entries()) { + if (isStaleRuntime(expectedRuntimeKey)) { + failedIds.push(...ids.slice(index)) + break + } + if (await deleteSession(id, { expectedRuntimeKey })) deletedIds.push(id) + else failedIds.push(id) + } + + return { deletedIds, failedIds } +} + +/** + * Archive one session. + * + * `expectedRuntimeKey` defaults to the active runtime when the action starts. + * Callers may supply a key captured earlier when confirmation spans a runtime + * switch. When the runtime changes, the action stops and returns `false` + * without reconciling any store, so a response + * produced by the previous runtime cannot mutate the current runtime's live or + * global session state. A session the server already archived before the switch + * stays archived on that runtime and is re-read from the server the next time + * the runtime is loaded. + */ +export async function archiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise { + if (isStaleRuntime(expectedRuntimeKey)) return false const sessionDirectory = getSessionDirectory(sessionId) const archivedAt = Date.now() try { - await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory) + await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey) + if (isStaleRuntime(expectedRuntimeKey)) return false const archived = await opencodeClient.updateSession(sessionId, { time: { archived: archivedAt } }, sessionDirectory) + if (isStaleRuntime(expectedRuntimeKey)) return false if (!archived) { throw new Error("session.update failed: server did not return the archived session") } @@ -824,6 +957,45 @@ export async function archiveSession(sessionId: string): Promise { } } +export type ArchiveSessionsOptions = { + /** + * Runtime key captured when the batch was confirmed. When supplied, the batch + * stops as soon as the active runtime differs. + */ + expectedRuntimeKey?: string +} + +/** + * Archive several sessions sequentially, preserving partial results. + * + * One failed session never blocks or erases the others: it is reported in + * `failedIds` while the remaining IDs are still attempted. When + * `expectedRuntimeKey` is supplied and the runtime changes mid-batch, the + * already-confirmed sessions stay in `archivedIds` and every ID that was not + * confirmed on the captured runtime is reported in `failedIds`, so callers keep + * showing the existing partial-failure feedback instead of silently dropping + * work. + */ +export async function archiveSessions( + ids: string[], + options?: ArchiveSessionsOptions, +): Promise<{ archivedIds: string[]; failedIds: string[] }> { + const archivedIds: string[] = [] + const failedIds: string[] = [] + const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey() + + for (const [index, id] of ids.entries()) { + if (isStaleRuntime(expectedRuntimeKey)) { + failedIds.push(...ids.slice(index)) + break + } + if (await archiveSession(id, expectedRuntimeKey)) archivedIds.push(id) + else failedIds.push(id) + } + + return { archivedIds, failedIds } +} + export async function updateSessionTitle(sessionId: string, title: string): Promise { const sessionDirectory = getSessionDirectory(sessionId) const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory) diff --git a/packages/ui/src/sync/session-ordering.ts b/packages/ui/src/sync/session-ordering.ts index 93ca6a24..83bd3f40 100644 --- a/packages/ui/src/sync/session-ordering.ts +++ b/packages/ui/src/sync/session-ordering.ts @@ -140,7 +140,7 @@ export const raiseSessionOrderingBaselines = (sessions: Iterable): void if (liveRank !== undefined) { // A live rank frozen BEFORE this newer authoritative stamp is stale — // the session was active again while this client wasn't watching (its - // transition events never arrived, e.g. другий пристрій + сон). Ranks + // transition events never arrived, e.g. another device + sleep). Ranks // share the epoch-ms scale with `updated`, so raising is well-ordered. if (fresh > liveRank) { nextRanks = nextRanks ?? new Map(currentRanks); diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 468ff442..d60be0fe 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -454,3 +454,72 @@ describe('routeMessage skill invocation', () => { expect(sendCommandCalls).toHaveLength(0); }); }); + +describe('archiveSessions option forwarding', () => { + let originalUpdateSession; + let updateSessionCalls; + + beforeEach(() => { + updateSessionCalls = []; + originalUpdateSession = opencodeClient.updateSession; + opencodeClient.updateSession = (sessionId) => { + updateSessionCalls.push(sessionId); + return Promise.resolve(null); + }; + }); + + afterEach(() => { + opencodeClient.updateSession = originalUpdateSession; + }); + + // The store used to accept an options object and silently drop it, so a + // caller-supplied runtime key had no effect. Passing a key that cannot match + // the active runtime must abort the batch before any SDK call. + test('honors expectedRuntimeKey instead of discarding the options object', async () => { + const result = await useSessionUIStore.getState().archiveSessions(['session-x', 'session-y'], { + expectedRuntimeKey: 'runtime-that-is-not-active', + }); + + expect(result).toEqual({ archivedIds: [], failedIds: ['session-x', 'session-y'] }); + expect(updateSessionCalls).toEqual([]); + }); +}); + +describe('deleteSessions option forwarding', () => { + let originalDeleteSession; + let deleteSessionCalls; + + beforeEach(() => { + deleteSessionCalls = []; + originalDeleteSession = opencodeClient.deleteSession; + opencodeClient.deleteSession = (sessionId) => { + deleteSessionCalls.push(sessionId); + return Promise.resolve(true); + }; + }); + + afterEach(() => { + opencodeClient.deleteSession = originalDeleteSession; + }); + + // The store accepted an options object and dropped it on both the single and + // batch delete paths. A key that cannot match the active runtime must abort + // before any SDK call rather than deleting and erasing persisted state. + test('honors expectedRuntimeKey on the batch delete instead of discarding options', async () => { + const result = await useSessionUIStore.getState().deleteSessions(['session-x', 'session-y'], { + expectedRuntimeKey: 'runtime-that-is-not-active', + }); + + expect(result).toEqual({ deletedIds: [], failedIds: ['session-x', 'session-y'] }); + expect(deleteSessionCalls).toEqual([]); + }); + + test('honors expectedRuntimeKey on the single delete instead of discarding options', async () => { + const deleted = await useSessionUIStore.getState().deleteSession('session-x', { + expectedRuntimeKey: 'runtime-that-is-not-active', + }); + + expect(deleted).toBe(false); + expect(deleteSessionCalls).toEqual([]); + }); +}); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 3f136e32..33618f71 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -46,7 +46,9 @@ import { setActiveSession } from "./sync-context" import { createSession as createSessionAction, deleteSession as deleteSessionAction, + deleteSessions as deleteSessionsAction, archiveSession as archiveSessionAction, + archiveSessions as archiveSessionsAction, updateSessionTitle as updateSessionTitleAction, shareSession as shareSessionAction, unshareSession as unshareSessionAction, @@ -56,6 +58,9 @@ import { unrevertSession as unrevertSessionAction, forkFromMessage as forkFromMessageAction, fetchMessagesForSession, + type ArchiveSessionsOptions, + type DeleteSessionOptions, + type DeleteSessionsOptions, } from "./session-actions" import { useInputStore, type SyntheticContextPart } from "./input-store" import { useSessionGoalArmStore } from "@/stores/useSessionGoalArmStore" @@ -320,10 +325,10 @@ export type SessionUIState = { ) => Promise createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null, metadata?: Record) => Promise - deleteSession: (id: string, options?: Record) => Promise - deleteSessions: (ids: string[], options?: Record) => Promise<{ deletedIds: string[]; failedIds: string[] }> + deleteSession: (id: string, options?: DeleteSessionOptions) => Promise + deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }> archiveSession: (id: string) => Promise - archiveSessions: (ids: string[], options?: Record) => Promise<{ archivedIds: string[]; failedIds: string[] }> + archiveSessions: (ids: string[], options?: ArchiveSessionsOptions) => Promise<{ archivedIds: string[]; failedIds: string[] }> updateSessionTitle: (sessionId: string, title: string) => Promise shareSession: (sessionId: string) => Promise unshareSession: (sessionId: string) => Promise @@ -1289,31 +1294,13 @@ export const useSessionUIStore = create()((set, get) => ({ // --------------------------------------------------------------------------- // deleteSession — calls SDK, SSE event updates child store // --------------------------------------------------------------------------- - deleteSession: (id) => deleteSessionAction(id), + deleteSession: (id, options) => deleteSessionAction(id, options), - deleteSessions: async (ids) => { - const deletedIds: string[] = [] - const failedIds: string[] = [] - for (const id of ids) { - const ok = await deleteSessionAction(id) - if (ok) deletedIds.push(id) - else failedIds.push(id) - } - return { deletedIds, failedIds } - }, + deleteSessions: (ids, options) => deleteSessionsAction(ids, options), archiveSession: (id) => archiveSessionAction(id), - archiveSessions: async (ids) => { - const archivedIds: string[] = [] - const failedIds: string[] = [] - for (const id of ids) { - const ok = await archiveSessionAction(id) - if (ok) archivedIds.push(id) - else failedIds.push(id) - } - return { archivedIds, failedIds } - }, + archiveSessions: (ids, options) => archiveSessionsAction(ids, options), // --------------------------------------------------------------------------- // updateSessionTitle — calls SDK, SSE event updates child store diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts index 6a9f49bb..02719c9e 100644 --- a/packages/ui/src/types/index.ts +++ b/packages/ui/src/types/index.ts @@ -13,6 +13,13 @@ export interface ModelMetadata { reasoning?: boolean; temperature?: boolean; attachment?: boolean; + /** + * Whether the model can be constrained to a JSON schema. Tri-state on + * purpose: `undefined` means the catalog does not say — common for + * aggregators and proxies — and must be treated as "worth trying", never as + * unsupported. + */ + structured_output?: boolean; modalities?: { input?: string[]; output?: string[]; diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index e8b77d20..54e4813f 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,11 @@ +## [Unreleased] + +- Chat: clicking an apply_patch tool result now opens each changed file at its correct path instead of always opening the first file (thanks to @nabsiddiqui). +- Chat: assistant messages no longer render active HTML. +- Sidebar: a worktree shared by more than one project no longer appears twice. +- Sidebar: session titles no longer clip at the ends of their rows. +- Sessions: archiving and unarchiving now stays scoped to the current workspace. + ## [1.17.2] - 2026-08-01 - Per-session permission auto-accept now replies to live permission requests correctly when auto-accept is turned on. diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts index 87a4fd9c..20f4219e 100644 --- a/packages/vscode/src/bridge-settings-runtime.ts +++ b/packages/vscode/src/bridge-settings-runtime.ts @@ -291,7 +291,7 @@ export const persistSettings = async (changes: Record, ctx?: Br const keysToClear = new Set(); - for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary', 'smallModelOverride']) { + for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary', 'smallModelOverride', 'walkthroughModelOverride']) { const value = restChanges[key]; if (typeof value === 'string' && value.trim().length === 0) { keysToClear.add(key); diff --git a/packages/web/package.json b/packages/web/package.json index 7380dd7d..f5afcf43 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -42,7 +42,7 @@ "qrcode-terminal": "^0.12.0", "reflect-metadata": "^0.2.2", "sherpa-onnx-node": "1.12.28", - "simple-git": "^3.28.0", + "simple-git": "^3.36.0", "web-push": "^3.6.7", "ws": "^8.18.3", "yaml": "^2.8.1" diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 746ecb88..7dbd31ce 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -26,9 +26,11 @@ The following functions are exported and used by the web server: ### Status and Diff Operations - `getStatus(directory)`: Get comprehensive Git status including current branch, tracking, ahead/behind, file changes, diff stats, merge/rebase state. - `getDiff(directory, { path, staged, contextLines })`: Get diff output for files or entire working tree. Untracked symbolic links are represented as link entries without following their targets. -- `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs. +- `getRangeDiff(directory, { base, head, path, contextLines })`: Get diff between two refs. Uses three-dot `base...head` semantics, so work merged into `head` from `base` is excluded and only the branch's own changes are returned. Prefers `origin/` when that remote-tracking ref exists, so a stale local base branch does not resurface already-merged commits. Exposed as `GET /api/git/range-diff` (`path` optional; omit it for the whole range). - `getRangeFiles(directory, { base, head })`: Get list of changed files between two refs. - `getFileDiff(directory, { path, staged })`: Get original and modified file contents for a single file (handles images as data URLs and symbolic links as their link-target text). +- `listUntrackedPaths(directory)`: List individual untracked file paths honoring ignore rules. Much cheaper than `getStatus` when that is all a caller needs. Deliberately not `--directory`: collapsed directory entries end in a slash and are rejected by the per-file diff helpers, so a caller would silently lose every file inside a new directory. +- `getUntrackedDiffs(directory, filePaths, { concurrency, contextLines })`: Diffs for untracked files against an empty tree. Resolves the repository context once instead of per file (`getDiff` re-resolves every call, costing an extra `rev-parse` each time) and bounds how many diff processes run at once. Returns one entry per input path in order; unreadable paths yield `''` rather than failing the batch. - `collectDiffs(directory, files)`: Collect diff output for multiple files. - `revertFile(directory, filePath, options)`: Revert a file. Default scope `all` discards staged and working-tree changes; scope `working` discards only unstaged/working-tree changes. - `stageFile(directory, filePath)`: Add one file path to the index. @@ -109,6 +111,9 @@ The following functions are internal helpers used by exported functions: - `mergeInProgress`: Object with `{ head, message }` if merge in progress. - `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress. +### Runtime availability of range diffs +- `GET /api/git/range-diff` is served by the OpenChamber web server, so it is available to web, desktop, and mobile clients. The shared `GitAPI.getGitRangeDiff` is therefore optional: web supplies the HTTP implementation, and VS Code does not implement it because the extension host serves Git through its own bridge rather than these routes. Features built on range diffs (currently the AI diff walkthrough) are not offered in VS Code. + ### Staged and unstaged change handling - `status.files` exposes both `index` and `working_dir` codes. Shared UI uses these as separate scopes: staged rows are derived from non-empty `index` statuses, while unstaged rows are derived from `working_dir` statuses and untracked files. - A file with both staged and unstaged changes can appear in both UI sections. Staged rows request diffs with `staged: true`; unstaged rows request normal working-tree diffs. @@ -135,7 +140,7 @@ The following functions are internal helpers used by exported functions: ### Adding a New Git Operation 1. Add the function to `packages/web/server/lib/git/service.js`. 2. Export the function if it's part of the public API. -3. Use `createGit(directory)` to get a simple-git instance with the correct environment. +3. Use `createGit(directory)` to get a simple-git instance with the correct environment. `directory` is required (`baseDir`); never omit it so commands cannot inherit `process.cwd()`. 4. Use `runGitCommand(cwd, args)` for direct git command execution with better error handling. 5. Use `runGitCommandOrThrow(cwd, args, fallbackMessage)` for commands that must succeed. 6. Return consistent error messages; use `parseGitErrorText(error)` to extract meaningful git errors. @@ -146,6 +151,11 @@ The following functions are internal helpers used by exported functions: - On Windows, paths are converted to MSYS format (`C:/path` → `/c/path`). - SSH_AUTH_SOCK is automatically resolved via `resolveSshAuthSock` (checks GPG agent, gpgconf). +### Working directory (simple-git) +- Repository operations always pass an explicit `baseDir` (the opened project/directory path) into simple-git. Omitting `baseDir` would default to `process.cwd()`, which breaks when the server was launched from a neutral directory (e.g. `$HOME`) while the opened project lives elsewhere. +- Global identity reads use the user home directory as `baseDir` (they do not need a repository). +- A `GitError` / non-repository result from status or check must not abort project/session enumeration: routes return a soft non-repo payload and log a warning. + ### Worktree Naming - Worktree names are slugified via `slugWorktreeName`. - Random names use adjectives/nouns from `OPENCODE_ADJECTIVES` and `OPENCODE_NOUNS` lists. diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index 25fd5bba..3766fea2 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -7,6 +7,36 @@ export function registerGitRoutes(app) { return gitLibraries; }; + const resolveDirectoryQuery = (value) => { + const raw = Array.isArray(value) ? value[0] : value; + if (typeof raw !== 'string') { + return null; + } + const trimmed = raw.trim(); + return trimmed || null; + }; + + const extractGitErrorText = (error) => { + const message = typeof error?.message === 'string' ? error.message : ''; + const stderr = typeof error?.stderr === 'string' ? error.stderr : ''; + const stdout = typeof error?.stdout === 'string' ? error.stdout : ''; + const fallback = !message && error != null ? String(error) : ''; + return [message, stderr, stdout, fallback] + .map((value) => String(value || '').trim()) + .filter(Boolean) + .join('\n'); + }; + + const isNonRepoGitError = (error) => /not a git repository/i.test(extractGitErrorText(error)); + + const nonRepoStatusPayload = () => ({ + isGitRepository: false, + files: [], + branch: null, + ahead: 0, + behind: 0, + }); + app.get('/api/git/identities', async (req, res) => { const { getProfiles } = await getGitLibraries(); try { @@ -79,7 +109,7 @@ export function registerGitRoutes(app) { app.get('/api/git/check', async (req, res) => { const { isGitRepository } = await getGitLibraries(); try { - const directory = req.query.directory; + const directory = resolveDirectoryQuery(req.query.directory); if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } @@ -87,6 +117,10 @@ export function registerGitRoutes(app) { const isRepo = await isGitRepository(directory); res.json({ isGitRepository: isRepo }); } catch (error) { + if (isNonRepoGitError(error)) { + console.warn('Git check treated non-repository path as not a git repo:', extractGitErrorText(error)); + return res.json({ isGitRepository: false }); + } console.error('Failed to check git repository:', error); res.status(500).json({ error: 'Failed to check git repository' }); } @@ -188,34 +222,26 @@ export function registerGitRoutes(app) { app.get('/api/git/status', async (req, res) => { const { getStatus, isGitRepository } = await getGitLibraries(); - const extractGitErrorText = (error) => { - const message = typeof error?.message === 'string' ? error.message : ''; - const stderr = typeof error?.stderr === 'string' ? error.stderr : ''; - const stdout = typeof error?.stdout === 'string' ? error.stdout : ''; - return [message, stderr, stdout] - .map((value) => String(value || '').trim()) - .filter(Boolean) - .join('\n'); - }; - try { - const directory = req.query.directory; + const directory = resolveDirectoryQuery(req.query.directory); if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } const isRepo = await isGitRepository(directory); if (!isRepo) { - return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 }); + return res.json(nonRepoStatusPayload()); } const mode = req.query.mode === 'light' ? 'light' : undefined; const status = await getStatus(directory, { mode }); res.json(status); } catch (error) { - const errorText = extractGitErrorText(error); - if (/not a git repository/i.test(errorText)) { - return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 }); + // Non-repo / GitError must not abort callers that enumerate projects or + // sessions (e.g. sidebar discovery). Log a warning and continue. + if (isNonRepoGitError(error)) { + console.warn('Git status skipped for non-repository path:', extractGitErrorText(error)); + return res.json(nonRepoStatusPayload()); } console.error('Failed to get git status:', error); res.status(500).json({ error: error.message || 'Failed to get git status' }); @@ -371,6 +397,37 @@ export function registerGitRoutes(app) { } }); + app.get('/api/git/range-diff', async (req, res) => { + const { getRangeDiff } = await getGitLibraries(); + try { + const directory = req.query.directory; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const base = req.query.base; + const head = req.query.head; + if (!base || typeof base !== 'string' || !head || typeof head !== 'string') { + return res.status(400).json({ error: 'base and head parameters are required' }); + } + + const pathParam = typeof req.query.path === 'string' && req.query.path ? req.query.path : undefined; + const context = req.query.context ? parseInt(String(req.query.context), 10) : undefined; + + const diff = await getRangeDiff(directory, { + base, + head, + path: pathParam, + contextLines: Number.isFinite(context) ? context : 3, + }); + + res.json({ diff }); + } catch (error) { + console.error('Failed to get git range diff:', error); + res.status(500).json({ error: error.message || 'Failed to get git range diff' }); + } + }); + app.post('/api/git/revert', async (req, res) => { const { revertFile } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git/routes.test.js b/packages/web/server/lib/git/routes.test.js index 0c0597c8..ac6e3309 100644 --- a/packages/web/server/lib/git/routes.test.js +++ b/packages/web/server/lib/git/routes.test.js @@ -3,11 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const gitLibraries = { stageFiles: vi.fn(), unstageFiles: vi.fn(), + isGitRepository: vi.fn(), + getStatus: vi.fn(), }; vi.mock('./index.js', () => ({ stageFiles: gitLibraries.stageFiles, unstageFiles: gitLibraries.unstageFiles, + isGitRepository: gitLibraries.isGitRepository, + getStatus: gitLibraries.getStatus, })); const { registerGitRoutes } = await import('./routes.js'); @@ -47,7 +51,6 @@ const createMockResponse = () => { }, json(payload) { body = payload; - return this; }, get statusCode() { return statusCode; @@ -62,6 +65,8 @@ describe('git routes index mutations', () => { beforeEach(() => { gitLibraries.stageFiles.mockReset(); gitLibraries.unstageFiles.mockReset(); + gitLibraries.isGitRepository.mockReset(); + gitLibraries.getStatus.mockReset(); }); it('accepts legacy stage path payloads', async () => { @@ -135,3 +140,71 @@ describe('git routes index mutations', () => { expect(gitLibraries.stageFiles).not.toHaveBeenCalled(); }); }); + +describe('git routes status discovery', () => { + beforeEach(() => { + gitLibraries.isGitRepository.mockReset(); + gitLibraries.getStatus.mockReset(); + }); + + it('returns a soft non-repo payload for non-git folders', async () => { + gitLibraries.isGitRepository.mockResolvedValue(false); + const { app, getRoute } = createRouteRegistry(); + registerGitRoutes(app); + const response = createMockResponse(); + + await getRoute('GET', '/api/git/status')( + { query: { directory: '/tmp/not-a-repo' } }, + response, + ); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + isGitRepository: false, + files: [], + branch: null, + ahead: 0, + behind: 0, + }); + expect(gitLibraries.getStatus).not.toHaveBeenCalled(); + }); + + it('does not abort when getStatus throws a non-repo GitError', async () => { + gitLibraries.isGitRepository.mockResolvedValue(true); + gitLibraries.getStatus.mockRejectedValue( + Object.assign(new Error('fatal: not a git repository (or any of the parent directories): .git'), { + task: { commands: ['status'] }, + }), + ); + const { app, getRoute } = createRouteRegistry(); + registerGitRoutes(app); + const response = createMockResponse(); + + await getRoute('GET', '/api/git/status')( + { query: { directory: '/opened/project' } }, + response, + ); + + expect(response.statusCode).toBe(200); + expect(response.body).toMatchObject({ isGitRepository: false }); + expect(gitLibraries.getStatus).toHaveBeenCalledWith('/opened/project', { mode: undefined }); + }); + + it('uses the opened project path from query arrays without falling back to cwd', async () => { + gitLibraries.isGitRepository.mockResolvedValue(true); + gitLibraries.getStatus.mockResolvedValue({ current: 'main', files: [], isClean: true, ahead: 0, behind: 0 }); + const { app, getRoute } = createRouteRegistry(); + registerGitRoutes(app); + const response = createMockResponse(); + + await getRoute('GET', '/api/git/status')( + { query: { directory: ['/opened/git-project', '/ignored'] } }, + response, + ); + + expect(response.statusCode).toBe(200); + expect(gitLibraries.isGitRepository).toHaveBeenCalledWith('/opened/git-project'); + expect(gitLibraries.getStatus).toHaveBeenCalledWith('/opened/git-project', { mode: undefined }); + expect(response.body).toMatchObject({ current: 'main' }); + }); +}); diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index a70b7a47..ebe8c334 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -351,17 +351,28 @@ const buildGitEnv = async () => { return env; }; -const createGit = async (directory) => { +const createGit = async (directory, { allowUnsafeSshCommand = false } = {}) => { const env = await buildGitEnv(); const spawnOptions = { windowsHide: true }; const binary = getGitBinary(); const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe'; - const unsafe = hasCustomBinary ? { allowUnsafeCustomBinary: true } : undefined; - if (!directory) { - return createSimpleGit({ env, spawnOptions, binary, unsafe }); + const unsafe = hasCustomBinary || allowUnsafeSshCommand + ? { + ...(hasCustomBinary && { allowUnsafeCustomBinary: true }), + ...(allowUnsafeSshCommand && { allowUnsafeSshCommand: true }), + } + : undefined; + // Always pin simple-git to an explicit working directory. Omitting baseDir + // makes simple-git use process.cwd(), which breaks when the OpenChamber + // server was launched from a neutral directory (e.g. $HOME) and the opened + // project lives elsewhere — session/project discovery then sees spurious + // "not a git repository" errors and can abort enumeration. + const baseDir = normalizeDirectoryPath(directory); + if (typeof baseDir !== 'string' || !baseDir.trim()) { + throw new Error('Git directory is required'); } return createSimpleGit({ - baseDir: normalizeDirectoryPath(directory), + baseDir, env, spawnOptions, binary, @@ -369,6 +380,10 @@ const createGit = async (directory) => { }); }; +// Global config reads do not need a repository; use the home directory as a +// stable baseDir so we never accidentally inherit process.cwd(). +const createGitForGlobalConfig = async () => createGit(os.homedir()); + const normalizeDirectoryPath = (value) => { if (typeof value !== 'string') { return value; @@ -469,12 +484,25 @@ const resolveGitRepositoryRoot = async (directoryPath, git) => { const createRepositoryGitContext = async (directory) => { const directoryPath = normalizeDirectoryPath(directory); + if (typeof directoryPath !== 'string' || !directoryPath.trim()) { + throw new Error('Git directory is required'); + } const directoryGit = await createGit(directoryPath); const repoRoot = await resolveGitRepositoryRoot(directoryPath, directoryGit); const git = path.resolve(directoryPath) === repoRoot ? directoryGit : await createGit(repoRoot); return { directoryPath, directoryGit, repoRoot, git }; }; +/** + * Absolute repository root for a directory anywhere inside it. Callers that key + * persisted data by repository need this so two directories in the same + * repository do not address different records. + */ +export async function getRepositoryRoot(directory) { + const { repoRoot } = await createRepositoryGitContext(directory); + return repoRoot; +} + const resolveGitInternalPath = async (repoRoot, git, gitPath) => { const resolved = await git.raw(['rev-parse', '--git-path', gitPath]); return path.resolve(repoRoot, resolved.trim()); @@ -767,7 +795,11 @@ const parseGitErrorText = (error) => { const stderr = typeof error?.stderr === 'string' ? error.stderr : ''; const stdout = typeof error?.stdout === 'string' ? error.stdout : ''; const message = typeof error?.message === 'string' ? error.message : ''; - return [stderr, stdout, message] + // Some runtimes (notably Bun + simple-git GitError) surface the fatal text + // primarily via message/toString; keep String(error) as a last resort so + // "not a git repository" matching never misses and aborts callers. + const fallback = !message && error != null ? String(error) : ''; + return [stderr, stdout, message, fallback] .map((chunk) => String(chunk || '').trim()) .filter(Boolean) .join('\n') @@ -1942,7 +1974,7 @@ export async function isGitRepository(directory) { } export async function getGlobalIdentity() { - const git = await createGit(); + const git = await createGitForGlobalConfig(); try { const userName = await git.getConfig('user.name', 'global').catch(() => null); @@ -2020,7 +2052,7 @@ export async function hasLocalIdentity(directory) { } export async function setLocalIdentity(directory, profile) { - const git = await createGit(directory); + const git = await createGit(directory, { allowUnsafeSshCommand: true }); try { @@ -2030,12 +2062,12 @@ export async function setLocalIdentity(directory, profile) { const authType = profile.authType || 'ssh'; if (authType === 'ssh' && profile.sshKey) { - await git.addConfig( + await git.raw([ + 'config', + '--local', 'core.sshCommand', - buildSshCommand(profile.sshKey), - false, - 'local' - ); + buildSshCommand(profile.sshKey) + ]); await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {}); } else if (authType === 'token' && profile.host) { await git.addConfig( @@ -2062,9 +2094,19 @@ export async function setLocalIdentity(directory, profile) { export async function getStatus(directory, options = {}) { const lightMode = options.mode === 'light'; + const normalizedDirectory = normalizeDirectoryPath(directory); + if (typeof normalizedDirectory !== 'string' || !normalizedDirectory.trim()) { + throw new Error('directory is required'); + } try { - const { directoryPath, repoRoot, git } = await createRepositoryGitContext(directory); + // Prefer an explicit non-repo check before simple-git status so a missing + // repository never depends on process.cwd() or an opaque GitError shape. + if (!(await isGitRepository(normalizedDirectory))) { + throw new Error('fatal: not a git repository (or any of the parent directories): .git'); + } + + const { directoryPath, repoRoot, git } = await createRepositoryGitContext(normalizedDirectory); // Use -uall to show all untracked files individually, not just directories const status = await git.status(['-uall']); @@ -2318,9 +2360,12 @@ export async function getStatus(directory, options = {}) { rebaseInProgress, }; } catch (error) { - if (!isNotGitRepositoryError(error) && !isMissingDirectoryError(error)) { - console.error('Failed to get Git status:', error); + if (isNotGitRepositoryError(error) || isMissingDirectoryError(error)) { + // Re-throw a plain Error so route/session callers can match reliably and + // continue enumerating other projects instead of treating GitError as 500. + throw new Error('fatal: not a git repository (or any of the parent directories): .git'); } + console.error('Failed to get Git status:', error); throw error; } } @@ -2397,6 +2442,78 @@ export async function getDiff(directory, { path: filePath, staged = false, conte } } +/** + * Individual untracked file paths, honoring ignore rules. + * + * Deliberately not `--directory`: collapsed directory entries end in a slash + * and are not valid inputs to the per-file diff helpers, so a caller would + * silently lose every file inside a new directory. Listing files costs more + * entries but each one is usable. + * + * Callers that only need this list should not pay for `getStatus`, which also + * computes ahead/behind, diff stats, and merge state — an order of magnitude + * more work for an answer they throw away. + */ +export async function listUntrackedPaths(directory) { + const { repoRoot } = await createRepositoryGitContext(directory); + const result = await runGitCommand(repoRoot, [ + 'ls-files', + '--others', + '--exclude-standard', + ]); + if (!result.success) return []; + return String(result.stdout || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +/** + * Diffs for untracked files, produced against an empty tree. + * + * `getDiff` re-resolves the repository context on every call, which costs an + * extra `rev-parse` per file; a walkthrough of a branch with thirty new files + * pays that thirty times. This resolves once and reuses it, with a bounded pool + * so a repository full of new files cannot flood the process table. + * + * Returns one entry per input path, in order; unreadable paths yield `''` + * rather than failing the batch. + */ +export async function getUntrackedDiffs(directory, filePaths = [], { concurrency = 8, contextLines = 3 } = {}) { + const paths = (Array.isArray(filePaths) ? filePaths : []).filter((value) => typeof value === 'string' && value); + if (paths.length === 0) return []; + + const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); + const results = new Array(paths.length).fill(''); + let cursor = 0; + + const worker = async () => { + while (cursor < paths.length) { + const index = cursor++; + try { + const fileContext = await resolveGitFileContext(directoryPath, directoryGit, paths[index], repoRoot); + const args = ['diff', '--no-color']; + if (typeof contextLines === 'number' && !Number.isNaN(contextLines)) { + args.push(`-U${Math.max(0, contextLines)}`); + } + args.push('--no-index', '--', '/dev/null', fileContext.repoPath); + try { + results[index] = await git.raw(args); + } catch (error) { + // `git diff --no-index` exits 1 whenever there are differences, which + // for a new file is always. + results[index] = error?.exitCode === 1 && error?.message ? error.message : ''; + } + } catch { + results[index] = ''; + } + } + }; + + await Promise.all(Array.from({ length: Math.min(concurrency, paths.length) }, worker)); + return results; +} + export async function getRangeDiff(directory, { base, head, path: filePath, contextLines = 3 } = {}) { const { directoryPath, directoryGit, repoRoot, git } = await createRepositoryGitContext(directory); const baseRef = typeof base === 'string' ? base.trim() : ''; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 9afe758f..e5110995 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -11,6 +11,7 @@ import { createWorktree, getWorktreeBootstrapStatus, getStatus, + isGitRepository, populateWorktreeWithLockRecovery, removeWorktree, resolvePrimaryWorktreeRoot, @@ -18,6 +19,7 @@ import { resetToCommit, resolveBaseRefForLog, revertCommit, + setLocalIdentity, stageFiles, unstageFiles, applyHunk, @@ -128,6 +130,23 @@ describe('git index path validation', () => { }); }); +describe.runIf(canRunGit())('setLocalIdentity', () => { + it('configures the local SSH command with the targeted simple-git opt-in', async () => { + const { tmpDir } = await createTempRepo(); + + await setLocalIdentity(tmpDir, { + userName: 'SSH User', + userEmail: 'ssh@example.com', + authType: 'ssh', + sshKey: '/tmp/test key', + }); + + expect(runGit(tmpDir, ['config', '--local', '--get', 'core.sshCommand']).trim()).toBe( + "ssh -i '/tmp/test key' -o IdentitiesOnly=yes" + ); + }); +}); + // --------------------------------------------------------------------------- // applyHunk (per-hunk stage / unstage / discard) // --------------------------------------------------------------------------- @@ -303,6 +322,84 @@ describe('getStatus', () => { await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main' }); }); + + it('rejects a non-git folder without using process.cwd()', async () => { + if (!canRunGit()) return; + + const nonGit = createTempDir(); + const previousCwd = process.cwd(); + process.chdir(nonGit); + try { + await expect(getStatus(nonGit)).rejects.toThrow(/not a git repository/i); + } finally { + process.chdir(previousCwd); + } + }); + + it('reads status for a git repo when process.cwd() is elsewhere', async () => { + if (!canRunGit()) return; + + const repo = createTempDir(); + const neutralCwd = createTempDir(); + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md']); + runGit(repo, ['commit', '-m', 'Initial commit']); + + const previousCwd = process.cwd(); + process.chdir(neutralCwd); + try { + await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main', isClean: true }); + await expect(isGitRepository(repo)).resolves.toBe(true); + await expect(isGitRepository(neutralCwd)).resolves.toBe(false); + } finally { + process.chdir(previousCwd); + } + }); + + it('supports a folder with nested git repositories from a foreign cwd', async () => { + if (!canRunGit()) return; + + const parent = createTempDir(); + const nested = path.join(parent, 'nested'); + const neutralCwd = createTempDir(); + fs.mkdirSync(nested, { recursive: true }); + + runGit(parent, ['init', '-b', 'main']); + runGit(parent, ['config', 'user.email', 'test@example.com']); + runGit(parent, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(parent, 'README.md'), '# Parent\n'); + runGit(parent, ['add', 'README.md']); + runGit(parent, ['commit', '-m', 'Parent commit']); + + runGit(nested, ['init', '-b', 'feature']); + runGit(nested, ['config', 'user.email', 'test@example.com']); + runGit(nested, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(nested, 'nested.txt'), 'nested\n'); + runGit(nested, ['add', 'nested.txt']); + runGit(nested, ['commit', '-m', 'Nested commit']); + + const previousCwd = process.cwd(); + process.chdir(neutralCwd); + try { + await expect(getStatus(parent)).resolves.toMatchObject({ current: 'main' }); + await expect(getStatus(nested)).resolves.toMatchObject({ current: 'feature' }); + // Enumeration must continue when one path is not a repo. + const results = await Promise.allSettled([ + getStatus(parent), + getStatus(neutralCwd), + getStatus(nested), + ]); + expect(results[0].status).toBe('fulfilled'); + expect(results[1].status).toBe('rejected'); + expect(results[1].reason?.message || String(results[1].reason)).toMatch(/not a git repository/i); + expect(results[2].status).toBe('fulfilled'); + } finally { + process.chdir(previousCwd); + } + }); }); // --------------------------------------------------------------------------- diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index a0b71cdd..afe5f9e4 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -1073,6 +1073,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => { req.path.startsWith('/api/provider') || req.path.startsWith('/api/session-folders') || req.path.startsWith('/api/small-model') || + req.path.startsWith('/api/walkthrough') || req.path.startsWith('/api/goals') || req.path.startsWith('/api/text') || req.path.startsWith('/api/voice') || diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 0a6dbf8a..803c742e 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -1,6 +1,7 @@ import { registerFsRoutes } from '../fs/routes.js'; import { registerQuotaRoutes } from '../quota/routes.js'; import { registerSmallModelRoutes } from '../small-model/routes.js'; +import { registerWalkthroughRoutes } from '../walkthrough/routes.js'; import { registerSessionGoalRoutes } from '../session-goal/routes.js'; import { registerGitHubRoutes } from '../github/routes.js'; import { registerGitRoutes } from '../git/routes.js'; @@ -67,6 +68,18 @@ export const createFeatureRoutesRuntime = (dependencies) => { return smallModelService; }; + let walkthroughService = null; + const getWalkthroughService = async () => { + if (!walkthroughService) { + const [service, pullRequest] = await Promise.all([ + import('../walkthrough/index.js'), + import('../walkthrough/pull-request.js'), + ]); + walkthroughService = { ...service, getPullRequestDiff: pullRequest.getPullRequestDiff }; + } + return walkthroughService; + }; + const registerRoutes = async (app, routeDependencies) => { const { crypto, @@ -265,6 +278,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { registerQuotaRoutes(app, { getQuotaProviders }); registerSmallModelRoutes(app, { getSmallModelService }); + registerWalkthroughRoutes(app, { getWalkthroughService }); registerSessionGoalRoutes(app); registerGitHubRoutes(app); registerGitRoutes(app); diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 4c799991..9393375b 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -443,6 +443,10 @@ export const createSettingsHelpers = (dependencies) => { const trimmed = candidate.smallModelOverride.trim(); result.smallModelOverride = trimmed.length > 0 ? trimmed : undefined; } + if (typeof candidate.walkthroughModelOverride === 'string') { + const trimmed = candidate.walkthroughModelOverride.trim(); + result.walkthroughModelOverride = trimmed.length > 0 ? trimmed : undefined; + } if (typeof candidate.defaultGitIdentityId === 'string') { const trimmed = candidate.defaultGitIdentityId.trim(); result.defaultGitIdentityId = trimmed.length > 0 ? trimmed : undefined; diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index c71801fe..a9d60a99 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -31,10 +31,48 @@ other runtime API. and as a final utility fallback. 4. Last resort: the session's own model (`preferredModelID`) when no small model resolves anywhere — costlier, but always valid. -- Input clamp: the prompt is truncated to the resolved model's catalog +- Input clamp: the prompt is measured against the resolved model's catalog `limit.context` (minus an output reserve, ~4 chars/token estimate; - conservative default when the model is not in the catalog). Truncation is - reported as `inputTruncated: true` in the response. + conservative default when the model is not in the catalog). `onOverflow` + decides what an oversized prompt means: + - `truncate` (default) clips the tail and reports `inputTruncated: true`. + Correct for callers that degrade gracefully (summaries, commit messages). + - `error` throws a `413` with `code: 'context-too-small'` plus + `requiredChars`/`availableChars`. Correct for callers whose output would be + quietly wrong on a clipped input, so they can ask the user for a roomier + model instead of returning confident nonsense. +- Structured output: pass `responseSchema` (a JSON Schema) to get + schema-shaped JSON back as `text`. Wire support differs per format — + `response_format: {type: 'json_schema'}` for OpenAI-compatible chat, + `text.format` for the Responses API, a forced single tool call for the + Anthropic messages API, and `generationConfig.responseSchema` for Google + (whose OpenAPI-flavored dialect drops unknown JSON Schema keywords). The + ChatGPT-plan codex backend has no equivalent and rejects a schema request + with `code: 'structured-output-unsupported'` rather than silently returning + prose. +- Output budget: `maxOutputTokens` is capped at the catalog's `limit.output` for + the model, and the **same number** is reserved from the input allowance. The + two must not drift — a caller that asks for a large answer while the reserve + stays at the default overruns the context, and the failure looks like a + truncation bug rather than a budgeting one. `describeSmallModel` takes + `outputReserveTokens` so readiness checks agree with what generation will do. + It may be a **function** of `{ contextTokens, outputTokenLimit }` for callers + that want as much answer room as the resolved model allows — they cannot name + a number before knowing which model they got. The resolved value comes back as + `outputTokens`, which is what the caller should then request, so the reserve + and the request are the same number by construction. +- Reasoning models can spend the entire output budget thinking and return + nothing. That case (empty content with `finish_reason: 'length'`, or content + empty while `reasoning_content` is populated) throws with + `code: 'output-exhausted'` so callers can offer a different model instead of + showing a transport error. +- `timeoutMs` overrides the 60s default per call; `signal` lets a caller abort + a request that is no longer wanted. Both apply to every wire format. +- `describeSmallModel()` additionally reports `inputCharBudget`, + `contextTokens`, `contextKnown`, and `structuredOutput`. The last is + tri-state: `true`/`false` from the catalog, `null` when the catalog omits the + field — which it does for roughly half of all models, aggregators and proxies + especially. Callers must treat `null` as "try it", not "unsupported". - `call.js` — wire formats and per-provider auth, replicating OpenCode's plugin auth loaders: - **GitHub Copilot**: fetches the requested model's authenticated `/models` diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 3eb12cb5..17c8949c 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -25,7 +25,44 @@ const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses'; const httpError = async (response, provider) => { const body = await response.text().catch(() => ''); const snippet = body ? `: ${body.slice(0, 300)}` : ''; - return new Error(`${provider} request failed with ${response.status}${snippet}`); + // Callers need the status to tell "this provider rejected the request shape" + // (retryable with a different shape) from "this provider is down". + return Object.assign(new Error(`${provider} request failed with ${response.status}${snippet}`), { + status: response.status, + provider, + }); +}; + +// Callers own two independent reasons to stop: their own abort signal (user +// navigated away, request cancelled) and a per-call deadline. Long-running +// callers such as the diff walkthrough need a deadline well past the default. +const requestSignal = (timeoutMs, signal) => { + const deadline = AbortSignal.timeout(Number(timeoutMs) > 0 ? Number(timeoutMs) : REQUEST_TIMEOUT_MS); + return signal ? AbortSignal.any([deadline, signal]) : deadline; +}; + +const STRUCTURED_OUTPUT_NAME = 'response'; + +// Google's schema dialect is OpenAPI-flavored and rejects JSON Schema keywords +// it does not know, so unsupported keys are dropped rather than passed through. +const GOOGLE_UNSUPPORTED_SCHEMA_KEYS = new Set([ + '$schema', + 'additionalProperties', + 'definitions', + '$defs', + '$ref', + 'strict', +]); + +const toGoogleSchema = (schema) => { + if (Array.isArray(schema)) return schema.map(toGoogleSchema); + if (!schema || typeof schema !== 'object') return schema; + const result = {}; + for (const [key, value] of Object.entries(schema)) { + if (GOOGLE_UNSUPPORTED_SCHEMA_KEYS.has(key)) continue; + result[key] = toGoogleSchema(value); + } + return result; }; // --------------------------------------------------------------------------- @@ -106,7 +143,7 @@ const ensureFreshOpenaiOauth = async (entry) => { // Wire formats // --------------------------------------------------------------------------- -const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, extraBody }) => { +const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, extraBody, responseSchema, timeoutMs, signal }) => { const trimmedBase = baseURL.replace(/\/+$/, ''); console.log('[small-model:diagnostic] request', { provider: providerLabel, @@ -132,9 +169,17 @@ const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, ], max_tokens: maxOutputTokens, stream: false, + ...(responseSchema + ? { + response_format: { + type: 'json_schema', + json_schema: { name: STRUCTURED_OUTPUT_NAME, strict: true, schema: responseSchema }, + }, + } + : {}), ...(extraBody || {}), }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: requestSignal(timeoutMs, signal), }); console.log('[small-model:diagnostic] response', { provider: providerLabel, @@ -171,11 +216,16 @@ const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, .map((part) => (typeof part?.text === 'string' ? part.text : '')) .join(''); } - if (!text.trim() && typeof message?.reasoning_content === 'string' && message.reasoning_content.trim()) { - const finishReason = payload?.choices?.[0]?.finish_reason; - throw new Error( - `${providerLabel} spent the output budget on reasoning and returned no answer` - + (finishReason ? ` (finish_reason: ${finishReason})` : ''), + const finishReason = payload?.choices?.[0]?.finish_reason; + if (!text.trim() && (finishReason === 'length' || (typeof message?.reasoning_content === 'string' && message.reasoning_content.trim()))) { + // The model produced only reasoning, or was cut off before answering. This + // is a budget problem, not a transport problem, and callers can act on it. + throw Object.assign( + new Error( + `${providerLabel} spent the output budget on reasoning and returned no answer` + + (finishReason ? ` (finish_reason: ${finishReason})` : ''), + ), + { code: 'output-exhausted', provider: providerLabel }, ); } if (!text.trim()) { @@ -184,7 +234,7 @@ const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, return text; }; -const callOpenaiResponses = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel }) => { +const callOpenaiResponses = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, responseSchema, timeoutMs, signal }) => { const trimmedBase = baseURL.replace(/\/+$/, ''); const response = await fetch(`${trimmedBase}/responses`, { method: 'POST', @@ -201,10 +251,22 @@ const callOpenaiResponses = async ({ baseURL, headers, modelID, prompt, system, content: [{ type: 'input_text', text: prompt }], }], max_output_tokens: maxOutputTokens, + ...(responseSchema + ? { + text: { + format: { + type: 'json_schema', + name: STRUCTURED_OUTPUT_NAME, + strict: true, + schema: responseSchema, + }, + }, + } + : {}), stream: false, store: false, }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: requestSignal(timeoutMs, signal), }); if (!response.ok) { throw await httpError(response, providerLabel); @@ -224,7 +286,7 @@ const callOpenaiResponses = async ({ baseURL, headers, modelID, prompt, system, return text; }; -const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTokens, providerLabel }) => { +const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTokens, providerLabel, responseSchema, timeoutMs, signal }) => { const response = await fetch(url, { method: 'POST', headers: { @@ -237,13 +299,36 @@ const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTo max_tokens: maxOutputTokens, ...(system ? { system } : {}), messages: [{ role: 'user', content: prompt }], + // The messages API has no response_format; a forced single-tool call is + // the supported way to get schema-shaped output. + ...(responseSchema + ? { + tools: [{ + name: STRUCTURED_OUTPUT_NAME, + description: 'Return the answer in the required structure.', + input_schema: responseSchema, + }], + tool_choice: { type: 'tool', name: STRUCTURED_OUTPUT_NAME }, + } + : {}), }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: requestSignal(timeoutMs, signal), }); if (!response.ok) { throw await httpError(response, providerLabel); } const payload = await response.json(); + + if (responseSchema) { + const toolUse = (payload?.content || []).find( + (part) => part?.type === 'tool_use' && part.name === STRUCTURED_OUTPUT_NAME, + ); + if (!toolUse || typeof toolUse.input !== 'object' || toolUse.input === null) { + throw new Error(`${providerLabel} returned no structured output`); + } + return JSON.stringify(toolUse.input); + } + const text = (payload?.content || []) .filter((part) => part?.type === 'text' && typeof part.text === 'string') .map((part) => part.text) @@ -254,7 +339,7 @@ const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTo return text; }; -const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => callMessages({ +const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => callMessages({ url: 'https://api.anthropic.com/v1/messages', headers: { 'x-api-key': apiKey, @@ -265,6 +350,9 @@ const callAnthropic = async ({ apiKey, modelID, prompt, system, maxOutputTokens system, maxOutputTokens, providerLabel: 'Anthropic', + responseSchema, + timeoutMs, + signal, }); const getCopilotEndpoint = async ({ baseURL, headers, modelID }) => { @@ -312,7 +400,7 @@ const getCopilotEndpoint = async ({ baseURL, headers, modelID }) => { throw new Error(`GitHub Copilot model "${modelID}" has no supported text endpoint`); }; -const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) => { +const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => { const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`; const thinkingConfig = modelID.toLowerCase().startsWith('gemini-3') ? { thinkingLevel: modelID.toLowerCase().includes('flash') ? 'minimal' : 'low' } @@ -327,9 +415,15 @@ const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) body: JSON.stringify({ contents: [{ role: 'user', parts: [{ text: prompt }] }], ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}), - generationConfig: { maxOutputTokens, thinkingConfig }, + generationConfig: { + maxOutputTokens, + thinkingConfig, + ...(responseSchema + ? { responseMimeType: 'application/json', responseSchema: toGoogleSchema(responseSchema) } + : {}), + }, }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: requestSignal(timeoutMs, signal), }); if (!response.ok) { throw await httpError(response, 'Google'); @@ -346,7 +440,7 @@ const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens }) // ChatGPT-plan traffic goes to the codex backend, which only speaks the // streaming Responses API — collect the output_text deltas from the SSE body. -const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, system }) => { +const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, system, timeoutMs, signal }) => { const response = await fetch(CODEX_RESPONSES_URL, { method: 'POST', headers: { @@ -372,7 +466,7 @@ const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, sys stream: true, store: false, }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: requestSignal(timeoutMs, signal), }); if (!response.ok) { throw await httpError(response, 'OpenAI (ChatGPT plan)'); @@ -472,7 +566,7 @@ const readProviderConfig = (workingDirectory, providerID) => { // Dispatch // --------------------------------------------------------------------------- -export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens }) { +export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) { const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS; const providerConfig = readProviderConfig(workingDirectory, providerID); // Match OpenCode's resolveSDK precedence: @@ -516,6 +610,9 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider system, maxOutputTokens: tokens, providerLabel: 'GitHub Copilot', + responseSchema, + timeoutMs, + signal, }; if (endpoint === 'messages') { return callMessages({ @@ -534,6 +631,15 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider } if (providerID === 'openai' && entry.type === 'oauth') { + // The codex backend speaks only the streaming Responses API and rejects + // the structured-output fields, so a schema request fails loudly here + // instead of silently returning free-form prose. + if (responseSchema) { + throw Object.assign( + new Error('The ChatGPT-plan OpenAI login does not support structured output — choose another small model'), + { code: 'structured-output-unsupported' }, + ); + } const fresh = await ensureFreshOpenaiOauth(entry); return callCodexResponses({ accessToken: fresh.access, @@ -541,6 +647,8 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider modelID, prompt, system, + timeoutMs, + signal, }); } @@ -552,10 +660,10 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider } if (providerID === 'anthropic') { - return callAnthropic({ apiKey, modelID, prompt, system, maxOutputTokens: tokens }); + return callAnthropic({ apiKey, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal }); } if (providerID === 'google') { - return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens }); + return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal }); } // Everything else: OpenAI-compatible chat completions against the catalog's @@ -600,5 +708,8 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider maxOutputTokens: tokens, providerLabel: provider?.name || providerID, extraBody, + responseSchema, + timeoutMs, + signal, }); } diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 0b1a45ec..78ef733f 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -635,3 +635,156 @@ describe('callSmallModel — GitHub Copilot endpoint routing', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); }); + +// Structured output has no single wire format: each provider family needs its +// own request shape and its own extraction, and one family cannot do it at all. +// These lock the per-format translation so a provider is never silently sent a +// schema it will ignore. +describe('callSmallModel — structured output', () => { + let fetchMock; + let originalFetch; + + const SCHEMA = { + type: 'object', + properties: { title: { type: 'string' } }, + required: ['title'], + additionalProperties: false, + }; + + beforeEach(() => { + fetchMock = vi.fn(); + originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + readConfig.mockReset(); + readConfig.mockReturnValue({}); + readConfigLayers.mockReset(); + readConfigLayers.mockReturnValue({ mergedConfig: {} }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('sends a json_schema response_format on OpenAI-compatible chat', async () => { + fetchMock.mockResolvedValue(ok('{"title":"ok"}')); + + const text = await callSmallModel({ + auth: { openai: { type: 'api', key: 'sk-test' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'openai', + modelID: 'gpt-5.4-mini', + prompt: 'summarize', + responseSchema: SCHEMA, + }); + + expect(text).toBe('{"title":"ok"}'); + const body = JSON.parse(lastCall(fetchMock).init.body); + expect(body.response_format).toEqual({ + type: 'json_schema', + json_schema: { name: 'response', strict: true, schema: SCHEMA }, + }); + }); + + it('omits response_format entirely when no schema is requested', async () => { + fetchMock.mockResolvedValue(ok('plain text')); + + await callSmallModel({ + auth: { openai: { type: 'api', key: 'sk-test' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'openai', + modelID: 'gpt-5.4-mini', + prompt: 'summarize', + }); + + const body = JSON.parse(lastCall(fetchMock).init.body); + expect(body.response_format).toBeUndefined(); + }); + + it('forces a single tool call on the Anthropic messages API and returns its input', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + content: [ + { type: 'text', text: 'thinking out loud' }, + { type: 'tool_use', name: 'response', input: { title: 'ok' } }, + ], + }), + }); + + const text = await callSmallModel({ + auth: { anthropic: { type: 'api', key: 'sk-ant' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + prompt: 'summarize', + responseSchema: SCHEMA, + }); + + expect(JSON.parse(text)).toEqual({ title: 'ok' }); + const body = JSON.parse(lastCall(fetchMock).init.body); + expect(body.tool_choice).toEqual({ type: 'tool', name: 'response' }); + expect(body.tools[0].input_schema).toEqual(SCHEMA); + }); + + it('fails loudly when Anthropic answers with prose instead of the tool call', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ content: [{ type: 'text', text: 'here you go' }] }), + }); + + await expect(callSmallModel({ + auth: { anthropic: { type: 'api', key: 'sk-ant' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + prompt: 'summarize', + responseSchema: SCHEMA, + })).rejects.toThrow('returned no structured output'); + }); + + it('strips JSON Schema keywords Google rejects', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ candidates: [{ content: { parts: [{ text: '{"title":"ok"}' }] } }] }), + }); + + await callSmallModel({ + auth: { google: { type: 'api', key: 'google-key' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'google', + modelID: 'gemini-2.5-flash', + prompt: 'summarize', + responseSchema: { ...SCHEMA, $schema: 'https://json-schema.org/draft/2020-12/schema' }, + }); + + const body = JSON.parse(lastCall(fetchMock).init.body); + expect(body.generationConfig.responseMimeType).toBe('application/json'); + expect(body.generationConfig.responseSchema).toEqual({ + type: 'object', + properties: { title: { type: 'string' } }, + required: ['title'], + }); + }); + + it('refuses a schema on the ChatGPT-plan backend instead of returning prose', async () => { + await expect(callSmallModel({ + auth: { openai: { type: 'oauth', access: 'token', refresh: 'refresh' } }, + catalog: {}, + workingDirectory: '/proj', + providerID: 'openai', + modelID: 'gpt-5.4-mini', + prompt: 'summarize', + responseSchema: SCHEMA, + })).rejects.toThrow('does not support structured output'); + + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js index 92abbad8..40e5e7f5 100644 --- a/packages/web/server/lib/small-model/index.js +++ b/packages/web/server/lib/small-model/index.js @@ -36,14 +36,46 @@ const readSmallModelSettingsOverride = () => { const DEFAULT_CONTEXT_TOKENS = 64_000; const OUTPUT_RESERVE_TOKENS = 4_000; -const clampPromptToModelLimit = ({ prompt, catalog, providerID, modelID }) => { +/** + * Input budget in characters, given how much of the context the caller intends + * to leave for the answer. The reserve must match the output budget the caller + * will actually request, or the two disagree and the model overruns its context. + */ +export const getModelInputCharBudget = ({ catalog, providerID, modelID, outputReserveTokens }) => { const limit = catalog?.[providerID]?.models?.[modelID]?.limit; - const contextTokens = Number(limit?.context) > 0 ? Number(limit.context) : DEFAULT_CONTEXT_TOKENS; - const inputBudgetTokens = Math.max(1_000, contextTokens - OUTPUT_RESERVE_TOKENS); - const maxChars = inputBudgetTokens * 4; + const known = Number(limit?.context) > 0; + const contextTokens = known ? Number(limit.context) : DEFAULT_CONTEXT_TOKENS; + const reserve = Number(outputReserveTokens) > 0 ? Number(outputReserveTokens) : OUTPUT_RESERVE_TOKENS; + const inputBudgetTokens = Math.max(1_000, contextTokens - reserve); + return { maxChars: inputBudgetTokens * 4, contextTokens, contextKnown: known }; +}; + +/** + * The output budget to actually request: what the caller asked for, capped by + * what the model admits it can emit. Asking for more than `limit.output` is + * rejected outright by some providers and silently ignored by others. + */ +const resolveOutputTokens = ({ catalog, providerID, modelID, maxOutputTokens }) => { + const requested = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : 0; + if (!requested) return undefined; + const limit = Number(catalog?.[providerID]?.models?.[modelID]?.limit?.output); + return limit > 0 ? Math.min(requested, limit) : requested; +}; + +// `truncate` keeps the historical behavior for callers whose prompt losing its +// tail is survivable (summaries, commit messages). `error` is for callers whose +// output would be quietly wrong on a clipped input — they need the failure. +const clampPromptToModelLimit = ({ prompt, catalog, providerID, modelID, onOverflow, outputReserveTokens }) => { + const { maxChars } = getModelInputCharBudget({ catalog, providerID, modelID, outputReserveTokens }); if (prompt.length <= maxChars) { return { prompt, truncated: false }; } + if (onOverflow === 'error') { + throw Object.assign( + new Error(`Input is too large for ${providerID}/${modelID}: ${prompt.length} characters exceeds the ${maxChars} the model's context allows`), + { statusCode: 413, code: 'context-too-small', providerID, modelID, requiredChars: prompt.length, availableChars: maxChars }, + ); + } return { prompt: `${prompt.slice(0, maxChars)}…`, truncated: true }; }; @@ -61,7 +93,7 @@ const readConfiguredSmallModel = (workingDirectory) => { * Generates text with the user's small model, resolved and authenticated * entirely server-side from the OpenCode config and auth store. */ -export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false }) { +export async function generateSmallModelText({ prompt, system, maxOutputTokens, model, directory, preferredProviderID, preferredModelID, restrictToPreferredProvider = false, responseSchema, timeoutMs, signal, onOverflow = 'truncate' }) { if (typeof prompt !== 'string' || !prompt.trim()) { throw Object.assign(new Error('prompt is required'), { statusCode: 400 }); } @@ -101,11 +133,20 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens, ); } + const outputTokens = resolveOutputTokens({ + catalog, + providerID: resolved.providerID, + modelID: resolved.modelID, + maxOutputTokens, + }); + const clamped = clampPromptToModelLimit({ prompt: prompt.trim(), catalog, providerID: resolved.providerID, modelID: resolved.modelID, + onOverflow, + outputReserveTokens: outputTokens, }); const text = await callSmallModel({ @@ -116,7 +157,10 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens, modelID: resolved.modelID, prompt: clamped.prompt, system: typeof system === 'string' && system.trim() ? system.trim() : undefined, - maxOutputTokens, + maxOutputTokens: outputTokens, + responseSchema, + timeoutMs, + signal, }); return { @@ -152,17 +196,71 @@ export function listAuthenticatedProviders() { /** * Reports which model would be used, without calling it. + * + * `inputCharBudget` and `structuredOutput` let callers refuse work before + * spending a request: the walkthrough needs both a big enough context and + * schema-shaped output, and would rather tell the user to pick another model + * than send a doomed prompt. `structuredOutput` is deliberately tri-state — + * the catalog omits the field for roughly half of all models (aggregators and + * proxies especially), and treating "unknown" as "unsupported" would hide + * models that work fine. */ -export async function describeSmallModel({ directory, preferredProviderID, preferredModelID } = {}) { +/** + * The reserve, resolved against the model that was actually picked. + * + * A caller that wants "as much answer room as this model allows" cannot state a + * number up front — it does not know which model it will get. Passing a + * function lets it decide once the limits are known, and keeps the reserve and + * the eventual request the same number by construction. + */ +const resolveReserveTokens = (outputReserveTokens, limits) => ( + typeof outputReserveTokens === 'function' ? outputReserveTokens(limits) : outputReserveTokens +); + +export async function describeSmallModel({ directory, preferredProviderID, preferredModelID, outputReserveTokens, overrideModel } = {}) { const auth = readAuthFile(); const catalog = await getModelCatalog().catch(() => ({})); - const resolved = resolveSmallModel({ - auth, + // A caller with its own model setting (the diff walkthrough) outranks the + // small-model chain entirely — it asked for this model on purpose. + const explicit = parseModelRef(overrideModel); + const resolved = explicit + ? { ...explicit, source: 'request' } + : resolveSmallModel({ + auth, + catalog, + settingsSmallModel: readSmallModelSettingsOverride(), + configSmallModel: readConfiguredSmallModel(directory), + preferredProviderID, + preferredModelID, + }); + if (!resolved) return resolved; + + const entry = catalog?.[resolved.providerID]?.models?.[resolved.modelID]; + const outputTokenLimit = Number(entry?.limit?.output) > 0 ? Number(entry.limit.output) : null; + // Two passes: the first only to learn the context, which a caller-supplied + // reserve function needs before it can answer. + const { contextTokens, contextKnown } = getModelInputCharBudget({ catalog, - settingsSmallModel: readSmallModelSettingsOverride(), - configSmallModel: readConfiguredSmallModel(directory), - preferredProviderID, - preferredModelID, + providerID: resolved.providerID, + modelID: resolved.modelID, }); - return resolved; + const reserveTokens = resolveReserveTokens(outputReserveTokens, { contextTokens, outputTokenLimit }); + const { maxChars } = getModelInputCharBudget({ + catalog, + providerID: resolved.providerID, + modelID: resolved.modelID, + outputReserveTokens: reserveTokens, + }); + + return { + ...resolved, + inputCharBudget: maxChars, + contextTokens, + contextKnown, + // What the caller should ask for, so the request and the reserve above + // cannot drift apart. + outputTokens: Number(reserveTokens) > 0 ? Number(reserveTokens) : null, + structuredOutput: typeof entry?.structured_output === 'boolean' ? entry.structured_output : null, + outputTokenLimit, + }; } diff --git a/packages/web/server/lib/small-model/index.test.js b/packages/web/server/lib/small-model/index.test.js new file mode 100644 index 00000000..14e5f015 --- /dev/null +++ b/packages/web/server/lib/small-model/index.test.js @@ -0,0 +1,244 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The settings override is read straight from disk at module load, so without +// this the suite would resolve whatever small model the developer running it +// happens to have configured. +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'small-model-settings-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +vi.mock('../opencode/auth.js', () => ({ readAuthFile: vi.fn() })); +vi.mock('../opencode/shared.js', () => ({ + readConfig: vi.fn(), + readConfigLayers: vi.fn(), +})); +vi.mock('./catalog.js', () => ({ + getModelCatalog: vi.fn(), + getCatalogProvider: vi.fn(), +})); +vi.mock('./call.js', () => ({ callSmallModel: vi.fn() })); + +const { generateSmallModelText, describeSmallModel } = await import('./index.js'); +const { readAuthFile } = await import('../opencode/auth.js'); +const { readConfigLayers } = await import('../opencode/shared.js'); +const { getModelCatalog } = await import('./catalog.js'); +const { callSmallModel } = await import('./call.js'); + +// 8k context leaves 4k input tokens after the output reserve → 16k chars. +const CATALOG = { + anthropic: { + id: 'anthropic', + models: { + 'claude-haiku-4-5': { id: 'claude-haiku-4-5', limit: { context: 8_000 }, structured_output: true }, + 'legacy-tiny': { id: 'legacy-tiny', limit: { context: 8_000 }, structured_output: false }, + 'unlisted-capability': { id: 'unlisted-capability', limit: { context: 8_000 } }, + }, + }, +}; + +const request = (overrides = {}) => ({ + prompt: 'x'.repeat(20_000), + model: 'anthropic/claude-haiku-4-5', + directory: '/proj', + ...overrides, +}); + +describe('generateSmallModelText — oversized input', () => { + beforeEach(() => { + readAuthFile.mockReturnValue({ anthropic: { type: 'api', key: 'sk-ant' } }); + readConfigLayers.mockReturnValue({ mergedConfig: {} }); + getModelCatalog.mockResolvedValue(CATALOG); + callSmallModel.mockReset(); + callSmallModel.mockResolvedValue('generated'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('truncates and flags the response by default', async () => { + const result = await generateSmallModelText(request()); + + expect(result.inputTruncated).toBe(true); + const sent = callSmallModel.mock.calls.at(-1)[0].prompt; + expect(sent.length).toBeLessThan(20_000); + expect(sent.endsWith('…')).toBe(true); + }); + + it('refuses without calling the provider when the caller cannot survive truncation', async () => { + await expect(generateSmallModelText(request({ onOverflow: 'error' }))) + .rejects.toMatchObject({ + statusCode: 413, + code: 'context-too-small', + requiredChars: 20_000, + availableChars: 16_000, + }); + + expect(callSmallModel).not.toHaveBeenCalled(); + }); + + it('leaves an input that fits untouched under either policy', async () => { + const result = await generateSmallModelText(request({ prompt: 'short prompt', onOverflow: 'error' })); + + expect(result.inputTruncated).toBeUndefined(); + expect(callSmallModel.mock.calls.at(-1)[0].prompt).toBe('short prompt'); + }); + + it('forwards schema, timeout, and abort signal to the transport', async () => { + const controller = new AbortController(); + const schema = { type: 'object' }; + + await generateSmallModelText(request({ + prompt: 'short', + responseSchema: schema, + timeoutMs: 240_000, + signal: controller.signal, + })); + + expect(callSmallModel.mock.calls.at(-1)[0]).toMatchObject({ + responseSchema: schema, + timeoutMs: 240_000, + signal: controller.signal, + }); + }); +}); + +describe('describeSmallModel — capability reporting', () => { + beforeEach(() => { + readAuthFile.mockReturnValue({ anthropic: { type: 'api', key: 'sk-ant' } }); + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/claude-haiku-4-5' } }); + getModelCatalog.mockResolvedValue(CATALOG); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('reports the input budget and a known structured-output capability', async () => { + const described = await describeSmallModel({ directory: '/proj' }); + + expect(described).toMatchObject({ + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + inputCharBudget: 16_000, + contextTokens: 8_000, + contextKnown: true, + structuredOutput: true, + }); + }); + + it('reports an explicit false so callers can block the model', async () => { + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/legacy-tiny' } }); + + const described = await describeSmallModel({ directory: '/proj' }); + + expect(described.structuredOutput).toBe(false); + }); + + it('reports null — not false — when the catalog omits the capability', async () => { + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/unlisted-capability' } }); + + const described = await describeSmallModel({ directory: '/proj' }); + + expect(described.structuredOutput).toBeNull(); + }); +}); + +// The input reserve and the requested output budget are the same number seen +// from two sides; if they drift, a caller that asks for a large answer overruns +// the model's context and the failure looks like a truncation bug. +describe('output budget and input reserve', () => { + beforeEach(() => { + readAuthFile.mockReturnValue({ anthropic: { type: 'api', key: 'sk-ant' } }); + readConfigLayers.mockReturnValue({ mergedConfig: {} }); + getModelCatalog.mockResolvedValue({ + anthropic: { + id: 'anthropic', + models: { + roomy: { id: 'roomy', limit: { context: 100_000, output: 8_000 } }, + unlisted: { id: 'unlisted', limit: { context: 100_000 } }, + }, + }, + }); + callSmallModel.mockReset(); + callSmallModel.mockResolvedValue('generated'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('caps the request at the model\'s advertised output limit', async () => { + await generateSmallModelText({ + prompt: 'short', + model: 'anthropic/roomy', + maxOutputTokens: 24_000, + }); + + expect(callSmallModel.mock.calls.at(-1)[0].maxOutputTokens).toBe(8_000); + }); + + it('honours the requested budget when the catalog states no output limit', async () => { + await generateSmallModelText({ + prompt: 'short', + model: 'anthropic/unlisted', + maxOutputTokens: 24_000, + }); + + expect(callSmallModel.mock.calls.at(-1)[0].maxOutputTokens).toBe(24_000); + }); + + it('reserves exactly the requested output budget from the input allowance', async () => { + // 100k context − 24k reserved for the answer = 76k tokens ≈ 304k chars. + await expect(generateSmallModelText({ + prompt: 'x'.repeat(304_001), + model: 'anthropic/unlisted', + maxOutputTokens: 24_000, + onOverflow: 'error', + })).rejects.toMatchObject({ code: 'context-too-small', availableChars: 304_000 }); + + await expect(generateSmallModelText({ + prompt: 'x'.repeat(303_999), + model: 'anthropic/unlisted', + maxOutputTokens: 24_000, + onOverflow: 'error', + })).resolves.toBeTruthy(); + }); + + it('reports the same budget through describeSmallModel', async () => { + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/unlisted' } }); + + const described = await describeSmallModel({ directory: '/proj', outputReserveTokens: 24_000 }); + + expect(described.inputCharBudget).toBe(304_000); + }); + + // A caller that wants "as much room as this model allows" cannot name a + // number before knowing which model it got, so it hands over the decision. + it('lets the reserve be decided from the resolved model\'s limits', async () => { + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/roomy' } }); + + const described = await describeSmallModel({ + directory: '/proj', + outputReserveTokens: ({ contextTokens, outputTokenLimit }) => Math.min(contextTokens / 10, outputTokenLimit), + }); + + // 100k context, 8k output limit -> 8k reserved, leaving 92k tokens. + expect(described.outputTokens).toBe(8_000); + expect(described.inputCharBudget).toBe(92_000 * 4); + }); + + it('reports the reserve it used so the caller can request the same number', async () => { + readConfigLayers.mockReturnValue({ mergedConfig: { small_model: 'anthropic/unlisted' } }); + + const described = await describeSmallModel({ directory: '/proj', outputReserveTokens: 24_000 }); + + expect(described.outputTokens).toBe(24_000); + }); +}); + +afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); +}); diff --git a/packages/web/server/lib/small-model/routes.js b/packages/web/server/lib/small-model/routes.js index c53143de..2e66063f 100644 --- a/packages/web/server/lib/small-model/routes.js +++ b/packages/web/server/lib/small-model/routes.js @@ -38,7 +38,10 @@ export function registerSmallModelRoutes(app, { getSmallModelService }) { if (statusCode >= 500) { console.error('Small model generation failed:', error); } - res.status(statusCode).json({ error: error.message || 'Small model generation failed' }); + res.status(statusCode).json({ + error: error.message || 'Small model generation failed', + ...(error?.code ? { code: error.code } : {}), + }); } }); } diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md new file mode 100644 index 00000000..892d403d --- /dev/null +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -0,0 +1,371 @@ +# Walkthrough + +Generates a guided, ordered reading path through a diff: the small model groups +related hunks into stops and chapters and explains each group, and the UI +renders those stops interleaved with the code they describe. + +Generation is **always user-initiated**. Nothing here runs on a timer, on a file +change, or as a side effect of opening a panel — it spends tokens, so a person +has to ask for it. + +## Files + +- `hunks.js` — parses a unified diff into files and hunks and assigns each hunk + a stable id. +- `generated.js` — recognises tool-produced files that are kept out of the + model's input. +- `sources.js` — turns a source descriptor into diff *sections*. +- `digest.js` — builds the model-facing digest and the alias↔id mapping. +- `prompt.js` — system prompt, size guidance, previous-walkthrough section, and + `PROMPT_VERSION`. +- `schema.js` — response schema, response normalization, tolerant JSON parsing. +- `store.js` — content-addressed cache entries plus mutable pointers. +- `pull-request.js` — PR diffs via the shared GitHub octokit helper. +- `model-settings.js` — the feature's own model override. +- `languages.js` — the languages the prose may be written in. +- `index.js` — orchestration. +- `routes.js` — `/api/walkthrough*`. + +## Hunk identity + +`hunks.js` is the only place that decides what a hunk is or what its id is. The +client never recomputes ids; it receives the current hunk index (id → patch) +alongside the walkthrough and matches ids to ids. Two implementations of the +same hash would have to agree byte-for-byte forever, and the first one to drift +would silently mis-anchor every stop. + +An id is `::`, with a `-2`, `-3`, … suffix +for byte-identical hunks repeated inside one file. + +Two consequences fall out of hashing the content: + +- Editing a hunk changes its id, so an anchor that no longer resolves is + **proof** the code it described changed. Staleness needs no heuristics. +- Editing one hunk does not disturb its neighbours, so a small edit invalidates + only the stops that actually covered it. + +`scope` keeps staged and unstaged versions of the same lines apart, so a stop +written against staged code never silently re-anchors onto an unstaged edit. + +## Sources + +| Kind | Sections | Notes | +|---|---|---| +| `working-tree` (`all` \| `staged` \| `working`) | `staged`, `working` | Untracked files are fetched individually because `git diff` omits them | +| `branch` | `branch` | `getRangeDiff` uses three-dot `base...head`, so work merged in from the base branch is excluded | +| `pr` | `pr:` | GitHub returns the merge-base diff, matching the branch semantics | + +The panel offers the current branch's pull request on its own: it registers with +the shared GitHub PR status store (`useGitHubPrStatusStore`) rather than waiting +for the pull request panel to have been visited. That store already dedupes +concurrent requests by signature and throttles by TTL, so several panels asking +the same question produce one call to GitHub. + +## No truncation + +Within what it covers, the digest is complete. When it does not fit the resolved +model's context, generation is **refused** (`409`, `code: 'context-too-small'`) +so the user can pick a roomier model. A walkthrough written against a silently +clipped diff is confidently wrong in a way no reader can detect, which is worse +than no walkthrough. + +## Generated files + +`generated.js` excludes tool-produced files — lockfiles, minified bundles, +codegen, snapshots — from the digest by **name, never by size**. A lockfile can +be larger than the entire change around it and carries no intent, so sending it +wastes context that real code needs. + +They are excluded, not hidden: they carry no hunk aliases (so nothing can anchor +to them), but they are still parsed, still returned to the client, and still +appear in the uncovered tail. The matcher is deliberately conservative — +`src/lock.ts` and `src/generator.ts` are authored code — because a false +positive silently drops real code from a review, which is the exact failure this +feature exists to prevent. + +When a change consists only of generated files, generation is refused with +`code: 'only-generated'` rather than the misleading "nothing changed". + +## Model selection + +The walkthrough has its own model setting (Settings → Sessions → Changes +Walkthrough Model), read by `model-settings.js`: + +`walkthroughModelOverride` (`provider/model`) is the whole contract: set, that +model is used for this feature and nothing else; unset or empty, generation +falls back to whatever the small-model chain resolves to. Choosing a model *is* +the opt-out, so there is no separate toggle to disagree with the picker — the +settings picker simply shows "Small model will be used" until a choice is made, +and clearing it restores the fallback. + +The separation exists because the two roles pull in opposite directions: the +small model is chosen to be cheap and fast for recaps and commit messages, while +this one needs schema-shaped output and enough context for a whole diff. Forcing +one setting to serve both means degrading one feature to fix the other. + +A review can also override the model for itself: `GET`/`POST` accept a `model` +(`provider/model`) that outranks both the setting and the small-model chain. +That choice is panel state, not a settings edit — picking a roomier model for +one risky change should not silently redefine the default for every future one. +It needs no storage: the model that produced a walkthrough is already recorded +in its cache entry, so reopening a panel resolves the picker as *explicit choice +→ model that generated what is on screen → settings*. Because the model is part +of the cache key, switching models and back returns the earlier review for free. + +The picker hides models the catalog reports as `structured_output: false` — +offering them would move the same refusal one click later — and, like the small +model picker, only shows providers with a usable login. The in-panel picker on a +blocked walkthrough writes this setting too, so recovering from a refusal never +silently changes the model behind commit messages. + +## Output language + +A walkthrough its reader cannot read is worth nothing, so the prose language is +a per-review choice in the panel header, defaulting to the interface language. +Like the model it is request state rather than a setting: it travels as +`language` on `GET` and `POST`, and it is not persisted, because the language a +walkthrough was written in is already recorded in its cache entry and returned +as `language` — which makes it the better default on reopen than any remembered +preference. Resolution is *explicit choice → language of what is on screen → +interface locale*. + +Only prose is translated. Hunk aliases are keys that resolve back to hunk ids, +and `icon`/`importance` are validated against fixed English values, so a +translated one is dropped by the normalizer — losing an anchor or a style +silently. The prompt says so explicitly. + +`languages.js` owns the accepted tags; they match the UI's `Locale` union, and +anything else — unknown, malformed, absent — resolves to English rather than +failing the request. The default language adds no instruction at all, since the +system prompt is already English. + +The language is part of the cache key. Without that, asking for a translation +would be answered with the untranslated entry that was already there; with it, +switching language and back returns the earlier walkthrough for free, exactly as +switching models does. + +The read follows the same rule: `GET` builds the cache key for the language and +model being asked for and answers from that entry when it exists, before +consulting the pointer. The pointer alone was not enough — it records what was +generated here *last*, which after a switch is the answer to a different +question, and the panel kept showing the English review while the picker said +Ukrainian and the Ukrainian one sat unused in the cache. The key is computed +from the diff the read already parsed, so this costs one file read and no extra +git work. + +Falling back to the pointer still happens when nothing exists in the requested +language: an English review beats an empty panel, and the response says which +language it is in so the panel can say so too — it shows a banner naming what is +on screen versus what was asked for, and only once a read has settled, because +claiming something is missing while still looking for it is the same flicker in +another place. Serving an entry makes it the last +one shown here, so the pointer follows it — otherwise a regeneration would +re-author from a walkthrough the reader is not looking at. + +Attaching to a running job still ignores the language of the second request, +because the job already has one. That matches how the model behaves. + +## Structured output, and what happens when it is refused + +`structured_output: false` in the catalog blocks generation up front. A +**missing** capability field does not block — the catalog omits it for roughly +half of all models, and treating unknown as unsupported would hide models that +work. + +Providers that do not declare the capability sometimes reject the schema at +request time (a plain `400`, or Alibaba/Qwen's "'messages' must contain the word +'json'"). A rejected request shape is not a dead end, so a `4xx` on a schema +request triggers exactly one retry with the schema moved into the prompt and the +tolerant parser handling the result. Only if *that* fails to yield usable JSON is +`structured-output-unsupported` reported — at which point it is a real capability +problem the user can fix by switching model. + +The refusal is then remembered per `provider/model` and the fallback goes first +from then on. Without that, every generation on such a provider pays for a call +whose failure is already known. The memory is process-lifetime only on purpose: +a provider that gains structured-output support should not need a settings +change to be tried again, and one wasted first attempt after a restart is cheap. + +The system prompt states "respond with a single JSON object" explicitly, which +also satisfies the providers that scan the request for the word `json` before +honouring `response_format`. That keeps them on the fast path instead of paying +for a wasted first call. + +## Output budget + +A walkthrough itself is only a few thousand tokens of JSON. The budget exists +for what comes before it: reasoning models spend the same allowance thinking and +return nothing when it runs out, which is a bill for no answer. + +The ask is therefore derived from the resolved model rather than fixed: +`min(96k, max(24k, a quarter of the context))`, then capped by the catalog's +`limit.output`. A flat 24k was the same number for a 64k-context model and for +one that admits to 384k output tokens and a million of context — and on the +latter it was the only reason generation failed. + +The bounds are not arbitrary. The **same number is reserved from the input +allowance**, so the ceiling and the context share are what stop a generous +answer budget from eating the diff it is supposed to describe; the 24k floor is +what this feature always asked for, so no model gets less room than before. A +model whose own `limit.output` is below the floor gets its limit, because asking +for more than a provider allows is rejected by some and ignored by others. + +`describeSmallModel` decides this once — the walkthrough hands it the rule as a +function and reads back `outputTokens` — so the reserve and the request cannot +drift apart. + +When a model exhausts even that, `code: 'output-exhausted'` reports it as what +it is: this model cannot finish this job, so pick another or review a narrower +scope. + +## Caching and staleness + +**Cache entries** (`entries/.json`) are immutable and content-addressed. +The key covers walkthrough version, prompt version, repo root, source, provider, +model, output language, and every file's path/status/hunk-ids. The key is computed from the +*current* diff, so a hit means the walkthrough was written about exactly this +code; there is no freshness question to ask of an entry, because staleness is a +miss. Returning the working tree to an earlier state therefore costs nothing. + +**Pointers** (`pointers/.json`) are mutable and hold +`{ cacheKey, generatedAt, repoRoot, sourceKey }`. They answer what the cache +cannot: which walkthrough was last shown here, and has the code moved since. A +pointer whose entry has been evicted reads as "no walkthrough" — truthful, and +the next generation overwrites it. + +A pointer is a *fallback*, not the primary lookup. A read that can name the +entry it wants — same diff, same model, same language — goes straight to it and +moves the pointer there; the pointer answers only when nothing matches the +request exactly. + +Regeneration is manual and re-authors rather than merges: the previous +walkthrough goes into the prompt as prose so the model can keep what is still +true, with its anchors deliberately stripped so everything is re-anchored +against the current digest. Splicing partially-regenerated chapters into an old +narrative was considered and rejected — the seams produce stops that contradict +each other, and the failure is invisible. + +## Hygiene + +- Entries are bounded by count and total size (200 / 50 MB) and evicted + least-recently-used after a write that crosses a limit. Nothing is dropped for + being merely old: an entry costs kilobytes and stays reachable if the working + tree ever returns to that state. +- Writes are tmp+rename; reads enforce a size limit and validate the version, so + a corrupt file is a miss rather than a crash. +- Pointers are never evicted by size. They are pruned only when their repository + is **provably gone**, deferred off the request path, fully asynchronous, and + capped. + +That last point is deliberate rather than incidental. The desktop app hosts this +server inside the Electron main process, so a synchronous loop here would stall +IPC and the window rather than a single request — and the paths being checked +are user repositories, where a worktree on an unplugged drive or an unreachable +share can make one existence check hang for seconds. Only `ENOENT` deletes a +pointer: unreachable is not the same as gone, and a dead share must not cost the +user their walkthroughs. + +## Coverage + +The model is told it may leave mechanical changes out. Whatever it does not +anchor is computed as `uncoveredHunkIds` and rendered as a collapsed tail, so +the reader can always answer "have I seen everything that changed". No hunk +disappears from the view. + +## Cost of reading + +Opening the panel is a `GET` that runs the whole git pipeline, so it is kept as +cheap as the data allows: + +- Untracked files come from `listUntrackedPaths` (a plain `ls-files`) rather + than `getStatus`, which also computes ahead/behind, diff stats, and merge + state — roughly 180ms of work for an answer this module discards. +- Their diffs go through `getUntrackedDiffs`, which resolves the repository once + for the whole batch and bounds concurrency, instead of one `getDiff` per file + each re-resolving the repository. +- Readiness is computed from the same diff as the walkthrough itself. It used to + be its own endpoint that the client called in parallel, which meant every + panel open ran the entire pipeline twice. + +On a working tree of 80 files and 138 hunks this took a panel open from ~800ms +to ~340ms. Parsing and digest building are ~3ms of that; everything else is git. + +## Generation outlives its request + +A dropped connection and a deliberate cancel are indistinguishable at the +socket, so tying generation to the request lifetime meant an accidental refresh +threw away a minute of paid-for work. Instead: + +- Jobs live in a module-level map keyed by repository + source. A second + `generate` for the same source **attaches to the running job** rather than + starting a rival one — pressing the button again after a refresh costs + nothing extra. +- Leaving the page detaches the client; the job finishes and writes its cache + entry, so coming back finds the result waiting. +- `GET /api/walkthrough` reports `generating`, letting a returning client show + progress instead of an empty panel, and the client re-attaches so the result + lands somewhere. +- Stopping is an explicit `POST /api/walkthrough/cancel`. That is the only thing + that aborts the model call. + +The cost of this is that a job everyone abandoned keeps spending until it +finishes; the generation timeout bounds it. + +That timeout is a hang guard, not a pace-setter, and it scales with the diff: +120s plus 1s per hunk, capped at 15 minutes. A fixed number made a three-hunk +edit and a 500-hunk pull request wait the same, which guarded nothing in the +small case and risked killing the big one just short of the finish line. It errs +long on purpose — losing a nearly-complete generation costs real money, while an +over-long deadline only holds a job slot. Note that the schema fallback can use +the deadline twice, once per attempt. + +## Progress + +A running job records a coarse stage: `collecting` (reading the diff, which for +a pull request is seconds of network), `asking`, `retrying` when a provider +rejects the schema and the prompt-side fallback runs, and `assembling`. + +Only phases a person can wait on are named. Building the digest and reading the +cache take single-digit milliseconds; giving them rows would imply progress that +is not happening. + +`retrying` exists for diagnostics but is **not shown**: from outside it is the +same wait on the same model, and naming our fallback only raises the question of +what it is. The client folds it into `asking`. + +The client also paces the display, holding each step for a floor before +revealing the next and keeping the list on screen briefly after the work ends. +Assembling takes milliseconds, so without that the result replaces the list +before the final step is ever seen finishing — naming a step the user never +observes is worse than not naming it. The cost is well under a second at the end +of a wait measured in minutes. + +`GET /api/walkthrough/progress` reads the job registry and nothing else — no git, +no network — so the client can poll it once a second. The full read must never +be used for this: it re-runs the whole git pipeline. + +## Routes + +- `GET /api/walkthrough?directory&source&model&language` — last walkthrough, the + current hunk index, staleness, and `readiness`. Never generates. `language` + matters here because readiness is measured against the prompt that would be + sent, and the language instruction is part of it. +- `POST /api/walkthrough/generate` — `{ directory, source, force, model, language }`. Survives + client disconnects; a concurrent call for the same source joins the running + job. +- `GET /api/walkthrough/progress?directory&source` — the current stage, or + `null`. Memory-only and safe to poll. +- `POST /api/walkthrough/cancel` — `{ directory, source }`; aborts a running + generation. + +There is deliberately no delete route: regeneration covers the need, and an +endpoint nothing calls is a maintenance surface that rots untested. + +Registered lazily from `feature-routes-runtime.js`. `/api/walkthrough` is in the +JSON body-parser allowlist in `core-routes.js`. + +## Runtime availability + +Web, desktop, and hosted mobile reach these routes normally. VS Code serves Git +through its own bridge rather than the OpenChamber Git routes, so the feature is +not offered there; the surface is also gated to tablet width and above. diff --git a/packages/web/server/lib/walkthrough/digest.js b/packages/web/server/lib/walkthrough/digest.js new file mode 100644 index 00000000..e74903d7 --- /dev/null +++ b/packages/web/server/lib/walkthrough/digest.js @@ -0,0 +1,73 @@ +import { isGeneratedArtifact } from './generated.js'; +import { parseDiffFiles } from './hunks.js'; + +// The digest is what the model actually reads. Within what it covers there is +// no truncation: a diff that does not fit the model's context is refused +// upstream so the user can pick a roomier model, because a walkthrough written +// against a silently clipped diff is confidently wrong in a way nobody can see. +// +// The one thing it does not cover is tool-produced files (lockfiles, minified +// bundles, codegen). Those are excluded by name, not by size, and they are not +// hidden — they carry no hunk aliases, so nothing can anchor to them, and they +// surface in the uncovered tail like any other unreviewed change. + +/** + * Parse sections into files and build the model-facing digest. + * + * Hunks are exposed to the model as request-local aliases (`h1`, `h2`, …) + * rather than their real ids: the aliases are far cheaper in tokens, and a + * model cannot invent a plausible-looking id for a hunk that does not exist. + */ +export function buildDigest(sections) { + const files = []; + for (const section of sections) { + const parsed = parseDiffFiles(section.patch, section.scope); + for (const file of parsed.files) { + files.push({ ...file, scope: section.scope, generated: isGeneratedArtifact(file.path) }); + } + } + + const idByAlias = new Map(); + const aliasById = new Map(); + let counter = 0; + + const digestFiles = files + .filter((file) => !file.generated) + .map((file) => ({ + path: file.path, + ...(file.oldPath ? { oldPath: file.oldPath } : {}), + status: file.status, + ...(file.scope !== 'branch' && !file.scope.startsWith('pr:') ? { scope: file.scope } : {}), + ...(file.binary ? { binary: true } : {}), + hunks: file.hunks.map((hunk) => { + counter += 1; + const alias = `h${counter}`; + idByAlias.set(alias, hunk.id); + aliasById.set(hunk.id, alias); + return { + alias, + header: hunk.header, + oldLines: `${hunk.oldStart}-${hunk.oldStart + Math.max(0, hunk.oldLines - 1)}`, + newLines: `${hunk.newStart}-${hunk.newStart + Math.max(0, hunk.newLines - 1)}`, + added: hunk.added, + deleted: hunk.deleted, + patch: hunk.body, + }; + }), + })); + + const generatedFiles = files.filter((file) => file.generated); + + return { + digest: { files: digestFiles }, + files, + idByAlias, + aliasById, + // Reviewable counts: what the model is actually asked about. The excluded + // files still reach the client through `files`. + hunkCount: counter, + fileCount: digestFiles.length, + generatedFileCount: generatedFiles.length, + generatedPaths: generatedFiles.map((file) => file.path), + }; +} diff --git a/packages/web/server/lib/walkthrough/digest.test.js b/packages/web/server/lib/walkthrough/digest.test.js new file mode 100644 index 00000000..468e9fef --- /dev/null +++ b/packages/web/server/lib/walkthrough/digest.test.js @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { buildDigest } from './digest.js'; +import { isGeneratedArtifact } from './generated.js'; + +const fileDiff = (path, body = '+const a = 1;') => `diff --git a/${path} b/${path} +--- a/${path} ++++ b/${path} +@@ -1,1 +1,2 @@ +${body} +`; + +describe('isGeneratedArtifact', () => { + it('matches lockfiles by exact name anywhere in the tree', () => { + expect(isGeneratedArtifact('bun.lock')).toBe(true); + expect(isGeneratedArtifact('packages/web/package-lock.json')).toBe(true); + expect(isGeneratedArtifact('Cargo.lock')).toBe(true); + expect(isGeneratedArtifact('go.sum')).toBe(true); + }); + + it('matches conventional generated output', () => { + expect(isGeneratedArtifact('dist/app.min.js')).toBe(true); + expect(isGeneratedArtifact('src/api.generated.ts')).toBe(true); + expect(isGeneratedArtifact('proto/user.pb.go')).toBe(true); + expect(isGeneratedArtifact('src/__snapshots__/App.test.tsx.snap')).toBe(true); + expect(isGeneratedArtifact('src/generated/client.ts')).toBe(true); + }); + + it('does not match authored source that merely looks similar', () => { + // A false positive silently removes real code from the review, so these + // near-misses matter more than the hits. + expect(isGeneratedArtifact('src/lock.ts')).toBe(false); + expect(isGeneratedArtifact('src/useLockfile.ts')).toBe(false); + expect(isGeneratedArtifact('src/generator.ts')).toBe(false); + expect(isGeneratedArtifact('src/minifier.ts')).toBe(false); + expect(isGeneratedArtifact('packages/ui/src/lib/i18n/messages/en.ts')).toBe(false); + }); +}); + +describe('buildDigest', () => { + const sections = [{ + scope: 'working', + patch: [fileDiff('src/a.ts'), fileDiff('bun.lock', '+ "version": "2",'), fileDiff('src/b.ts')].join(''), + }]; + + it('keeps generated files out of what the model sees', () => { + const built = buildDigest(sections); + + expect(built.digest.files.map((file) => file.path)).toEqual(['src/a.ts', 'src/b.ts']); + expect(JSON.stringify(built.digest)).not.toContain('bun.lock'); + expect(built.fileCount).toBe(2); + expect(built.hunkCount).toBe(2); + expect(built.generatedFileCount).toBe(1); + expect(built.generatedPaths).toEqual(['bun.lock']); + }); + + it('still returns generated files to the client so nothing disappears', () => { + const built = buildDigest(sections); + + expect(built.files.map((file) => file.path)).toEqual(['src/a.ts', 'bun.lock', 'src/b.ts']); + expect(built.files.find((file) => file.path === 'bun.lock')?.generated).toBe(true); + }); + + it('gives aliases only to reviewable hunks', () => { + const built = buildDigest(sections); + const aliased = [...built.idByAlias.values()]; + + expect([...built.idByAlias.keys()]).toEqual(['h1', 'h2']); + expect(aliased.some((id) => id.includes('bun.lock'))).toBe(false); + }); + + it('reports zero reviewable hunks when only generated files changed', () => { + const built = buildDigest([{ scope: 'working', patch: fileDiff('bun.lock', '+ "version": "2",') }]); + + expect(built.hunkCount).toBe(0); + expect(built.files).toHaveLength(1); + expect(built.generatedFileCount).toBe(1); + }); +}); diff --git a/packages/web/server/lib/walkthrough/generated.js b/packages/web/server/lib/walkthrough/generated.js new file mode 100644 index 00000000..c4f825e9 --- /dev/null +++ b/packages/web/server/lib/walkthrough/generated.js @@ -0,0 +1,58 @@ +// Files that are produced by a tool rather than written by a person. Their +// diffs are enormous, carry no intent, and are exactly the kind of content a +// reviewer scrolls past — but they are still part of the change, so they are +// never hidden: they are kept out of the model's input and shown in the +// uncovered tail instead. + +const LOCKFILES = new Set([ + 'bun.lock', + 'bun.lockb', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'composer.lock', + 'Gemfile.lock', + 'Pipfile.lock', + 'poetry.lock', + 'uv.lock', + 'Cargo.lock', + 'go.sum', + 'mix.lock', + 'pubspec.lock', + 'flake.lock', + 'gradle.lockfile', + 'packages.lock.json', + 'deno.lock', +]); + +const GENERATED_PATTERNS = [ + // Minified or bundled output committed to the repository. + /\.min\.(js|css)$/i, + /\.(js|css)\.map$/i, + // Conventional "this file is generated" naming. + /\.generated\.[^/]+$/i, + /\.gen\.[^/]+$/i, + /(^|\/)generated\//i, + // Protocol buffers and similar codegen. + /\.pb\.(go|ts|js)$/i, + /_pb2(_grpc)?\.py$/i, + /\.pb\.cc$|\.pb\.h$/i, + // Test snapshots. + /(^|\/)__snapshots__\//, + /\.snap$/, +]; + +/** + * Whether a path is a tool-produced artifact rather than authored source. + * + * Deliberately conservative: a false positive silently removes real code from + * the review, which is the failure this whole feature exists to prevent. Only + * unambiguous, conventional names qualify. + */ +export function isGeneratedArtifact(filePath) { + if (typeof filePath !== 'string' || !filePath) return false; + const name = filePath.split('/').pop() || ''; + if (LOCKFILES.has(name)) return true; + return GENERATED_PATTERNS.some((pattern) => pattern.test(filePath)); +} diff --git a/packages/web/server/lib/walkthrough/hunks.js b/packages/web/server/lib/walkthrough/hunks.js new file mode 100644 index 00000000..32e5b53d --- /dev/null +++ b/packages/web/server/lib/walkthrough/hunks.js @@ -0,0 +1,166 @@ +import crypto from 'crypto'; + +// Parsing a unified diff into addressable hunks lives here and only here. The +// model anchors its narrative to hunk ids, the client resolves those ids back +// to rendered code, and staleness is "an id the current diff no longer has" — +// all three break the moment two implementations disagree about what an id is, +// so the client is never given the algorithm, only the results. + +const FILE_HEADER = /^diff --git /; +const HUNK_HEADER = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$/; + +const shortHash = (value) => crypto.createHash('sha1').update(value).digest('hex').slice(0, 8); + +const parsePathsFromFileHeader = (line) => { + // `diff --git a/old b/new`, with either side quoted when it contains spaces. + const match = /^diff --git (?:"?a\/(.+?)"?) (?:"?b\/(.+?)"?)$/.exec(line); + if (!match) return null; + return { oldPath: match[1], newPath: match[2] }; +}; + +const statusFromHeaderLines = (lines) => { + if (lines.some((line) => line.startsWith('new file mode'))) return 'added'; + if (lines.some((line) => line.startsWith('deleted file mode'))) return 'deleted'; + if (lines.some((line) => line.startsWith('rename from'))) return 'renamed'; + return 'modified'; +}; + +const isBinaryHeader = (lines) => lines.some((line) => line.startsWith('Binary files ') || line.startsWith('GIT binary patch')); + +/** + * Split a unified diff covering any number of files into files and hunks. + * + * @param {string} patch raw `git diff` output + * @param {string} scope opaque namespace for the ids (e.g. 'staged', 'branch'). + * Two scopes of the same repository can contain byte-identical hunks; the + * scope keeps their ids distinct so a walkthrough written against staged + * changes never silently resolves against unstaged ones. + * @returns {{files: Array<{path: string, oldPath: string|null, status: string, binary: boolean, hunks: Array}>}} + */ +export function parseDiffFiles(patch, scope = 'diff') { + const text = typeof patch === 'string' ? patch : ''; + if (!text.trim()) return { files: [] }; + + const lines = text.split(/\r?\n/); + const files = []; + let current = null; + let headerLines = []; + let hunk = null; + + const closeHunk = () => { + if (!current || !hunk) return; + const body = hunk.lines.join('\n'); + // The id covers the header and the body, so any edit to the hunk — even one + // that keeps its line numbers — produces a different id. That is what makes + // "this stop is stale" detectable without diffing narratives. + const digest = shortHash(`${hunk.header}\n${body}`); + const seen = current.hunkDigests.get(digest) ?? 0; + current.hunkDigests.set(digest, seen + 1); + // A file can legitimately contain byte-identical hunks (repeated boilerplate + // edits). Disambiguate by occurrence so ids stay unique without becoming + // positional for the common case. + const suffix = seen === 0 ? '' : `-${seen + 1}`; + + current.hunks.push({ + id: `${scope}:${current.path}:${digest}${suffix}`, + header: hunk.header, + oldStart: hunk.oldStart, + oldLines: hunk.oldLines, + newStart: hunk.newStart, + newLines: hunk.newLines, + added: hunk.added, + deleted: hunk.deleted, + patch: `${current.headerText}\n${hunk.header}\n${body}\n`, + body, + }); + hunk = null; + }; + + const closeFile = () => { + closeHunk(); + if (!current) return; + current.binary = current.binary || isBinaryHeader(headerLines); + delete current.hunkDigests; + files.push(current); + current = null; + }; + + for (const line of lines) { + if (FILE_HEADER.test(line)) { + closeFile(); + headerLines = [line]; + const paths = parsePathsFromFileHeader(line); + current = { + path: paths?.newPath || paths?.oldPath || '', + oldPath: paths && paths.oldPath !== paths.newPath ? paths.oldPath : null, + status: 'modified', + binary: false, + headerText: line, + hunks: [], + hunkDigests: new Map(), + }; + continue; + } + + if (!current) continue; + + const hunkMatch = HUNK_HEADER.exec(line); + if (hunkMatch) { + closeHunk(); + current.status = statusFromHeaderLines(headerLines); + current.headerText = headerLines.join('\n'); + hunk = { + header: line, + oldStart: Number.parseInt(hunkMatch[1], 10), + oldLines: hunkMatch[2] === undefined ? 1 : Number.parseInt(hunkMatch[2], 10), + newStart: Number.parseInt(hunkMatch[3], 10), + newLines: hunkMatch[4] === undefined ? 1 : Number.parseInt(hunkMatch[4], 10), + added: 0, + deleted: 0, + lines: [], + }; + continue; + } + + if (!hunk) { + headerLines.push(line); + continue; + } + + hunk.lines.push(line); + if (line.startsWith('+')) hunk.added += 1; + else if (line.startsWith('-')) hunk.deleted += 1; + } + + closeFile(); + + return { + files: files.filter((file) => file.path), + }; +} + +/** + * Flatten parsed files into an id-keyed index for resolution and staleness + * checks. + */ +export function indexHunks(files) { + const index = new Map(); + for (const file of files) { + for (const hunk of file.hunks) { + index.set(hunk.id, { ...hunk, path: file.path, status: file.status }); + } + } + return index; +} + +/** + * Every hunk id in the diff, in file-then-position order. Used to compute the + * "not covered by any stop" tail. + */ +export function listHunkIds(files) { + const ids = []; + for (const file of files) { + for (const hunk of file.hunks) ids.push(hunk.id); + } + return ids; +} diff --git a/packages/web/server/lib/walkthrough/hunks.test.js b/packages/web/server/lib/walkthrough/hunks.test.js new file mode 100644 index 00000000..fc250723 --- /dev/null +++ b/packages/web/server/lib/walkthrough/hunks.test.js @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; +import { parseDiffFiles, indexHunks, listHunkIds } from './hunks.js'; + +const TWO_FILE_DIFF = `diff --git a/src/a.ts b/src/a.ts +index 1111111..2222222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,3 +1,4 @@ + const a = 1; ++const b = 2; + const c = 3; + const d = 4; +@@ -20,2 +21,2 @@ +-const old = true; ++const next = true; +diff --git a/src/b.ts b/src/b.ts +new file mode 100644 +index 0000000..3333333 +--- /dev/null ++++ b/src/b.ts +@@ -0,0 +1,2 @@ ++export const x = 1; ++export const y = 2; +`; + +describe('parseDiffFiles', () => { + it('splits files and hunks with line ranges and counts', () => { + const { files } = parseDiffFiles(TWO_FILE_DIFF, 'working'); + + expect(files.map((file) => file.path)).toEqual(['src/a.ts', 'src/b.ts']); + expect(files[0].hunks).toHaveLength(2); + expect(files[0].hunks[0]).toMatchObject({ + oldStart: 1, + oldLines: 3, + newStart: 1, + newLines: 4, + added: 1, + deleted: 0, + }); + expect(files[0].hunks[1]).toMatchObject({ added: 1, deleted: 1 }); + expect(files[1].status).toBe('added'); + expect(files[1].hunks[0]).toMatchObject({ added: 2, deleted: 0 }); + }); + + it('produces a standalone applicable patch per hunk', () => { + const { files } = parseDiffFiles(TWO_FILE_DIFF, 'working'); + const patch = files[0].hunks[1].patch; + + expect(patch.startsWith('diff --git a/src/a.ts b/src/a.ts')).toBe(true); + expect(patch).toContain('--- a/src/a.ts'); + expect(patch).toContain('+++ b/src/a.ts'); + expect((patch.match(/^@@/gm) || [])).toHaveLength(1); + expect(patch).toContain('+const next = true;'); + expect(patch).not.toContain('const b = 2;'); + }); + + it('keeps ids stable across reparses of identical input', () => { + const first = listHunkIds(parseDiffFiles(TWO_FILE_DIFF, 'working').files); + const second = listHunkIds(parseDiffFiles(TWO_FILE_DIFF, 'working').files); + + expect(first).toEqual(second); + expect(new Set(first).size).toBe(first.length); + }); + + it('keeps an untouched hunk addressable when a neighbour changes', () => { + const before = parseDiffFiles(TWO_FILE_DIFF, 'working').files[0].hunks; + const edited = TWO_FILE_DIFF.replace('+const next = true;', '+const next = false;'); + const after = parseDiffFiles(edited, 'working').files[0].hunks; + + // The unrelated first hunk survives; only the edited one loses its id. + expect(after[0].id).toBe(before[0].id); + expect(after[1].id).not.toBe(before[1].id); + }); + + it('separates identical hunks in different scopes', () => { + const staged = parseDiffFiles(TWO_FILE_DIFF, 'staged').files[0].hunks[0].id; + const working = parseDiffFiles(TWO_FILE_DIFF, 'working').files[0].hunks[0].id; + + expect(staged).not.toBe(working); + }); + + it('disambiguates byte-identical hunks inside one file', () => { + const repeated = `diff --git a/src/dup.ts b/src/dup.ts +--- a/src/dup.ts ++++ b/src/dup.ts +@@ -1,1 +1,2 @@ ++import { thing } from './thing'; +@@ -1,1 +1,2 @@ ++import { thing } from './thing'; +`; + + const ids = listHunkIds(parseDiffFiles(repeated, 'working').files); + + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); + }); + + it('records renames and deletions', () => { + const renamed = `diff --git a/src/old.ts b/src/new.ts +similarity index 90% +rename from src/old.ts +rename to src/new.ts +--- a/src/old.ts ++++ b/src/new.ts +@@ -1,1 +1,1 @@ +-const a = 1; ++const a = 2; +diff --git a/src/gone.ts b/src/gone.ts +deleted file mode 100644 +--- a/src/gone.ts ++++ /dev/null +@@ -1,1 +0,0 @@ +-const gone = true; +`; + + const { files } = parseDiffFiles(renamed, 'working'); + + expect(files[0]).toMatchObject({ path: 'src/new.ts', oldPath: 'src/old.ts', status: 'renamed' }); + expect(files[1]).toMatchObject({ path: 'src/gone.ts', status: 'deleted' }); + }); + + it('marks binary files and gives them no hunks', () => { + const binary = `diff --git a/logo.png b/logo.png +index 1111111..2222222 100644 +Binary files a/logo.png and b/logo.png differ +`; + + const { files } = parseDiffFiles(binary, 'working'); + + expect(files[0]).toMatchObject({ path: 'logo.png', binary: true }); + expect(files[0].hunks).toHaveLength(0); + }); + + it('returns nothing for empty or whitespace input', () => { + expect(parseDiffFiles('', 'working').files).toEqual([]); + expect(parseDiffFiles(' \n', 'working').files).toEqual([]); + expect(parseDiffFiles(undefined, 'working').files).toEqual([]); + }); +}); + +describe('indexHunks', () => { + it('maps every id to its hunk with the owning file path', () => { + const { files } = parseDiffFiles(TWO_FILE_DIFF, 'working'); + const index = indexHunks(files); + + expect(index.size).toBe(3); + for (const [id, hunk] of index) { + expect(hunk.id).toBe(id); + expect(hunk.path).toBeTruthy(); + } + }); +}); diff --git a/packages/web/server/lib/walkthrough/index.js b/packages/web/server/lib/walkthrough/index.js new file mode 100644 index 00000000..600b8af4 --- /dev/null +++ b/packages/web/server/lib/walkthrough/index.js @@ -0,0 +1,586 @@ +import { getRepositoryRoot } from '../git/service.js'; +import { describeSmallModel, generateSmallModelText } from '../small-model/index.js'; +import { buildDigest } from './digest.js'; +import { indexHunks } from './hunks.js'; +import { normalizeLanguage } from './languages.js'; +import { buildPrompt, JSON_SHAPE_INSTRUCTION } from './prompt.js'; +import { normalizeWalkthrough, parseModelJson, responseSchema } from './schema.js'; +import { + buildCacheKey, + pruneMissingRepositories, + readCachedWalkthrough, + readPointer, + writeCachedWalkthrough, + writePointer, +} from './store.js'; +import { readWalkthroughModelOverride } from './model-settings.js'; +import { loadSourceSections, parseSource, sourceKey, WalkthroughSourceError } from './sources.js'; + +// Walkthrough generation is always user-initiated and never automatic: it costs +// tokens, and a background regeneration on every keystroke would be a way to +// spend a budget without anyone deciding to. + +// This module is imported lazily, which means module-level work lands on the +// first walkthrough request. Housekeeping has no business being there, so it is +// deferred and never awaited: the request proceeds immediately and the prune +// interleaves behind it. +setTimeout(() => { + void pruneMissingRepositories().catch(() => { + // Housekeeping failing is not worth surfacing or retrying. + }); +}, 0).unref?.(); + +// A hang guard, not a pace-setter. Losing a nearly-finished generation wastes +// real money and minutes, while an over-long deadline only holds a job slot, so +// this errs long. It scales because a three-hunk edit and a 500-hunk pull +// request have no business sharing a deadline. +const GENERATION_TIMEOUT_BASE_MS = 120_000; +const GENERATION_TIMEOUT_PER_HUNK_MS = 1_000; +const GENERATION_TIMEOUT_MAX_MS = 900_000; + +const generationTimeoutMs = (hunkCount) => Math.min( + GENERATION_TIMEOUT_MAX_MS, + GENERATION_TIMEOUT_BASE_MS + Math.max(0, hunkCount) * GENERATION_TIMEOUT_PER_HUNK_MS, +); +// A full walkthrough is a few thousand tokens of JSON. The budget exists for +// what comes before it: reasoning models spend the same allowance thinking and +// return nothing when it runs out, which is a bill for no answer. +// +// So the ask is derived from the model rather than fixed. A flat 24k was the +// same number for a 64k-context model and for one that admits to 384k output +// tokens, and on the latter it was the only reason generation failed. +// +// The reserve subtracted from the input budget is the same number, always: ask +// for more than was reserved and a large diff overruns the context mid-answer, +// which surfaces as a truncation bug rather than a budgeting one. +const MIN_OUTPUT_TOKENS = 24_000; +// A ceiling, because the reserve is taken out of the input allowance: a model +// that would let us ask for 384k tokens of answer would also let us spend a +// third of a million tokens of context reserving them, and no walkthrough needs +// that much thinking. +const MAX_OUTPUT_TOKENS = 96_000; +// Above this share of the context, the reserve starts costing more diff than +// the extra room is worth. +const OUTPUT_CONTEXT_SHARE = 0.25; + +/** + * Answer allowance for a specific model: as much as it admits it can emit, + * bounded by a share of its context and never below what this feature always + * asked for. + */ +const walkthroughOutputTokens = ({ contextTokens, outputTokenLimit }) => { + const wanted = Math.min( + MAX_OUTPUT_TOKENS, + Math.max(MIN_OUTPUT_TOKENS, Math.floor((Number(contextTokens) || 0) * OUTPUT_CONTEXT_SHARE)), + ); + // A model whose own limit is below the floor gets its limit: asking for more + // than a provider allows is rejected outright by some and ignored by others. + return Number(outputTokenLimit) > 0 ? Math.min(wanted, Number(outputTokenLimit)) : wanted; +}; + +const fail = (message, statusCode, extra = {}) => + Object.assign(new Error(message), { statusCode, ...extra }); + +// Generation outlives the request that started it. +// +// A dropped connection and a deliberate cancel look identical at the socket, so +// tying the work to the request lifetime meant an accidental refresh threw away +// a minute of paid-for work. Jobs are keyed by repository + source, so a client +// that comes back attaches to the running job instead of starting a second one, +// and cancelling is an explicit request rather than a side effect of leaving. +const jobs = new Map(); + +// Providers that answered a schema request with a 4xx. Retrying the schema on +// every generation means paying for a call we already know will fail, so the +// refusal is remembered and the fallback goes first next time. +// +// Process-lifetime only, on purpose: a provider that gains structured-output +// support should not need a settings change to be tried again — a restart is +// enough, and the cost of one wasted first attempt after that is small. +const schemaRefusedBy = new Set(); + +const modelKey = (model) => `${model.providerID}/${model.modelID}`; + +const jobKey = (repoRoot, sourceKeyValue) => `${repoRoot}\0${sourceKeyValue}`; + +/** + * Coarse stages, reported so a long wait is legible. + * + * Only phases a person can actually wait on are named. Building the digest and + * reading the cache take single-digit milliseconds; giving them their own rows + * would imply progress where there is none. `retrying` appears only when a + * provider rejects the schema and the prompt-side fallback runs. + */ +export const GENERATION_STAGES = ['collecting', 'asking', 'retrying', 'assembling']; + +const setStage = (repoRoot, sourceKeyValue, stage) => { + const job = jobs.get(jobKey(repoRoot, sourceKeyValue)); + if (job) job.stage = stage; +}; + +/** + * Current stage of a running generation, or `null` when nothing is running. + * Reads memory only — no git, no network — so it is cheap to poll. + */ +export function getGenerationStage(repoRoot, sourceKeyValue) { + return jobs.get(jobKey(repoRoot, sourceKeyValue))?.stage ?? null; +} + +/** + * Whether a generation is currently running for a source. Lets a reconnecting + * client show progress instead of an empty panel. + */ +export function isGenerating(repoRoot, sourceKeyValue) { + return jobs.has(jobKey(repoRoot, sourceKeyValue)); +} + +/** + * Resolve the pair the job registry is keyed by, for callers that need to look + * a job up without doing any diff work. + */ +export async function getRepositoryRootFor(directory, rawSource) { + const source = parseSource(rawSource); + return { repoRoot: await getRepositoryRoot(directory), sourceKey: sourceKey(source) }; +} + +/** + * Stop a running generation. Only an explicit request does this — leaving the + * page does not. + */ +export async function cancelWalkthroughGeneration({ directory, source: rawSource }) { + const source = parseSource(rawSource); + const repoRoot = await getRepositoryRoot(directory); + const job = jobs.get(jobKey(repoRoot, sourceKey(source))); + if (!job) return { cancelled: false }; + job.controller.abort(); + return { cancelled: true }; +} + +const modelLabel = (model) => `${model.providerID}/${model.modelID}`; + +/** + * Resolve the model for this feature: the walkthrough override when set, + * otherwise whatever the small-model chain resolves to. + */ +/** + * Resolve the model for this feature. An explicit per-review choice outranks the + * saved setting, which in turn outranks the small-model chain — the user picking + * a roomier model for a risky change is the most specific intent there is. + */ +const resolveModel = (directory, explicitModel) => describeSmallModel({ + directory, + outputReserveTokens: walkthroughOutputTokens, + overrideModel: explicitModel || readWalkthroughModelOverride(), +}); + +export const __testing = { generationTimeoutMs, walkthroughOutputTokens }; + +/** + * Current diff for a source, parsed into files and hunks. + */ +async function loadCurrentDiff(directory, source, deps) { + const { sections } = await loadSourceSections(directory, source, deps); + const built = buildDigest(sections); + return built; +} + +const stopHunkIds = (walkthrough) => + walkthrough.chapters.flatMap((chapter) => chapter.stops.flatMap((stop) => stop.hunkIds)); + +/** + * Compare a stored walkthrough against the diff as it is right now. + * + * Staleness is not a heuristic here: a hunk id is a hash of the hunk's content, + * so an anchor that no longer resolves is proof that the code it described has + * changed or gone. Anchors that still resolve are still accurate. + */ +function resolveAgainstCurrent(walkthrough, hunkIndex) { + const missingHunkIds = []; + const staleStopIds = []; + + for (const chapter of walkthrough.chapters) { + for (const stop of chapter.stops) { + const missing = stop.hunkIds.filter((id) => !hunkIndex.has(id)); + if (missing.length === 0) continue; + missingHunkIds.push(...missing); + staleStopIds.push(stop.id); + } + } + + const covered = new Set(stopHunkIds(walkthrough)); + const uncoveredHunkIds = [...hunkIndex.keys()].filter((id) => !covered.has(id)); + + return { + isStale: missingHunkIds.length > 0, + missingHunkIds, + staleStopIds, + uncoveredHunkIds, + }; +} + +const serializeHunks = (files) => files.flatMap((file) => file.hunks.map((hunk) => ({ + id: hunk.id, + path: file.path, + oldPath: file.oldPath || null, + status: file.status, + scope: file.scope, + header: hunk.header, + newStart: hunk.newStart, + added: hunk.added, + deleted: hunk.deleted, + patch: hunk.patch, +}))); + +/** + * Read the last walkthrough for a source, resolved against the current diff. + * Never generates and never spends tokens. + */ +export async function getWalkthrough({ directory, source: rawSource, model: explicitModel, language: rawLanguage }, deps = {}) { + const source = parseSource(rawSource); + const repoRoot = await getRepositoryRoot(directory); + const key = sourceKey(source); + const language = normalizeLanguage(rawLanguage); + + const pointer = readPointer(repoRoot, key); + // One diff, one model lookup, both answers. These used to be separate + // endpoints the client called in parallel, which meant every panel open ran + // the whole git pipeline twice. + const [built, model] = await Promise.all([ + loadCurrentDiff(directory, source, deps), + resolveModel(directory, explicitModel).catch(() => null), + ]); + const { files } = built; + const hunkIndex = indexHunks(files); + const readiness = computeReadiness({ ...built, model, source, language }); + + const base = { + source, + hunks: serializeHunks(files), + hunkCount: hunkIndex.size, + readiness, + generating: isGenerating(repoRoot, key), + }; + + // Ask the cache for *this* request before falling back to the pointer. + // + // The pointer only knows which walkthrough was generated here last, which + // after a model or language switch is the answer to a different question: + // the panel would keep showing the English review while the picker said + // Ukrainian, even though the Ukrainian one was sitting in the cache. The key + // is computed from the diff this read already parsed, so this costs a file + // read and no git work at all. + const requestedKey = model + ? buildCacheKey({ + repoRoot, + sourceKey: key, + providerID: model.providerID, + modelID: model.modelID, + language, + files, + }) + : null; + + const requested = requestedKey ? readCachedWalkthrough(requestedKey) : null; + const entry = requested ?? (pointer ? readCachedWalkthrough(pointer.cacheKey) : null); + if (!entry) { + // No pointer, or the pointer outlived its entry (eviction, manual cleanup). + // "No walkthrough" is the truthful answer either way; the pointer is left + // for the next generation to overwrite. + return { ...base, walkthrough: null }; + } + + // Showing it makes it the last walkthrough shown here, and a regeneration + // re-authors from whatever the reader is actually looking at. Only written + // when it moved, so an unchanged read stays a pure read. + if (requested && pointer?.cacheKey !== requestedKey) { + writePointer(repoRoot, key, { + repoRoot, + sourceKey: key, + cacheKey: requestedKey, + generatedAt: requested.generatedAt, + }); + } + + return { + ...base, + walkthrough: entry.walkthrough, + model: entry.model, + // The language the text on screen is actually written in, which is not + // necessarily the one being asked for now. The picker needs the difference: + // it is what lets it default to what produced this rather than to a setting. + language: entry.language ?? null, + generatedAt: entry.generatedAt, + ...resolveAgainstCurrent(entry.walkthrough, hunkIndex), + }; +} + +/** + * Whether the resolved model can do this job, computed from a digest the caller + * already built. + * + * Folded into the walkthrough read rather than living on its own endpoint: both + * answers need the same diff, and computing it twice doubled the git work on + * every panel open. + */ +function computeReadiness({ model, digest, files, fileCount, hunkCount, generatedFileCount, source, language }) { + if (!model) return { ready: false, reason: 'no-model' }; + + if (hunkCount === 0) { + // "Only a lockfile changed" is a different answer from "nothing changed", + // and the user can act on it (commit and move on) rather than wonder why + // the review refuses. + const reason = files.length > 0 && generatedFileCount === files.length ? 'only-generated' : 'empty-diff'; + return { ready: false, reason, model, generatedFileCount }; + } + + // Built with the same language the generation would use: the instruction is + // part of the prompt, so a readiness answer computed without it would be + // measuring a request nobody is going to send. + const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source, language }); + const requiredChars = prompt.length + system.length; + + if (model.structuredOutput === false) { + return { ready: false, reason: 'structured-output-unsupported', model, requiredChars }; + } + + if (requiredChars > model.inputCharBudget) { + return { + ready: false, + reason: 'context-too-small', + model, + requiredChars, + availableChars: model.inputCharBudget, + }; + } + + return { ready: true, model, requiredChars, availableChars: model.inputCharBudget, hunkCount, fileCount }; +} + +/** + * Generate a walkthrough for a source. + * + * Returns the cached entry when the diff, model, and prompt are all unchanged — + * which also means returning to a previous state of the working tree costs + * nothing. + */ +export async function generateWalkthrough({ directory, source: rawSource, force = false, model: explicitModel, language: rawLanguage }, deps = {}) { + const source = parseSource(rawSource); + const repoRoot = await getRepositoryRoot(directory); + const key = sourceKey(source); + const language = normalizeLanguage(rawLanguage); + + // Attach to a running job rather than starting a second one. A user who + // refreshed and pressed the button again wants the answer, not two bills. + const existing = jobs.get(jobKey(repoRoot, key)); + if (existing) return existing.promise; + + const controller = new AbortController(); + const promise = runGeneration({ directory, source, repoRoot, key, force, explicitModel, language, signal: controller.signal }, deps) + .finally(() => { + if (jobs.get(jobKey(repoRoot, key))?.controller === controller) { + jobs.delete(jobKey(repoRoot, key)); + } + }); + + jobs.set(jobKey(repoRoot, key), { controller, promise, stage: 'collecting' }); + return promise; +} + +async function runGeneration({ directory, source, repoRoot, key, force, explicitModel, language, signal }, deps) { + + const model = await resolveModel(directory, explicitModel); + if (!model) { + throw fail('No model is available — sign in to a provider first', 404, { code: 'no-model' }); + } + + const { digest, files, idByAlias, fileCount, hunkCount, generatedFileCount } = await loadCurrentDiff(directory, source, deps); + setStage(repoRoot, key, 'asking'); + if (hunkCount === 0) { + if (files.length > 0 && generatedFileCount === files.length) { + throw fail('Only generated files changed — there is nothing to review', 400, { code: 'only-generated' }); + } + throw fail('There are no changes to review', 400, { code: 'empty-diff' }); + } + + const cacheKey = buildCacheKey({ + repoRoot, + sourceKey: key, + providerID: model.providerID, + modelID: model.modelID, + language, + files, + }); + + const hunkIndex = indexHunks(files); + + if (!force) { + const cached = readCachedWalkthrough(cacheKey); + if (cached) { + writePointer(repoRoot, key, { + repoRoot, + sourceKey: key, + cacheKey, + generatedAt: cached.generatedAt, + }); + return { + source, + walkthrough: cached.walkthrough, + model: cached.model, + language: cached.language ?? null, + generatedAt: cached.generatedAt, + fromCache: true, + hunks: serializeHunks(files), + hunkCount, + ...resolveAgainstCurrent(cached.walkthrough, hunkIndex), + }; + } + } + + // A forced regeneration hands the model its own previous narrative so it can + // keep what is still true instead of starting from a blank page. The old + // anchors are deliberately not included — they belong to code that has moved. + let previousWalkthrough = null; + const pointer = readPointer(repoRoot, key); + if (pointer) { + const previousEntry = readCachedWalkthrough(pointer.cacheKey); + if (previousEntry && previousEntry.cacheKey !== cacheKey) { + previousWalkthrough = previousEntry.walkthrough; + } + } + + const { prompt, system } = buildPrompt({ digest, fileCount, hunkCount, source, previousWalkthrough, language }); + + if (model.structuredOutput === false) { + throw fail( + `${modelLabel(model)} cannot produce structured output — choose a different small model`, + 409, + { code: 'structured-output-unsupported', model }, + ); + } + + const run = (options) => generateSmallModelText({ + prompt: options.prompt, + system: options.system, + directory, + model: `${model.providerID}/${model.modelID}`, + responseSchema: options.responseSchema, + onOverflow: 'error', + timeoutMs: generationTimeoutMs(hunkCount), + // The number the input budget was already reduced by, not a fresh guess. + maxOutputTokens: model.outputTokens ?? MIN_OUTPUT_TOKENS, + signal, + }); + + // Roughly half the catalog does not declare `structured_output`, and some of + // those providers reject the schema outright. A rejected request shape is not + // a dead end: the shape can travel in the prompt instead, and the response + // parser is already tolerant of imperfect JSON. + const withSchema = () => run({ prompt, system, responseSchema }); + const withoutSchema = () => run({ + prompt, + system: `${system}\n${JSON_SHAPE_INSTRUCTION}`, + responseSchema: undefined, + }); + + const asRequestFailure = (error) => { + if (error?.code === 'context-too-small') { + return fail(error.message, 409, { + code: 'context-too-small', + model, + requiredChars: error.requiredChars, + availableChars: error.availableChars, + }); + } + if (error?.code === 'output-exhausted') { + return fail(error.message, 409, { code: 'output-exhausted', model }); + } + return null; + }; + + const refusesSchema = (error) => error?.code === 'structured-output-unsupported' + || (Number(error?.status) >= 400 && Number(error?.status) < 500); + + let raw; + let usedSchema = false; + + if (schemaRefusedBy.has(modelKey(model))) { + // Already known to refuse: skip straight to the fallback rather than pay + // for a call whose failure is a foregone conclusion. + setStage(repoRoot, key, 'retrying'); + try { + raw = await withoutSchema(); + } catch (error) { + throw asRequestFailure(error) ?? error; + } + } else { + try { + raw = await withSchema(); + usedSchema = true; + } catch (error) { + const failure = asRequestFailure(error); + if (failure) throw failure; + if (!refusesSchema(error)) throw error; + + schemaRefusedBy.add(modelKey(model)); + setStage(repoRoot, key, 'retrying'); + try { + raw = await withoutSchema(); + } catch (fallbackError) { + throw asRequestFailure(fallbackError) ?? fallbackError; + } + } + } + + setStage(repoRoot, key, 'assembling'); + + let walkthrough; + try { + walkthrough = normalizeWalkthrough(parseModelJson(raw.text), idByAlias); + } catch (error) { + // Without schema support the model was asked for JSON in prose and did not + // deliver: that is a capability problem the user can fix by switching model, + // so it gets the picker rather than a parser error. + if (!usedSchema) { + throw fail( + `${modelLabel(model)} could not return the structured response a walkthrough needs`, + 409, + { code: 'structured-output-unsupported', model }, + ); + } + throw fail( + `${modelLabel(model)} did not return a usable walkthrough — try a different small model`, + 502, + { code: 'invalid-walkthrough', model, cause: error?.message }, + ); + } + + const generatedAt = new Date().toISOString(); + const entry = { + cacheKey, + generatedAt, + repoRoot, + sourceKey: key, + model: { providerID: model.providerID, modelID: model.modelID, source: model.source }, + language, + walkthrough, + }; + + // A failed write costs a regeneration next time; it must never fail the + // request that already produced a good walkthrough. + writeCachedWalkthrough(cacheKey, entry); + writePointer(repoRoot, key, { repoRoot, sourceKey: key, cacheKey, generatedAt }); + + return { + source, + walkthrough, + model: entry.model, + language, + generatedAt, + fromCache: false, + hunks: serializeHunks(files), + hunkCount, + ...resolveAgainstCurrent(walkthrough, hunkIndex), + }; +} + +export { WalkthroughSourceError }; diff --git a/packages/web/server/lib/walkthrough/jobs.test.js b/packages/web/server/lib/walkthrough/jobs.test.js new file mode 100644 index 00000000..b5485c7e --- /dev/null +++ b/packages/web/server/lib/walkthrough/jobs.test.js @@ -0,0 +1,303 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'walkthrough-jobs-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +// Mocking git rather than this module's own source loading: fewer of our own +// seams faked means the test exercises the real digest and prompt path. +vi.mock('../git/service.js', () => ({ + getRepositoryRoot: vi.fn(async () => '/repo'), + getDiff: vi.fn(), + getRangeDiff: vi.fn(), + getUntrackedDiffs: vi.fn(async () => []), + listUntrackedPaths: vi.fn(async () => []), +})); +vi.mock('../small-model/index.js', () => ({ + describeSmallModel: vi.fn(), + generateSmallModelText: vi.fn(), +})); +const { + generateWalkthrough, + cancelWalkthroughGeneration, + isGenerating, + getGenerationStage, + __testing: walkthroughTesting, +} = await import('./index.js'); +const { describeSmallModel, generateSmallModelText } = await import('../small-model/index.js'); +const { getDiff } = await import('../git/service.js'); + +// bun's vitest shim has no `vi.waitFor`. +const waitFor = async (predicate, { timeout = 2_000, interval = 5 } = {}) => { + const deadline = Date.now() + timeout; + for (;;) { + if (predicate()) return; + if (Date.now() > deadline) throw new Error('waitFor timed out'); + await new Promise((resolve) => setTimeout(resolve, interval)); + } +}; + +const SOURCE = { kind: 'working-tree', scope: 'all' }; + +const PATCH = `diff --git a/src/a.ts b/src/a.ts +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,1 +1,2 @@ ++const added = true; +`; + +const RESPONSE = JSON.stringify({ + title: 'Change', + focus: 'why', + chapters: [{ + title: 'Data', + icon: 'doc', + blurb: '', + stops: [{ title: 'Adds a flag', hunks: ['h1'], importance: 'normal', prose: 'It adds a flag.' }], + }], +}); + +describe('generation jobs', () => { + beforeEach(() => { + fs.rmSync(path.join(TEMP_DATA_DIR, 'walkthroughs'), { recursive: true, force: true }); + describeSmallModel.mockResolvedValue({ + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + source: 'config', + inputCharBudget: 1_000_000, + structuredOutput: true, + }); + getDiff.mockImplementation(async (_dir, options) => (options?.staged ? '' : PATCH)); + generateSmallModelText.mockReset(); + }); + + afterEach(async () => { + if (isGenerating('/repo', 'working-tree:all')) { + await cancelWalkthroughGeneration({ directory: '/repo', source: SOURCE }).catch(() => {}); + } + }); + + afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); + }); + + it('runs a second request against the same job instead of paying twice', async () => { + let release; + generateSmallModelText.mockImplementation(() => new Promise((resolve) => { + release = () => resolve({ text: RESPONSE }); + })); + + const first = generateWalkthrough({ directory: '/repo', source: SOURCE }); + // Let the first call reach the model before the second arrives, which is + // what a refresh-then-press-again actually looks like. + await waitFor(() => generateSmallModelText.mock.calls.length === 1); + const second = generateWalkthrough({ directory: '/repo', source: SOURCE }); + + release(); + const [a, b] = await Promise.all([first, second]); + + expect(generateSmallModelText).toHaveBeenCalledTimes(1); + expect(a.walkthrough.title).toBe('Change'); + expect(b).toBe(a); + }); + + it('reports a running job so a returning client can show progress', async () => { + let release; + generateSmallModelText.mockImplementation(() => new Promise((resolve) => { + release = () => resolve({ text: RESPONSE }); + })); + + const running = generateWalkthrough({ directory: '/repo', source: SOURCE }); + await waitFor(() => isGenerating('/repo', 'working-tree:all')); + + release(); + await running; + + expect(isGenerating('/repo', 'working-tree:all')).toBe(false); + }); + + it('stops only on an explicit cancel', async () => { + generateSmallModelText.mockImplementation(({ signal }) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))); + })); + + const running = generateWalkthrough({ directory: '/repo', source: SOURCE }); + await waitFor(() => isGenerating('/repo', 'working-tree:all')); + + expect(await cancelWalkthroughGeneration({ directory: '/repo', source: SOURCE })) + .toEqual({ cancelled: true }); + await expect(running).rejects.toThrow(); + expect(isGenerating('/repo', 'working-tree:all')).toBe(false); + }); + + it('reports nothing to cancel when no job is running', async () => { + expect(await cancelWalkthroughGeneration({ directory: '/repo', source: SOURCE })) + .toEqual({ cancelled: false }); + }); + + // The reserve and the request must be the same number: asking for more than + // was subtracted from the input allowance overruns the context mid-answer. + it('requests exactly the budget the model resolution reserved', async () => { + describeSmallModel.mockResolvedValue({ + providerID: 'opencode-go', + modelID: 'deepseek-v4-flash', + source: 'config', + inputCharBudget: 1_000_000, + structuredOutput: true, + outputTokens: 96_000, + outputTokenLimit: 384_000, + }); + generateSmallModelText.mockResolvedValue({ text: RESPONSE }); + + await generateWalkthrough({ directory: '/repo', source: SOURCE }); + + expect(generateSmallModelText.mock.calls.at(-1)[0].maxOutputTokens).toBe(96_000); + }); + + it('serves the cache once the job has finished, without calling the model again', async () => { + generateSmallModelText.mockResolvedValue({ text: RESPONSE }); + + await generateWalkthrough({ directory: '/repo', source: SOURCE }); + generateSmallModelText.mockClear(); + + const second = await generateWalkthrough({ directory: '/repo', source: SOURCE }); + + expect(second.fromCache).toBe(true); + expect(generateSmallModelText).not.toHaveBeenCalled(); + }); +}); + +// A fixed deadline made a three-hunk edit and a 500-hunk pull request wait the +// same, so the small case guarded nothing and the big case died just short of +// the finish line. +describe('generation timeout', () => { + const { generationTimeoutMs } = walkthroughTesting; + + it('gives a small diff a floor rather than a proportional sliver', () => { + expect(generationTimeoutMs(0)).toBe(120_000); + expect(generationTimeoutMs(3)).toBe(123_000); + }); + + it('grows with the work', () => { + expect(generationTimeoutMs(515)).toBeGreaterThan(generationTimeoutMs(138)); + expect(generationTimeoutMs(515)).toBe(635_000); + }); + + it('stays bounded so a hung connection cannot hold a job forever', () => { + expect(generationTimeoutMs(100_000)).toBe(900_000); + }); +}); + +// The failure this replaced: a flat 24k ask, spent entirely on reasoning by a +// model that advertises 384k output tokens and a million of context. The ceiling +// exists because the same number is reserved out of the input allowance. +describe('output budget', () => { + const { walkthroughOutputTokens } = walkthroughTesting; + + it('asks a roomy model for far more than the old fixed budget', () => { + expect(walkthroughOutputTokens({ contextTokens: 1_000_000, outputTokenLimit: 384_000 })).toBe(96_000); + }); + + it('never asks for more than the model says it can emit', () => { + expect(walkthroughOutputTokens({ contextTokens: 202_752, outputTokenLimit: 32_768 })).toBe(32_768); + }); + + it('keeps the reserve to a share of the context', () => { + expect(walkthroughOutputTokens({ contextTokens: 200_000, outputTokenLimit: 64_000 })).toBe(50_000); + }); + + it('holds the old floor for a small or uncatalogued model', () => { + expect(walkthroughOutputTokens({ contextTokens: 64_000, outputTokenLimit: null })).toBe(24_000); + expect(walkthroughOutputTokens({ contextTokens: 0, outputTokenLimit: null })).toBe(24_000); + }); + + it('yields to a model whose own limit is below the floor', () => { + expect(walkthroughOutputTokens({ contextTokens: 128_000, outputTokenLimit: 8_192 })).toBe(8_192); + }); +}); + +describe('generation stages', () => { + beforeEach(() => { + // Without this the previous suite's cache entry is a hit for the same + // content and the model is never called. + fs.rmSync(path.join(TEMP_DATA_DIR, 'walkthroughs'), { recursive: true, force: true }); + describeSmallModel.mockResolvedValue({ + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + source: 'config', + inputCharBudget: 1_000_000, + structuredOutput: true, + }); + getDiff.mockImplementation(async (_dir, options) => (options?.staged ? '' : PATCH)); + generateSmallModelText.mockReset(); + }); + + it('reports asking while the model runs and clears when the job ends', async () => { + let release; + generateSmallModelText.mockImplementation(() => new Promise((resolve) => { + release = () => resolve({ text: RESPONSE }); + })); + + const running = generateWalkthrough({ directory: '/repo', source: SOURCE }); + await waitFor(() => getGenerationStage('/repo', 'working-tree:all') === 'asking'); + + release(); + await running; + + expect(getGenerationStage('/repo', 'working-tree:all')).toBeNull(); + }); + + it('reports retrying only when a provider rejects the schema', async () => { + let seen = []; + let attempt = 0; + generateSmallModelText.mockImplementation(async () => { + attempt += 1; + seen.push(getGenerationStage('/repo', 'working-tree:all')); + if (attempt === 1) throw Object.assign(new Error('bad request'), { status: 400 }); + return { text: RESPONSE }; + }); + + await generateWalkthrough({ directory: '/repo', source: SOURCE }); + + expect(seen).toEqual(['asking', 'retrying']); + }); +}); + +// Retrying the schema on every generation means paying for a call already known +// to fail; the refusal has to be remembered. +describe('schema refusal memory', () => { + beforeEach(() => { + fs.rmSync(path.join(TEMP_DATA_DIR, 'walkthroughs'), { recursive: true, force: true }); + describeSmallModel.mockResolvedValue({ + providerID: 'opencode-go', + modelID: 'deepseek-v4-flash', + source: 'config', + inputCharBudget: 1_000_000, + structuredOutput: null, + }); + getDiff.mockImplementation(async (_dir, options) => (options?.staged ? '' : PATCH)); + generateSmallModelText.mockReset(); + }); + + it('stops sending a schema to a model that already rejected one', async () => { + const sentSchema = []; + generateSmallModelText.mockImplementation(async ({ responseSchema }) => { + sentSchema.push(Boolean(responseSchema)); + if (responseSchema) throw Object.assign(new Error('bad request'), { status: 400 }); + return { text: RESPONSE }; + }); + + await generateWalkthrough({ directory: '/repo', source: SOURCE }); + expect(sentSchema).toEqual([true, false]); + + // A different diff, so the cache cannot answer instead. + getDiff.mockImplementation(async (_dir, options) => ( + options?.staged ? '' : PATCH.replace('const added = true;', 'const added = false;') + )); + await generateWalkthrough({ directory: '/repo', source: SOURCE }); + + expect(sentSchema).toEqual([true, false, false]); + }); +}); diff --git a/packages/web/server/lib/walkthrough/language.test.js b/packages/web/server/lib/walkthrough/language.test.js new file mode 100644 index 00000000..3b8897b3 --- /dev/null +++ b/packages/web/server/lib/walkthrough/language.test.js @@ -0,0 +1,226 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'walkthrough-language-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +vi.mock('../git/service.js', () => ({ + getRepositoryRoot: vi.fn(async () => '/repo'), + getDiff: vi.fn(), + getRangeDiff: vi.fn(), + getUntrackedDiffs: vi.fn(async () => []), + listUntrackedPaths: vi.fn(async () => []), +})); +vi.mock('../small-model/index.js', () => ({ + describeSmallModel: vi.fn(), + generateSmallModelText: vi.fn(), +})); + +const { normalizeLanguage, languageName } = await import('./languages.js'); +const { buildPrompt } = await import('./prompt.js'); +const { buildCacheKey } = await import('./store.js'); +const { generateWalkthrough, getWalkthrough } = await import('./index.js'); +const { describeSmallModel, generateSmallModelText } = await import('../small-model/index.js'); +const { getDiff } = await import('../git/service.js'); + +const SOURCE = { kind: 'working-tree', scope: 'all' }; + +const PATCH = `diff --git a/src/a.ts b/src/a.ts +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,1 +1,2 @@ ++const added = true; +`; + +const RESPONSE = JSON.stringify({ + title: 'Change', + focus: 'why', + chapters: [{ + title: 'Data', + icon: 'doc', + blurb: '', + stops: [{ title: 'Adds a flag', hunks: ['h1'], importance: 'normal', prose: 'It adds a flag.' }], + }], +}); + +const PROMPT_INPUT = { + digest: { files: [] }, + fileCount: 1, + hunkCount: 1, + source: SOURCE, +}; + +const FILES = [{ path: 'src/a.ts', status: 'modified', hunks: [{ id: 'unstaged:src/a.ts:abcd1234' }] }]; +const keyFor = (language) => buildCacheKey({ + repoRoot: '/repo', + sourceKey: 'working-tree:all', + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + language, + files: FILES, +}); + +describe('normalizeLanguage', () => { + it('accepts the tags the interface uses', () => { + expect(normalizeLanguage('uk')).toBe('uk'); + expect(normalizeLanguage('zh-TW')).toBe('zh-TW'); + expect(normalizeLanguage('pt-BR')).toBe('pt-BR'); + }); + + it('tolerates case and separator drift from a platform locale', () => { + expect(normalizeLanguage('uk-UA')).toBe('uk'); + expect(normalizeLanguage('pt_br')).toBe('pt-BR'); + expect(normalizeLanguage('ja-JP')).toBe('ja'); + }); + + // A language preference is about prose. Refusing to write a walkthrough over + // an unrecognised tag would be a worse answer than writing it in English. + it('falls back to English rather than failing', () => { + expect(normalizeLanguage('kl')).toBe('en'); + expect(normalizeLanguage('')).toBe('en'); + expect(normalizeLanguage(undefined)).toBe('en'); + expect(normalizeLanguage({ toString: () => 'uk' })).toBe('en'); + }); + + it('names languages in English, matching the language of the prompt', () => { + expect(languageName('uk')).toBe('Ukrainian'); + expect(languageName('nope')).toBe('English'); + }); +}); + +describe('prompt language instruction', () => { + it('says nothing when the prompt language is already the output language', () => { + const { system } = buildPrompt({ ...PROMPT_INPUT, language: 'en' }); + expect(system).not.toMatch(/Write all prose/); + }); + + it('asks for prose in the chosen language', () => { + const { system } = buildPrompt({ ...PROMPT_INPUT, language: 'uk' }); + expect(system).toMatch(/Write all prose in Ukrainian/); + }); + + // Aliases are keys the server resolves back to hunk ids and icon/importance + // are validated against fixed English values, so a translated one is dropped + // by the normalizer — silently losing an anchor or a style. + it('holds back the parts that are not prose', () => { + const { system } = buildPrompt({ ...PROMPT_INPUT, language: 'ja' }); + expect(system).toMatch(/Keep these in English exactly as given/); + expect(system).toMatch(/hunk aliases/); + expect(system).toMatch(/"importance"/); + }); + + it('defaults to English when no language is passed', () => { + expect(buildPrompt(PROMPT_INPUT).system).toBe(buildPrompt({ ...PROMPT_INPUT, language: 'en' }).system); + }); +}); + +describe('cache key', () => { + // Without the language in the key, asking for a translation is answered with + // the untranslated entry that was already there. + it('separates walkthroughs written in different languages', () => { + expect(keyFor('uk')).not.toBe(keyFor('en')); + }); + + it('is stable for the same language', () => { + expect(keyFor('uk')).toBe(keyFor('uk')); + }); +}); + +describe('generating in a language', () => { + beforeEach(() => { + fs.rmSync(path.join(TEMP_DATA_DIR, 'walkthroughs'), { recursive: true, force: true }); + describeSmallModel.mockResolvedValue({ + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + source: 'config', + inputCharBudget: 1_000_000, + structuredOutput: true, + }); + getDiff.mockImplementation(async (_dir, options) => (options?.staged ? '' : PATCH)); + generateSmallModelText.mockReset(); + generateSmallModelText.mockResolvedValue({ text: RESPONSE }); + }); + + afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); + }); + + it('sends the instruction and records the language with the result', async () => { + const result = await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + + expect(generateSmallModelText.mock.calls[0][0].system).toMatch(/Write all prose in Ukrainian/); + expect(result.language).toBe('uk'); + }); + + it('does not serve one language from the other language cache entry', async () => { + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + generateSmallModelText.mockClear(); + + const english = await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'en' }); + + expect(english.fromCache).toBeFalsy(); + expect(generateSmallModelText).toHaveBeenCalledTimes(1); + expect(english.language).toBe('en'); + }); + + // Switching away and back must not cost a second generation: the earlier + // walkthrough is still addressed by its own key. + it('returns the earlier language from cache when it is asked for again', async () => { + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'en' }); + generateSmallModelText.mockClear(); + + const back = await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + + expect(back.fromCache).toBe(true); + expect(back.language).toBe('uk'); + expect(generateSmallModelText).not.toHaveBeenCalled(); + }); + + // The pointer only knows what was generated here last. After a language + // switch that is the answer to a different question, and reading it instead + // left the panel showing English while the picker said Ukrainian — with the + // Ukrainian walkthrough sitting unused in the cache. + it('reads back the walkthrough written in the language being asked for', async () => { + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'en' }); + generateSmallModelText.mockClear(); + + const ukrainian = await getWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + + expect(ukrainian.language).toBe('uk'); + expect(ukrainian.walkthrough).toBeTruthy(); + expect(generateSmallModelText).not.toHaveBeenCalled(); + }); + + it('switches back and forth without generating anything', async () => { + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' }); + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'ja' }); + generateSmallModelText.mockClear(); + + expect((await getWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' })).language).toBe('uk'); + expect((await getWalkthrough({ directory: '/repo', source: SOURCE, language: 'ja' })).language).toBe('ja'); + expect((await getWalkthrough({ directory: '/repo', source: SOURCE, language: 'uk' })).language).toBe('uk'); + expect(generateSmallModelText).not.toHaveBeenCalled(); + }); + + // Falling back is still right: an English review beats an empty panel, and + // the response says which language it is in so the panel can be honest. + it('falls back to the last walkthrough when none exists in that language', async () => { + await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'en' }); + + const korean = await getWalkthrough({ directory: '/repo', source: SOURCE, language: 'ko' }); + + expect(korean.walkthrough).toBeTruthy(); + expect(korean.language).toBe('en'); + }); + + it('treats an unknown language as English rather than failing the request', async () => { + const result = await generateWalkthrough({ directory: '/repo', source: SOURCE, language: 'kl' }); + + expect(result.language).toBe('en'); + expect(generateSmallModelText.mock.calls[0][0].system).not.toMatch(/Write all prose/); + }); +}); diff --git a/packages/web/server/lib/walkthrough/languages.js b/packages/web/server/lib/walkthrough/languages.js new file mode 100644 index 00000000..787dfd36 --- /dev/null +++ b/packages/web/server/lib/walkthrough/languages.js @@ -0,0 +1,59 @@ +// Language the walkthrough prose is written in. +// +// This is the server's own list rather than an import from the UI package: the +// server cannot reach `packages/ui`, and the two lists answer different +// questions anyway. The UI list is "which locales do we have a dictionary +// for"; this one is "which languages may we ask a model to write in", and it +// needs the English endonym-free name that goes into the prompt. +// +// The tags match the UI's `Locale` union so the picker can pass its own value +// straight through. A tag we do not know resolves to English, which is exactly +// what the feature did before the setting existed. + +export const DEFAULT_LANGUAGE = 'en'; + +// Value is what the prompt says to write in. Naming the language in English +// keeps the instruction in the same language as the rest of the system prompt, +// which every model handles more reliably than a switch mid-sentence. +const LANGUAGE_NAMES = { + en: 'English', + fr: 'French', + 'zh-CN': 'Simplified Chinese', + 'zh-TW': 'Traditional Chinese', + uk: 'Ukrainian', + es: 'Spanish', + 'pt-BR': 'Brazilian Portuguese', + ko: 'Korean', + pl: 'Polish', + ja: 'Japanese', +}; + +/** + * Coerce a caller-supplied tag to one we support. + * + * Unknown, absent, and malformed all collapse to English rather than failing + * the request: the language is a preference about prose, and refusing to + * generate a walkthrough over one is a worse answer than writing it in English. + */ +export function normalizeLanguage(value) { + if (typeof value !== 'string' || !value) return DEFAULT_LANGUAGE; + if (Object.hasOwn(LANGUAGE_NAMES, value)) return value; + + // Tolerate case and separator drift (`uk-UA`, `pt_br`) so a runtime that + // passes a platform locale does not silently fall back to English. + const normalized = value.toLowerCase().replace(/_/g, '-'); + const match = Object.keys(LANGUAGE_NAMES).find((tag) => { + const lower = tag.toLowerCase(); + return lower === normalized || normalized.startsWith(`${lower}-`); + }); + if (match) return match; + + const base = normalized.split('-')[0]; + const baseMatch = Object.keys(LANGUAGE_NAMES).find((tag) => tag.toLowerCase() === base); + return baseMatch ?? DEFAULT_LANGUAGE; +} + +/** English name of a normalized tag, for the prompt. */ +export function languageName(language) { + return LANGUAGE_NAMES[language] ?? LANGUAGE_NAMES[DEFAULT_LANGUAGE]; +} diff --git a/packages/web/server/lib/walkthrough/model-settings.js b/packages/web/server/lib/walkthrough/model-settings.js new file mode 100644 index 00000000..84009b62 --- /dev/null +++ b/packages/web/server/lib/walkthrough/model-settings.js @@ -0,0 +1,38 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// The walkthrough may run on a different model than the rest of the small-model +// callers. Those callers want cheap and fast; this one needs structured output +// and enough context for a whole diff, and forcing one setting to serve both +// means the user has to degrade one feature to fix the other. + +const SETTINGS_FILE = path.join( + process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'), + 'settings.json', +); + +/** + * The explicit walkthrough model, or `null` to fall back to normal small-model + * resolution. + * + * Having chosen a model *is* the opt-out; a separate toggle would let the two + * disagree, and then clearing the picker would leave a setting that says "do + * not use the small model" with nothing to use instead. + */ +export function readWalkthroughModelOverride() { + try { + const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); + if (!settings || typeof settings !== 'object') return null; + const override = typeof settings.walkthroughModelOverride === 'string' + ? settings.walkthroughModelOverride.trim() + : ''; + return override || null; + } catch { + // No settings file, unreadable, or malformed all mean the same thing: no + // override, use the small model. + return null; + } +} diff --git a/packages/web/server/lib/walkthrough/model-settings.test.js b/packages/web/server/lib/walkthrough/model-settings.test.js new file mode 100644 index 00000000..984010d9 --- /dev/null +++ b/packages/web/server/lib/walkthrough/model-settings.test.js @@ -0,0 +1,58 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; + +const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'walkthrough-model-settings-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR; + +const { readWalkthroughModelOverride } = await import('./model-settings.js'); + +const SETTINGS_FILE = path.join(TEMP_DATA_DIR, 'settings.json'); + +const write = (value) => fs.writeFileSync(SETTINGS_FILE, JSON.stringify(value), 'utf8'); + +describe('readWalkthroughModelOverride', () => { + beforeEach(() => { + fs.rmSync(SETTINGS_FILE, { force: true }); + }); + + it('returns the chosen model', () => { + write({ walkthroughModelOverride: 'anthropic/claude-haiku-4-5' }); + expect(readWalkthroughModelOverride()).toBe('anthropic/claude-haiku-4-5'); + }); + + it('defers to the small model when nothing is chosen', () => { + write({}); + expect(readWalkthroughModelOverride()).toBeNull(); + + // Clearing the picker writes an empty string; that must read as "use the + // small model", not as an override of ''. + write({ walkthroughModelOverride: '' }); + expect(readWalkthroughModelOverride()).toBeNull(); + + write({ walkthroughModelOverride: ' ' }); + expect(readWalkthroughModelOverride()).toBeNull(); + }); + + it('never throws on a missing or corrupt settings file', () => { + expect(readWalkthroughModelOverride()).toBeNull(); + + fs.writeFileSync(SETTINGS_FILE, '{ not json', 'utf8'); + expect(readWalkthroughModelOverride()).toBeNull(); + }); + + it('is independent of the small model override', () => { + write({ + smallModelUseDefault: false, + smallModelOverride: 'google/gemini-2.5-flash', + walkthroughModelOverride: 'anthropic/claude-haiku-4-5', + }); + + expect(readWalkthroughModelOverride()).toBe('anthropic/claude-haiku-4-5'); + }); +}); + +afterAll(() => { + fs.rmSync(TEMP_DATA_DIR, { recursive: true, force: true }); +}); diff --git a/packages/web/server/lib/walkthrough/prompt.js b/packages/web/server/lib/walkthrough/prompt.js new file mode 100644 index 00000000..3f789810 --- /dev/null +++ b/packages/web/server/lib/walkthrough/prompt.js @@ -0,0 +1,103 @@ +import { DEFAULT_LANGUAGE, languageName } from './languages.js'; +import { MAX_CHAPTERS, MAX_CHAPTER_TITLE_CHARS, MAX_HUNKS_PER_STOP, MAX_STOPS } from './schema.js'; + +const SYSTEM = `You are writing a guided review of a code change for the engineer who is about to read it. + +Your job is to impose a reading order the diff itself does not have. A diff is ordered by file path, which is almost never the order in which the change makes sense. Group related hunks — across files — into stops, and order the stops so that each one is understandable given the ones before it. + +What a good stop says: +- what this code now does differently, in terms of behavior, not syntax +- why the surrounding hunks belong together +- what a reviewer should check or be suspicious about, when there is something + +What a bad stop says: +- "Renamed X to Y", "Added a parameter", "Updated the imports" — restating the diff in prose is worthless; the reader can already see it +- speculation about intent you cannot support from the code + +Rules: +- Anchor every stop to hunk aliases from the digest, exactly as given (h1, h2, …). Never invent an alias. +- Anchor each hunk at most once, in the stop where it matters most. +- You do not have to cover every hunk. Mechanical changes are better left out than padded into a stop; whatever you omit is still shown to the reader separately. +- Order stops so the reader builds understanding: entry points and data shape before the code that consumes them. +- importance: "critical" for changes that carry real risk or drive the rest, "context" for supporting changes, "normal" otherwise. +- Stop titles name the thing the stop is about, not the act of reviewing it. "Hardware keyboard bridge" and "Overflow menu removed" tell a reader scanning the contents what they will find; "Exercise the boundaries" and "Describe the contract" do not. +- Write prose as plain sentences. No markdown, no bullet lists, no code fences. + +Respond with a single JSON object and nothing else. (Some providers refuse a structured-output request unless the word "json" appears in the request, which is why this is stated explicitly.)`; + +// A reader who cannot follow English prose gets nothing out of a walkthrough, +// so the output language is the reader's, not the codebase's. +// +// Only prose is translated. Hunk aliases are keys the server resolves back to +// hunk ids, and `icon`/`importance` are enums the normalizer validates against +// fixed English values — translating either produces a walkthrough that drops +// its anchors or loses its styling, silently and completely. Identifiers taken +// from the diff stay verbatim for the same reason a translated function name +// would be unsearchable. +const languageInstruction = (language) => ` + +Write all prose in ${languageName(language)}: the walkthrough title, the focus line, chapter titles and blurbs, and stop titles and prose. The reader of this review reads ${languageName(language)}. + +Keep these in English exactly as given, regardless of the prose language: hunk aliases (h1, h2, …), the "icon" values, and the "importance" values. Keep identifiers, file paths, and API names as they appear in the code — never translate them.`; + +const sizing = ({ fileCount, hunkCount }) => { + const targetStops = Math.max(1, Math.min(MAX_STOPS, Math.round(hunkCount / 2.5) || 1)); + const targetChapters = hunkCount <= 4 + ? 1 + : Math.max(1, Math.min(MAX_CHAPTERS, Math.ceil(targetStops / 3))); + + return `This change has ${fileCount} file(s) and ${hunkCount} reviewable hunk(s). + +Aim for about ${targetStops} stop(s) across about ${targetChapters} chapter(s); never exceed ${MAX_STOPS} stops, ${MAX_CHAPTERS} chapters, or ${MAX_HUNKS_PER_STOP} hunks in one stop. Fewer, denser stops beat many thin ones. + +Chapter titles render in a narrow column: at most ${MAX_CHAPTER_TITLE_CHARS} characters, one or two words.`; +}; + +const previousWalkthroughSection = (previous) => { + if (!previous || !Array.isArray(previous.chapters) || previous.chapters.length === 0) return ''; + + const outline = previous.chapters + .map((chapter) => { + const stops = (chapter.stops || []) + .map((stop) => ` - ${stop.title}: ${stop.prose}`) + .join('\n'); + return `- ${chapter.title}${chapter.blurb ? ` — ${chapter.blurb}` : ''}\n${stops}`; + }) + .join('\n'); + + return ` +A previous walkthrough of an earlier state of this change is below. The code has moved on since it was written, so its anchors are gone — deliberately, so you re-anchor everything against the current digest. + +Keep the stops that are still accurate and phrased well, revise the ones whose code changed, drop the ones whose code no longer exists, and add stops for work that is new. Do not preserve its structure out of loyalty; preserve it only where it still fits. + +Previous walkthrough — "${previous.title}": +${outline} +`; +}; + +// Used only when a provider rejects a schema request: the shape has to travel +// in the prompt instead of the request body. +export const JSON_SHAPE_INSTRUCTION = ` +Return ONLY a JSON object, with no prose around it and no markdown fences, in exactly this shape: +{"title": string, "focus": string, "chapters": [{"title": string, "icon": "bug"|"wrench"|"path"|"flask"|"doc"|"gear", "blurb": string, "stops": [{"title": string, "hunks": [string], "importance": "critical"|"normal"|"context", "prose": string}]}]}`; + +export function buildPrompt({ digest, fileCount, hunkCount, source, previousWalkthrough, language = DEFAULT_LANGUAGE }) { + const sourceLine = source.kind === 'working-tree' + ? `Uncommitted local changes (${source.scope === 'all' ? 'staged and unstaged' : source.scope}).` + : source.kind === 'branch' + ? `All work on branch "${source.headRef}" that is not in "${source.baseRef}". Changes merged in from ${source.baseRef} are already excluded.` + : `Pull request #${source.number}.`; + + const prompt = `Reviewing: ${sourceLine} + +${sizing({ fileCount, hunkCount })} +${previousWalkthroughSection(previousWalkthrough)} +Change digest: +${JSON.stringify(digest)}`; + + // The default language adds nothing: the system prompt is already English, so + // saying so would only spend context restating it. + const system = language === DEFAULT_LANGUAGE ? SYSTEM : `${SYSTEM}${languageInstruction(language)}`; + + return { system, prompt }; +} diff --git a/packages/web/server/lib/walkthrough/pull-request.js b/packages/web/server/lib/walkthrough/pull-request.js new file mode 100644 index 00000000..e05a5f51 --- /dev/null +++ b/packages/web/server/lib/walkthrough/pull-request.js @@ -0,0 +1,46 @@ +import { getOctokitOrNull } from '../github/octokit.js'; +import { resolveGitHubRepoFromDirectory } from '../github/repo/index.js'; + +/** + * Raw unified diff for a pull request. + * + * GitHub already returns the merge-base diff for a PR, so this matches the + * three-dot semantics used for local branch reviews: work merged in from the + * base branch is not part of it. + */ +export async function getPullRequestDiff(directory, number) { + const octokit = getOctokitOrNull(); + if (!octokit) { + throw Object.assign(new Error('Connect a GitHub account to review pull requests'), { + statusCode: 401, + code: 'github-not-connected', + }); + } + + // The resolver returns `{ repo, remoteUrl }`, not the repo itself. Reading + // `.owner` off the wrapper made this check fail for every repository. + const { repo } = await resolveGitHubRepoFromDirectory(directory); + if (!repo?.owner || !repo?.repo) { + throw Object.assign(new Error('This directory has no GitHub remote'), { + statusCode: 400, + code: 'no-github-remote', + }); + } + + const response = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { + owner: repo.owner, + repo: repo.repo, + pull_number: number, + headers: { accept: 'application/vnd.github.v3.diff' }, + }); + + const patch = typeof response?.data === 'string' ? response.data : ''; + if (!patch.trim()) { + throw Object.assign(new Error(`Pull request #${number} has no diff`), { + statusCode: 404, + code: 'empty-diff', + }); + } + + return { patch, meta: { owner: repo.owner, repo: repo.repo, number } }; +} diff --git a/packages/web/server/lib/walkthrough/pull-request.test.js b/packages/web/server/lib/walkthrough/pull-request.test.js new file mode 100644 index 00000000..6a76a337 --- /dev/null +++ b/packages/web/server/lib/walkthrough/pull-request.test.js @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../github/octokit.js', () => ({ getOctokitOrNull: vi.fn() })); +vi.mock('../github/repo/index.js', () => ({ resolveGitHubRepoFromDirectory: vi.fn() })); + +const { getPullRequestDiff } = await import('./pull-request.js'); +const { getOctokitOrNull } = await import('../github/octokit.js'); +const { resolveGitHubRepoFromDirectory } = await import('../github/repo/index.js'); + +const PATCH = `diff --git a/src/a.ts b/src/a.ts +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,1 +1,2 @@ ++const added = true; +`; + +describe('getPullRequestDiff', () => { + let request; + + beforeEach(() => { + request = vi.fn().mockResolvedValue({ data: PATCH }); + getOctokitOrNull.mockReturnValue({ request }); + // The resolver hands back a wrapper, not the repo. Reading `.owner` off the + // wrapper made every repository look remote-less, which is what this suite + // exists to prevent. + resolveGitHubRepoFromDirectory.mockResolvedValue({ + repo: { owner: 'openchamber', repo: 'openchamber' }, + remoteUrl: 'git@github.com:openchamber/openchamber.git', + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('requests the diff for the resolved repository', async () => { + const result = await getPullRequestDiff('/repo', 2122); + + expect(result.patch).toBe(PATCH); + expect(result.meta).toEqual({ owner: 'openchamber', repo: 'openchamber', number: 2122 }); + expect(request).toHaveBeenCalledWith('GET /repos/{owner}/{repo}/pulls/{pull_number}', { + owner: 'openchamber', + repo: 'openchamber', + pull_number: 2122, + headers: { accept: 'application/vnd.github.v3.diff' }, + }); + }); + + it('reports a missing GitHub remote only when there really is none', async () => { + resolveGitHubRepoFromDirectory.mockResolvedValue({ repo: null, remoteUrl: null }); + + await expect(getPullRequestDiff('/repo', 2122)).rejects.toMatchObject({ + code: 'no-github-remote', + statusCode: 400, + }); + expect(request).not.toHaveBeenCalled(); + }); + + it('asks the user to connect GitHub before anything else', async () => { + getOctokitOrNull.mockReturnValue(null); + + await expect(getPullRequestDiff('/repo', 2122)).rejects.toMatchObject({ + code: 'github-not-connected', + statusCode: 401, + }); + expect(resolveGitHubRepoFromDirectory).not.toHaveBeenCalled(); + }); + + it('treats an empty diff as a missing pull request rather than an empty review', async () => { + request.mockResolvedValue({ data: ' ' }); + + await expect(getPullRequestDiff('/repo', 2122)).rejects.toMatchObject({ + code: 'empty-diff', + statusCode: 404, + }); + }); +}); diff --git a/packages/web/server/lib/walkthrough/routes.js b/packages/web/server/lib/walkthrough/routes.js new file mode 100644 index 00000000..e7ea12b1 --- /dev/null +++ b/packages/web/server/lib/walkthrough/routes.js @@ -0,0 +1,114 @@ +// `req.destroyed` is true for every healthy request once the body parser has +// consumed the stream, so using it as a disconnect check silently swallows every +// response. The response socket is the one that actually reflects whether the +// client is still there. +const clientIsGone = (res) => res.writableEnded || res.destroyed; + +export function registerWalkthroughRoutes(app, { getWalkthroughService }) { + const respondWithError = (res, error, fallback) => { + const statusCode = Number(error?.statusCode) || 500; + if (statusCode >= 500) { + console.error(`${fallback}:`, error); + } + res.status(statusCode).json({ + error: error?.message || fallback, + ...(error?.code ? { code: error.code } : {}), + ...(error?.model ? { model: error.model } : {}), + ...(Number.isFinite(error?.requiredChars) ? { requiredChars: error.requiredChars } : {}), + ...(Number.isFinite(error?.availableChars) ? { availableChars: error.availableChars } : {}), + }); + }; + + const readSource = (value) => { + if (typeof value !== 'string' || !value) return null; + try { + return JSON.parse(value); + } catch { + return null; + } + }; + + app.get('/api/walkthrough', async (req, res) => { + try { + const { getWalkthrough, getPullRequestDiff } = await getWalkthroughService(); + const directory = typeof req.query.directory === 'string' ? req.query.directory : ''; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const result = await getWalkthrough( + { + directory, + source: readSource(req.query.source), + model: typeof req.query.model === 'string' ? req.query.model : undefined, + language: typeof req.query.language === 'string' ? req.query.language : undefined, + }, + { getPullRequestDiff }, + ); + res.json(result); + } catch (error) { + respondWithError(res, error, 'Failed to load walkthrough'); + } + }); + + // Deliberately not aborted when the client disconnects: generation runs for + // minutes and a refresh must not throw the work away. Leaving detaches the + // client; the job finishes and caches its result. Stopping is an explicit + // request below. + app.post('/api/walkthrough/generate', async (req, res) => { + try { + const { generateWalkthrough, getPullRequestDiff } = await getWalkthroughService(); + const { directory, source, force, model, language } = req.body || {}; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory is required' }); + } + + const result = await generateWalkthrough( + { + directory, + source, + force: force === true, + model: typeof model === 'string' ? model : undefined, + language: typeof language === 'string' ? language : undefined, + }, + { getPullRequestDiff }, + ); + if (clientIsGone(res)) return; + res.json(result); + } catch (error) { + if (clientIsGone(res)) return; + respondWithError(res, error, 'Failed to generate walkthrough'); + } + }); + + // Memory-only, so it is safe to poll while a generation runs. The full read + // re-runs the whole git pipeline and must not be used for this. + app.get('/api/walkthrough/progress', async (req, res) => { + try { + const { getGenerationStage, getRepositoryRootFor } = await getWalkthroughService(); + const directory = typeof req.query.directory === 'string' ? req.query.directory : ''; + if (!directory) { + return res.status(400).json({ error: 'directory parameter is required' }); + } + + const { repoRoot, sourceKey } = await getRepositoryRootFor(directory, readSource(req.query.source)); + res.json({ stage: getGenerationStage(repoRoot, sourceKey) }); + } catch (error) { + respondWithError(res, error, 'Failed to read walkthrough progress'); + } + }); + + app.post('/api/walkthrough/cancel', async (req, res) => { + try { + const { cancelWalkthroughGeneration } = await getWalkthroughService(); + const { directory, source } = req.body || {}; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory is required' }); + } + + res.json(await cancelWalkthroughGeneration({ directory, source })); + } catch (error) { + respondWithError(res, error, 'Failed to cancel walkthrough generation'); + } + }); +} diff --git a/packages/web/server/lib/walkthrough/routes.test.js b/packages/web/server/lib/walkthrough/routes.test.js new file mode 100644 index 00000000..88ffaa55 --- /dev/null +++ b/packages/web/server/lib/walkthrough/routes.test.js @@ -0,0 +1,144 @@ +import express from 'express'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { registerWalkthroughRoutes } from './routes.js'; + +// These run over real HTTP on purpose. The bug this file exists for was +// invisible to unit tests: the service and the store were both correct, and the +// response was dropped by a disconnect check that misread a healthy request. + +const SOURCE = { kind: 'working-tree', scope: 'all' }; + +describe('walkthrough routes', () => { + let server; + let base; + let releaseJob; + let job; + + let lastArgs; + + const service = { + async getWalkthrough(args) { + lastArgs = args; + return { walkthrough: null, hunks: [], hunkCount: 0, generating: Boolean(job) }; + }, + async generateWalkthrough(args) { + lastArgs = args; + if (job) return job; + job = new Promise((resolve) => { + releaseJob = () => resolve({ walkthrough: { title: 'DONE' }, hunks: [], hunkCount: 1 }); + }).finally(() => { job = null; }); + return job; + }, + async cancelWalkthroughGeneration() { + return { cancelled: Boolean(job) }; + }, + }; + + const generate = (signal) => fetch(`${base}/api/walkthrough/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/repo', source: SOURCE }), + signal, + }); + + beforeEach(async () => { + job = null; + releaseJob = undefined; + lastArgs = undefined; + const app = express(); + app.use(express.json()); + registerWalkthroughRoutes(app, { getWalkthroughService: async () => service }); + server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + base = `http://127.0.0.1:${server.address().port}`; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(resolve)); + }); + + it('answers a generation request that nobody interrupted', async () => { + const pending = generate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + releaseJob(); + + const body = await (await pending).json(); + + expect(body.walkthrough).toEqual({ title: 'DONE' }); + }); + + it('delivers the result to a client that reconnected after a refresh', async () => { + const controller = new AbortController(); + generate(controller.signal).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + controller.abort(); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // The reloaded page sees work in progress and re-attaches to it. + const read = await (await fetch( + `${base}/api/walkthrough?directory=/repo&source=${encodeURIComponent(JSON.stringify(SOURCE))}`, + )).json(); + expect(read.generating).toBe(true); + + const reattached = generate(); + await new Promise((resolve) => setTimeout(resolve, 20)); + releaseJob(); + + const body = await (await reattached).json(); + expect(body.walkthrough).toEqual({ title: 'DONE' }); + }); + + it('rejects a request without a directory before touching the service', async () => { + const response = await fetch(`${base}/api/walkthrough/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: SOURCE }), + }); + + expect(response.status).toBe(400); + expect(job).toBeNull(); + }); + + // The language belongs to the request, not to a setting, so both the read + // and the generation have to carry it: readiness is computed from a prompt + // that contains the language instruction. + it('carries the requested language into the service', async () => { + await fetch( + `${base}/api/walkthrough?directory=/repo&language=uk&source=${encodeURIComponent(JSON.stringify(SOURCE))}`, + ); + expect(lastArgs.language).toBe('uk'); + + const pending = fetch(`${base}/api/walkthrough/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/repo', source: SOURCE, language: 'ja' }), + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + releaseJob(); + await pending; + + expect(lastArgs.language).toBe('ja'); + }); + + it('ignores a language that is not a string', async () => { + await fetch( + `${base}/api/walkthrough?directory=/repo&language[]=uk&source=${encodeURIComponent(JSON.stringify(SOURCE))}`, + ); + + expect(lastArgs.language).toBeUndefined(); + }); + + it('cancels through its own endpoint rather than a dropped connection', async () => { + generate().catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const response = await fetch(`${base}/api/walkthrough/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: '/repo', source: SOURCE }), + }); + + expect(await response.json()).toEqual({ cancelled: true }); + releaseJob(); + }); +}); diff --git a/packages/web/server/lib/walkthrough/schema.js b/packages/web/server/lib/walkthrough/schema.js new file mode 100644 index 00000000..d61f503d --- /dev/null +++ b/packages/web/server/lib/walkthrough/schema.js @@ -0,0 +1,175 @@ +// Shape of the walkthrough the model must produce, plus normalization of what +// it actually produced. The model is only ever trusted for prose and grouping — +// every anchor it returns is re-resolved against the digest here, and anything +// that does not resolve is dropped rather than rendered as a broken stop. + +export const WALKTHROUGH_VERSION = 1; + +// Bumping this invalidates every cached walkthrough, which is the point: a +// changed prompt produces different output and old entries would misrepresent +// what the current code would say. +export const PROMPT_VERSION = 3; + +export const MAX_CHAPTERS = 6; +export const MAX_STOPS = 16; +export const MAX_HUNKS_PER_STOP = 14; +export const MAX_CHAPTER_TITLE_CHARS = 24; + +const CHAPTER_ICONS = ['bug', 'wrench', 'path', 'flask', 'doc', 'gear']; +const STOP_IMPORTANCE = ['critical', 'normal', 'context']; + +export const responseSchema = { + type: 'object', + properties: { + title: { type: 'string' }, + focus: { type: 'string' }, + chapters: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + icon: { type: 'string', enum: CHAPTER_ICONS }, + blurb: { type: 'string' }, + stops: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + hunks: { type: 'array', items: { type: 'string' } }, + importance: { type: 'string', enum: STOP_IMPORTANCE }, + prose: { type: 'string' }, + }, + required: ['title', 'hunks', 'importance', 'prose'], + additionalProperties: false, + }, + }, + }, + required: ['title', 'icon', 'blurb', 'stops'], + additionalProperties: false, + }, + }, + }, + required: ['title', 'focus', 'chapters'], + additionalProperties: false, +}; + +const asString = (value, max) => { + if (typeof value !== 'string') return ''; + const trimmed = value.trim(); + return max && trimmed.length > max ? trimmed.slice(0, max) : trimmed; +}; + +/** + * Turn a raw model response into a walkthrough anchored to real hunk ids. + * + * @param {object} raw parsed model JSON + * @param {Map} idByAlias alias → real hunk id, from the digest + * @returns {{title: string, focus: string, chapters: Array, droppedAnchors: number}} + */ +export function normalizeWalkthrough(raw, idByAlias) { + if (!raw || typeof raw !== 'object') { + throw Object.assign(new Error('Model returned no walkthrough object'), { code: 'invalid-walkthrough' }); + } + + const usedIds = new Set(); + let droppedAnchors = 0; + let stopCount = 0; + + const chapters = []; + for (const [chapterIndex, rawChapter] of (Array.isArray(raw.chapters) ? raw.chapters : []).entries()) { + if (chapters.length >= MAX_CHAPTERS) break; + if (!rawChapter || typeof rawChapter !== 'object') continue; + + const stops = []; + for (const rawStop of Array.isArray(rawChapter.stops) ? rawChapter.stops : []) { + if (stopCount >= MAX_STOPS) break; + if (!rawStop || typeof rawStop !== 'object') continue; + + const hunkIds = []; + for (const alias of Array.isArray(rawStop.hunks) ? rawStop.hunks : []) { + const id = idByAlias.get(typeof alias === 'string' ? alias.trim() : ''); + if (!id) { + droppedAnchors += 1; + continue; + } + // One hunk belongs to exactly one stop; a model that anchors the same + // code twice would otherwise render it twice in the stream. + if (usedIds.has(id)) continue; + if (hunkIds.length >= MAX_HUNKS_PER_STOP) break; + usedIds.add(id); + hunkIds.push(id); + } + + const prose = asString(rawStop.prose); + if (hunkIds.length === 0 || !prose) continue; + + stopCount += 1; + stops.push({ + id: `stop-${chapterIndex + 1}-${stops.length + 1}`, + title: asString(rawStop.title) || `Step ${stopCount}`, + hunkIds, + importance: STOP_IMPORTANCE.includes(rawStop.importance) ? rawStop.importance : 'normal', + prose, + }); + } + + if (stops.length === 0) continue; + + chapters.push({ + id: `chapter-${chapters.length + 1}`, + title: asString(rawChapter.title, MAX_CHAPTER_TITLE_CHARS) || `Part ${chapters.length + 1}`, + icon: CHAPTER_ICONS.includes(rawChapter.icon) ? rawChapter.icon : 'doc', + blurb: asString(rawChapter.blurb), + stops, + }); + } + + if (chapters.length === 0) { + throw Object.assign( + new Error('Model returned no usable stops for this diff'), + { code: 'invalid-walkthrough' }, + ); + } + + return { + title: asString(raw.title) || 'Change walkthrough', + focus: asString(raw.focus), + chapters, + droppedAnchors, + }; +} + +/** + * Extract a JSON object from a model response that may or may not honour the + * schema — some providers wrap it in prose or a fenced block. + */ +export function parseModelJson(text) { + if (typeof text !== 'string' || !text.trim()) { + throw Object.assign(new Error('Model returned an empty response'), { code: 'invalid-walkthrough' }); + } + + const withoutFence = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); + + try { + return JSON.parse(withoutFence); + } catch { + // Fall through to a bounded scan for the outermost object. + } + + const start = withoutFence.indexOf('{'); + if (start === -1) { + throw Object.assign(new Error('Model response contained no JSON object'), { code: 'invalid-walkthrough' }); + } + + for (let end = withoutFence.lastIndexOf('}'); end > start; end = withoutFence.lastIndexOf('}', end - 1)) { + try { + return JSON.parse(withoutFence.slice(start, end + 1)); + } catch { + // Keep shrinking from the right. + } + } + + throw Object.assign(new Error('Model response was not valid JSON'), { code: 'invalid-walkthrough' }); +} diff --git a/packages/web/server/lib/walkthrough/schema.test.js b/packages/web/server/lib/walkthrough/schema.test.js new file mode 100644 index 00000000..ae889698 --- /dev/null +++ b/packages/web/server/lib/walkthrough/schema.test.js @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeWalkthrough, parseModelJson, MAX_STOPS } from './schema.js'; + +const ALIASES = new Map([ + ['h1', 'working:src/a.ts:aaaa1111'], + ['h2', 'working:src/a.ts:bbbb2222'], + ['h3', 'working:src/b.ts:cccc3333'], +]); + +const walkthrough = (chapters) => ({ title: 'Change', focus: 'why', chapters }); + +describe('normalizeWalkthrough', () => { + it('maps aliases to real hunk ids and assigns stable local ids', () => { + const result = normalizeWalkthrough(walkthrough([ + { + title: 'Data', + icon: 'doc', + blurb: 'shape first', + stops: [ + { title: 'New field', hunks: ['h1', 'h2'], importance: 'critical', prose: 'Adds a field.' }, + ], + }, + ]), ALIASES); + + expect(result.chapters[0].id).toBe('chapter-1'); + expect(result.chapters[0].stops[0]).toMatchObject({ + id: 'stop-1-1', + hunkIds: ['working:src/a.ts:aaaa1111', 'working:src/a.ts:bbbb2222'], + importance: 'critical', + }); + }); + + it('drops invented aliases instead of rendering a broken anchor', () => { + const result = normalizeWalkthrough(walkthrough([ + { + title: 'Data', + icon: 'doc', + blurb: '', + stops: [ + { title: 'Mixed', hunks: ['h1', 'h99', 'nonsense'], importance: 'normal', prose: 'Something.' }, + ], + }, + ]), ALIASES); + + expect(result.chapters[0].stops[0].hunkIds).toEqual(['working:src/a.ts:aaaa1111']); + expect(result.droppedAnchors).toBe(2); + }); + + it('anchors each hunk to a single stop', () => { + const result = normalizeWalkthrough(walkthrough([ + { + title: 'Data', + icon: 'doc', + blurb: '', + stops: [ + { title: 'First', hunks: ['h1'], importance: 'normal', prose: 'One.' }, + { title: 'Second', hunks: ['h1', 'h2'], importance: 'normal', prose: 'Two.' }, + ], + }, + ]), ALIASES); + + expect(result.chapters[0].stops[0].hunkIds).toEqual(['working:src/a.ts:aaaa1111']); + expect(result.chapters[0].stops[1].hunkIds).toEqual(['working:src/a.ts:bbbb2222']); + }); + + it('discards stops left with no anchor or no prose', () => { + const result = normalizeWalkthrough(walkthrough([ + { + title: 'Data', + icon: 'doc', + blurb: '', + stops: [ + { title: 'Ghost', hunks: ['h99'], importance: 'normal', prose: 'About nothing.' }, + { title: 'Silent', hunks: ['h1'], importance: 'normal', prose: ' ' }, + { title: 'Real', hunks: ['h2'], importance: 'normal', prose: 'Actual explanation.' }, + ], + }, + ]), ALIASES); + + expect(result.chapters[0].stops.map((stop) => stop.title)).toEqual(['Real']); + }); + + it('rejects a response whose stops all fall away', () => { + expect(() => normalizeWalkthrough(walkthrough([ + { title: 'Empty', icon: 'doc', blurb: '', stops: [{ title: 'Ghost', hunks: ['h99'], importance: 'normal', prose: 'x' }] }, + ]), ALIASES)).toThrow('no usable stops'); + }); + + it('clamps chapter titles and falls back on unknown enums', () => { + const result = normalizeWalkthrough(walkthrough([ + { + title: 'An extremely long chapter title that will not fit the column', + icon: 'rocket', + blurb: '', + stops: [{ title: 'A', hunks: ['h1'], importance: 'urgent', prose: 'Text.' }], + }, + ]), ALIASES); + + expect(result.chapters[0].title.length).toBeLessThanOrEqual(24); + expect(result.chapters[0].icon).toBe('doc'); + expect(result.chapters[0].stops[0].importance).toBe('normal'); + }); + + it('caps the total number of stops', () => { + const many = Array.from({ length: 30 }, (_, index) => ({ + title: `Stop ${index}`, + hunks: [['h1', 'h2', 'h3'][index % 3]], + importance: 'normal', + prose: 'Text.', + })); + + const result = normalizeWalkthrough( + walkthrough([{ title: 'All', icon: 'doc', blurb: '', stops: many }]), + ALIASES, + ); + + const total = result.chapters.reduce((sum, chapter) => sum + chapter.stops.length, 0); + expect(total).toBeLessThanOrEqual(MAX_STOPS); + // Only three aliases exist and each is used once, so the real cap here is + // the alias pool, not the stop limit. + expect(total).toBe(3); + }); +}); + +describe('parseModelJson', () => { + it('parses a clean object', () => { + expect(parseModelJson('{"title":"x"}')).toEqual({ title: 'x' }); + }); + + it('unwraps a fenced block', () => { + expect(parseModelJson('```json\n{"title":"x"}\n```')).toEqual({ title: 'x' }); + }); + + it('recovers an object followed by stray prose', () => { + expect(parseModelJson('{"title":"x"}\n\nHope that helps!')).toEqual({ title: 'x' }); + }); + + it('fails loudly on unusable output', () => { + expect(() => parseModelJson('')).toThrow('empty response'); + expect(() => parseModelJson('no json at all')).toThrow('no JSON object'); + expect(() => parseModelJson('{"broken":')).toThrow('not valid JSON'); + }); +}); diff --git a/packages/web/server/lib/walkthrough/sources.js b/packages/web/server/lib/walkthrough/sources.js new file mode 100644 index 00000000..ab705995 --- /dev/null +++ b/packages/web/server/lib/walkthrough/sources.js @@ -0,0 +1,116 @@ +import { getDiff, getRangeDiff, getUntrackedDiffs, listUntrackedPaths } from '../git/service.js'; + +// A walkthrough source resolves to one or more diff *sections*. A section is a +// patch plus the scope its hunk ids live in; keeping staged and working-tree +// changes in separate scopes means a stop written against staged code never +// silently re-anchors onto an unstaged edit of the same lines. + +const WORKING_TREE_SCOPES = new Set(['all', 'staged', 'working']); + +export class WalkthroughSourceError extends Error { + constructor(message, statusCode = 400, code = undefined) { + super(message); + this.statusCode = statusCode; + if (code) this.code = code; + } +} + +/** + * Normalize and validate an untrusted source descriptor from the client. + */ +export function parseSource(raw) { + if (!raw || typeof raw !== 'object') { + throw new WalkthroughSourceError('source is required'); + } + + if (raw.kind === 'working-tree') { + const scope = typeof raw.scope === 'string' ? raw.scope : 'all'; + if (!WORKING_TREE_SCOPES.has(scope)) { + throw new WalkthroughSourceError(`Unknown working-tree scope "${scope}"`); + } + return { kind: 'working-tree', scope }; + } + + if (raw.kind === 'branch') { + const baseRef = typeof raw.baseRef === 'string' ? raw.baseRef.trim() : ''; + const headRef = typeof raw.headRef === 'string' ? raw.headRef.trim() : ''; + if (!baseRef || !headRef) { + throw new WalkthroughSourceError('branch sources require baseRef and headRef'); + } + return { kind: 'branch', baseRef, headRef }; + } + + if (raw.kind === 'pr') { + const number = Number(raw.number); + if (!Number.isInteger(number) || number <= 0) { + throw new WalkthroughSourceError('pr sources require a positive number'); + } + return { kind: 'pr', number }; + } + + throw new WalkthroughSourceError(`Unknown source kind "${String(raw.kind)}"`); +} + +/** + * Stable string form of a source, used as the pointer key and as part of the + * cache key. Must not change shape casually — it addresses persisted files. + */ +export function sourceKey(source) { + if (source.kind === 'working-tree') return `working-tree:${source.scope}`; + if (source.kind === 'branch') return `branch:${source.baseRef}...${source.headRef}`; + return `pr:${source.number}`; +} + +// `git diff` never reports untracked files, so a brand-new file would be +// invisible in a walkthrough of local work. The batch helper resolves the +// repository once and bounds how many diff processes run at a time. +const untrackedSections = async (directory) => { + const untracked = await listUntrackedPaths(directory); + if (untracked.length === 0) return []; + + const patches = await getUntrackedDiffs(directory, untracked); + return patches.filter((patch) => typeof patch === 'string' && patch.trim()); +}; + +/** + * Resolve a source into diff sections. + * + * @returns {Promise<{sections: Array<{scope: string, patch: string}>, meta: object}>} + */ +export async function loadSourceSections(directory, source, { getPullRequestDiff } = {}) { + if (source.kind === 'working-tree') { + const sections = []; + + if (source.scope === 'all' || source.scope === 'staged') { + const patch = await getDiff(directory, { staged: true }); + if (patch && patch.trim()) sections.push({ scope: 'staged', patch }); + } + + if (source.scope === 'all' || source.scope === 'working') { + const patch = await getDiff(directory, { staged: false }); + const untracked = await untrackedSections(directory); + const combined = [patch, ...untracked].filter((value) => value && value.trim()).join('\n'); + if (combined.trim()) sections.push({ scope: 'working', patch: combined }); + } + + return { sections, meta: {} }; + } + + if (source.kind === 'branch') { + const patch = await getRangeDiff(directory, { base: source.baseRef, head: source.headRef }); + return { + sections: patch && patch.trim() ? [{ scope: 'branch', patch }] : [], + meta: { baseRef: source.baseRef, headRef: source.headRef }, + }; + } + + if (typeof getPullRequestDiff !== 'function') { + throw new WalkthroughSourceError('Pull request diffs are unavailable', 500); + } + + const { patch, meta } = await getPullRequestDiff(directory, source.number); + return { + sections: patch && patch.trim() ? [{ scope: `pr:${source.number}`, patch }] : [], + meta: meta || {}, + }; +} diff --git a/packages/web/server/lib/walkthrough/store.js b/packages/web/server/lib/walkthrough/store.js new file mode 100644 index 00000000..61571d9c --- /dev/null +++ b/packages/web/server/lib/walkthrough/store.js @@ -0,0 +1,230 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import fsp from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { PROMPT_VERSION, WALKTHROUGH_VERSION } from './schema.js'; + +// Two artifacts with two different jobs. +// +// Cache entries are content-addressed and immutable: the key is derived from +// the *current* diff, so a hit means "this walkthrough was written about +// exactly this code". There is no freshness question to ask of an entry — +// staleness is a miss. +// +// The pointer is mutable and keyed by repository + source only. It answers the +// questions the cache cannot: which walkthrough was the last one here, what was +// it written about, and has the code moved since. It is also what feeds the +// previous walkthrough into a regeneration. + +const DATA_DIR = process.env.OPENCHAMBER_DATA_DIR + ? path.resolve(process.env.OPENCHAMBER_DATA_DIR) + : path.join(os.homedir(), '.config', 'openchamber'); + +const WALKTHROUGH_DIR = path.join(DATA_DIR, 'walkthroughs'); +const ENTRIES_DIR = path.join(WALKTHROUGH_DIR, 'entries'); +const POINTERS_DIR = path.join(WALKTHROUGH_DIR, 'pointers'); + +const MAX_ENTRIES = 200; +const MAX_TOTAL_BYTES = 50 * 1024 * 1024; +const MAX_FILE_BYTES = 4 * 1024 * 1024; + +const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); + +const ensureDir = (dir) => { + try { + fs.mkdirSync(dir, { recursive: true }); + return true; + } catch (error) { + console.error('[walkthrough] failed to create store directory:', error?.message || error); + return false; + } +}; + +// Atomic so a crash mid-write leaves the previous entry intact rather than a +// half-written file that later fails to parse. +const writeJsonAtomic = (filePath, value) => { + if (!ensureDir(path.dirname(filePath))) return false; + const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`; + try { + fs.writeFileSync(tmp, JSON.stringify(value), 'utf8'); + fs.renameSync(tmp, filePath); + return true; + } catch (error) { + console.error('[walkthrough] failed to write store file:', error?.message || error); + try { + fs.unlinkSync(tmp); + } catch { + // Nothing else to do; the temp file is already orphaned. + } + return false; + } +}; + +const readJson = (filePath) => { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return null; + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + // Missing, unreadable, or corrupt all mean the same thing to callers: no + // usable cached walkthrough. Never throw — a bad cache file must not break + // the feature. + return null; + } +}; + +/** + * Content-addressed key. Every input that can change the output is in here: + * change any of them and you get a miss rather than a stale hit. + */ +export function buildCacheKey({ repoRoot, sourceKey, providerID, modelID, language, files }) { + const canonical = JSON.stringify({ + walkthroughVersion: WALKTHROUGH_VERSION, + promptVersion: PROMPT_VERSION, + repoRoot, + sourceKey, + providerID, + modelID, + // Without this, switching language hits the entry written in the previous + // one and the panel answers a request to translate with the untranslated + // text it already had. + language, + files: [...files] + .map((file) => ({ path: file.path, status: file.status, hunkIds: file.hunks.map((hunk) => hunk.id) })) + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)), + }); + return sha256(canonical); +} + +const entryPath = (cacheKey) => path.join(ENTRIES_DIR, `${cacheKey}.json`); +const pointerPath = (repoRoot, sourceKey) => path.join(POINTERS_DIR, `${sha256(`${repoRoot}\0${sourceKey}`)}.json`); + +const isWalkthroughEntry = (value) => Boolean( + value + && typeof value === 'object' + && value.walkthroughVersion === WALKTHROUGH_VERSION + && value.walkthrough + && Array.isArray(value.walkthrough.chapters), +); + +export function readCachedWalkthrough(cacheKey) { + const value = readJson(entryPath(cacheKey)); + return isWalkthroughEntry(value) ? value : null; +} + +export function writeCachedWalkthrough(cacheKey, entry) { + const written = writeJsonAtomic(entryPath(cacheKey), { + walkthroughVersion: WALKTHROUGH_VERSION, + ...entry, + }); + if (written) evictEntries(); + return written; +} + +export function readPointer(repoRoot, sourceKey) { + const value = readJson(pointerPath(repoRoot, sourceKey)); + if (!value || typeof value !== 'object' || typeof value.cacheKey !== 'string') return null; + return value; +} + +export function writePointer(repoRoot, sourceKey, pointer) { + return writeJsonAtomic(pointerPath(repoRoot, sourceKey), pointer); +} + +/** + * Bound the cache by count and total size, dropping least-recently-used + * entries. Pointers are tiny and are left alone; a pointer to an evicted entry + * simply reads as "no walkthrough", which is the truthful answer. + */ +function evictEntries() { + let files; + try { + files = fs.readdirSync(ENTRIES_DIR) + .filter((name) => name.endsWith('.json')) + .map((name) => { + const full = path.join(ENTRIES_DIR, name); + try { + const stat = fs.statSync(full); + return { full, size: stat.size, atime: stat.atimeMs }; + } catch { + return null; + } + }) + .filter(Boolean); + } catch { + return; + } + + let totalBytes = files.reduce((sum, file) => sum + file.size, 0); + if (files.length <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) return; + + files.sort((a, b) => a.atime - b.atime); + let count = files.length; + for (const file of files) { + if (count <= MAX_ENTRIES && totalBytes <= MAX_TOTAL_BYTES) break; + try { + fs.unlinkSync(file.full); + count -= 1; + totalBytes -= file.size; + } catch { + // Skip files we cannot remove; the next write retries. + } + } +} + +// Housekeeping runs off the request path and never synchronously. +// +// The Electron desktop app hosts this server inside the main process, so a +// blocking loop here stalls IPC and the window, not just one request. Worse, +// the paths being checked are user repositories: a worktree on an unplugged +// drive or an unreachable network share can make a single existence check hang +// for seconds. Async calls wait without holding the loop, and the cap keeps a +// pathological directory from turning into a long tail of work. +const PRUNE_LIMIT = 500; + +/** + * Drop pointers for repositories that no longer exist. Only ever removes + * entries whose subject is provably gone. + */ +export async function pruneMissingRepositories() { + let names; + try { + names = (await fsp.readdir(POINTERS_DIR)).filter((name) => name.endsWith('.json')); + } catch { + return 0; + } + + let removed = 0; + for (const name of names.slice(0, PRUNE_LIMIT)) { + const full = path.join(POINTERS_DIR, name); + let repoRoot = null; + try { + const value = JSON.parse(await fsp.readFile(full, 'utf8')); + repoRoot = value && typeof value.repoRoot === 'string' ? value.repoRoot : null; + } catch { + continue; + } + if (!repoRoot) continue; + + try { + await fsp.stat(repoRoot); + continue; + } catch (error) { + // Unreachable is not the same as gone. Only a definite "no such file" + // justifies deleting: a disconnected share or a permissions error must + // not cost the user their walkthroughs. + if (error?.code !== 'ENOENT') continue; + } + + try { + await fsp.unlink(full); + removed += 1; + } catch { + // Leave it; the next prune retries. + } + } + return removed; +} + +export const __testing = { WALKTHROUGH_DIR, ENTRIES_DIR, POINTERS_DIR, MAX_ENTRIES, MAX_TOTAL_BYTES }; diff --git a/packages/web/server/lib/walkthrough/store.test.js b/packages/web/server/lib/walkthrough/store.test.js new file mode 100644 index 00000000..4e1a80f4 --- /dev/null +++ b/packages/web/server/lib/walkthrough/store.test.js @@ -0,0 +1,206 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; + +// The store resolves its directory at import time from the environment, so the +// temp dir has to be in place before the module is loaded. +const TEMP_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'walkthrough-store-')); +process.env.OPENCHAMBER_DATA_DIR = TEMP_ROOT; + +const store = await import('./store.js'); +const { + buildCacheKey, + readCachedWalkthrough, + writeCachedWalkthrough, + readPointer, + writePointer, + pruneMissingRepositories, + __testing, +} = store; + +const files = (overrides = []) => ([ + { + path: 'src/a.ts', + status: 'modified', + hunks: [{ id: 'working:src/a.ts:aaaa1111' }, { id: 'working:src/a.ts:bbbb2222' }], + }, + { + path: 'src/b.ts', + status: 'added', + hunks: [{ id: 'working:src/b.ts:cccc3333' }], + }, + ...overrides, +]); + +const baseKeyInput = { + repoRoot: '/repo', + sourceKey: 'working-tree:all', + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + files: files(), +}; + +const entry = (cacheKey) => ({ + cacheKey, + generatedAt: '2026-08-02T00:00:00.000Z', + repoRoot: '/repo', + sourceKey: 'working-tree:all', + model: { providerID: 'anthropic', modelID: 'claude-haiku-4-5' }, + walkthrough: { title: 'x', focus: '', chapters: [{ id: 'chapter-1', stops: [] }] }, +}); + +describe('buildCacheKey', () => { + it('is stable for identical input', () => { + expect(buildCacheKey(baseKeyInput)).toBe(buildCacheKey(baseKeyInput)); + }); + + it('ignores file ordering', () => { + const reordered = { ...baseKeyInput, files: [...baseKeyInput.files].reverse() }; + expect(buildCacheKey(reordered)).toBe(buildCacheKey(baseKeyInput)); + }); + + it('changes when any hunk changes', () => { + const edited = { + ...baseKeyInput, + files: [ + { ...baseKeyInput.files[0], hunks: [{ id: 'working:src/a.ts:aaaa1111' }, { id: 'working:src/a.ts:dddd4444' }] }, + baseKeyInput.files[1], + ], + }; + expect(buildCacheKey(edited)).not.toBe(buildCacheKey(baseKeyInput)); + }); + + it('separates repositories, sources, and models', () => { + const original = buildCacheKey(baseKeyInput); + expect(buildCacheKey({ ...baseKeyInput, repoRoot: '/other' })).not.toBe(original); + expect(buildCacheKey({ ...baseKeyInput, sourceKey: 'working-tree:staged' })).not.toBe(original); + expect(buildCacheKey({ ...baseKeyInput, modelID: 'other-model' })).not.toBe(original); + expect(buildCacheKey({ ...baseKeyInput, providerID: 'google' })).not.toBe(original); + }); +}); + +describe('cache entries', () => { + beforeEach(() => { + fs.rmSync(__testing.ENTRIES_DIR, { recursive: true, force: true }); + fs.rmSync(__testing.POINTERS_DIR, { recursive: true, force: true }); + }); + + it('round-trips a walkthrough', () => { + const key = buildCacheKey(baseKeyInput); + expect(writeCachedWalkthrough(key, entry(key))).toBe(true); + + const read = readCachedWalkthrough(key); + expect(read.walkthrough.title).toBe('x'); + expect(read.cacheKey).toBe(key); + }); + + it('reports a miss for an unknown key', () => { + expect(readCachedWalkthrough('0'.repeat(64))).toBeNull(); + }); + + it('treats a corrupt entry as a miss rather than throwing', () => { + const key = buildCacheKey(baseKeyInput); + writeCachedWalkthrough(key, entry(key)); + fs.writeFileSync(path.join(__testing.ENTRIES_DIR, `${key}.json`), '{ not json', 'utf8'); + + expect(readCachedWalkthrough(key)).toBeNull(); + }); + + it('rejects an entry written by an incompatible version', () => { + const key = buildCacheKey(baseKeyInput); + writeCachedWalkthrough(key, entry(key)); + const file = path.join(__testing.ENTRIES_DIR, `${key}.json`); + const stored = JSON.parse(fs.readFileSync(file, 'utf8')); + fs.writeFileSync(file, JSON.stringify({ ...stored, walkthroughVersion: 999 }), 'utf8'); + + expect(readCachedWalkthrough(key)).toBeNull(); + }); + + it('leaves no temp files behind', () => { + const key = buildCacheKey(baseKeyInput); + writeCachedWalkthrough(key, entry(key)); + + const leftovers = fs.readdirSync(__testing.ENTRIES_DIR).filter((name) => name.includes('.tmp')); + expect(leftovers).toEqual([]); + }); + + it('evicts least-recently-used entries past the count limit', () => { + for (let index = 0; index < __testing.MAX_ENTRIES + 10; index += 1) { + const key = buildCacheKey({ ...baseKeyInput, sourceKey: `source-${index}` }); + writeCachedWalkthrough(key, entry(key)); + } + + const remaining = fs.readdirSync(__testing.ENTRIES_DIR).filter((name) => name.endsWith('.json')); + expect(remaining.length).toBeLessThanOrEqual(__testing.MAX_ENTRIES); + }); +}); + +describe('pointers', () => { + beforeEach(() => { + fs.rmSync(__testing.POINTERS_DIR, { recursive: true, force: true }); + }); + + it('round-trips and stays scoped to its source', () => { + writePointer('/repo', 'working-tree:all', { + repoRoot: '/repo', + sourceKey: 'working-tree:all', + cacheKey: 'abc', + generatedAt: 'now', + }); + + expect(readPointer('/repo', 'working-tree:all')).toMatchObject({ cacheKey: 'abc' }); + expect(readPointer('/repo', 'working-tree:staged')).toBeNull(); + }); + + it('keeps different repositories apart', () => { + writePointer('/repo-a', 'working-tree:all', { repoRoot: '/repo-a', cacheKey: 'a' }); + writePointer('/repo-b', 'working-tree:all', { repoRoot: '/repo-b', cacheKey: 'b' }); + + expect(readPointer('/repo-a', 'working-tree:all').cacheKey).toBe('a'); + expect(readPointer('/repo-b', 'working-tree:all').cacheKey).toBe('b'); + }); + + it('prunes only pointers whose repository is gone', async () => { + const liveRepo = fs.mkdtempSync(path.join(TEMP_ROOT, 'live-repo-')); + writePointer(liveRepo, 'working-tree:all', { repoRoot: liveRepo, cacheKey: 'live' }); + writePointer('/definitely/not/here', 'working-tree:all', { + repoRoot: '/definitely/not/here', + cacheKey: 'dead', + }); + + expect(await pruneMissingRepositories()).toBe(1); + expect(readPointer(liveRepo, 'working-tree:all')).toMatchObject({ cacheKey: 'live' }); + expect(readPointer('/definitely/not/here', 'working-tree:all')).toBeNull(); + }); + + it('keeps a pointer whose repository is merely unreachable', async () => { + // A path we cannot stat for a reason other than absence — an unplugged + // drive or a dead share behaves this way. Deleting then would cost the user + // walkthroughs for a repository that still exists. + const blocked = fs.mkdtempSync(path.join(TEMP_ROOT, 'blocked-')); + const inaccessible = path.join(blocked, 'inner', 'repo'); + fs.mkdirSync(path.join(blocked, 'inner'), { recursive: true }); + fs.mkdirSync(inaccessible); + writePointer(inaccessible, 'working-tree:all', { repoRoot: inaccessible, cacheKey: 'blocked' }); + fs.chmodSync(path.join(blocked, 'inner'), 0o000); + + try { + expect(await pruneMissingRepositories()).toBe(0); + expect(readPointer(inaccessible, 'working-tree:all')).toMatchObject({ cacheKey: 'blocked' }); + } finally { + fs.chmodSync(path.join(blocked, 'inner'), 0o755); + } + }); + + it('survives a pointer file it cannot parse', async () => { + fs.mkdirSync(__testing.POINTERS_DIR, { recursive: true }); + fs.writeFileSync(path.join(__testing.POINTERS_DIR, 'broken.json'), '{ not json', 'utf8'); + + await expect(pruneMissingRepositories()).resolves.toBeGreaterThanOrEqual(0); + }); +}); + +afterAll(() => { + fs.rmSync(TEMP_ROOT, { recursive: true, force: true }); +}); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index 41d57577..80c48a46 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -10,6 +10,7 @@ export const createWebGitAPI = (): GitAPI => ({ getGitStatus: gitApiHttp.getGitStatus, getGitDiff: gitApiHttp.getGitDiff, getGitFileDiff: gitApiHttp.getGitFileDiff, + getGitRangeDiff: gitApiHttp.getGitRangeDiff, revertGitFile: gitApiHttp.revertGitFile, stageGitFile: gitApiHttp.stageGitFile, stageGitFiles: gitApiHttp.stageGitFiles, diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 2b1c4fe7..272f775a 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -135,9 +135,23 @@ export default defineConfig({ external: ['node:child_process', 'node:fs', 'node:path', 'node:url'], output: { manualChunks(id) { + // Pin Vite's tiny runtime helpers to their own stable chunk. Otherwise + // Rollup co-locates the `__vitePreload` helper into an arbitrary vendor + // chunk (e.g. `shiki`), and since every dynamic import pulls the helper, + // that whole vendor (here Shiki core + the 629KB oniguruma engine) gets + // dragged into the eager bootstrap graph. + if (id.includes('vite/preload-helper') || id.includes('vite/modulepreload-polyfill')) { + return 'vendor-vite-runtime'; + } if (!id.includes('node_modules')) return undefined; - const match = id.split('node_modules/')[1]; + // Resolve the real package from the LAST `node_modules/` segment. + // bun's isolated install nests packages as + // `node_modules/.bun/@/node_modules//...`, so the first + // `node_modules/` segment is `.bun` — using it collapses every dependency + // (incl. lazy-only ones) into a single giant eager `vendor-.bun` chunk. + const lastNodeModules = id.lastIndexOf('node_modules/'); + const match = id.slice(lastNodeModules + 'node_modules/'.length); if (!match) return undefined; const segments = match.split('/'); @@ -149,7 +163,6 @@ export default defineConfig({ if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk'; if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown'; if (packageName === '@base-ui/react' || packageName.startsWith('@base-ui')) return 'vendor-base-ui'; - if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax'; const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-'); return `vendor-${sanitized}`; diff --git a/vite.config.ts b/vite.config.ts index 5f6cb03c..ae9721f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -33,9 +33,23 @@ export default defineConfig({ external: ['node:child_process', 'node:fs', 'node:path', 'node:url'], output: { manualChunks(id) { + // Pin Vite's tiny runtime helpers to their own stable chunk. Otherwise + // Rollup co-locates the `__vitePreload` helper into an arbitrary vendor + // chunk (e.g. `shiki`), and since every dynamic import pulls the helper, + // that whole vendor (here Shiki core + the 629KB oniguruma engine) gets + // dragged into the eager bootstrap graph. + if (id.includes('vite/preload-helper') || id.includes('vite/modulepreload-polyfill')) { + return 'vendor-vite-runtime' + } if (!id.includes('node_modules')) return undefined - const match = id.split('node_modules/')[1] + // Resolve the real package from the LAST `node_modules/` segment. + // bun's isolated install nests packages as + // `node_modules/.bun/@/node_modules//...`, so the first + // `node_modules/` segment is `.bun` — using it collapses every dependency + // (incl. lazy-only ones) into a single giant eager `vendor-.bun` chunk. + const lastNodeModules = id.lastIndexOf('node_modules/') + const match = id.slice(lastNodeModules + 'node_modules/'.length) if (!match) return undefined const segments = match.split('/') @@ -46,7 +60,6 @@ export default defineConfig({ if (packageName === '@opencode-ai/sdk') return 'vendor-opencode-sdk' if (packageName.includes('remark') || packageName.includes('rehype') || packageName === 'react-markdown') return 'vendor-markdown' if (packageName === '@base-ui/react' || packageName.startsWith('@base-ui')) return 'vendor-base-ui' - if (packageName.includes('react-syntax-highlighter') || packageName.includes('highlight.js')) return 'vendor-syntax' const sanitized = packageName.replace(/^@/, '').replace(/\//g, '-') return `vendor-${sanitized}`