Merge origin/main into deferred OpenCode restart branch
This commit is contained in:
@@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.18.1] - 2026-08-04
|
||||||
|
|
||||||
|
- **Providers:** signing in to an OAuth-only provider now actually completes — the browser login is stored and the provider list updates instead of remaining signed out. OAuth-only providers show a Connect flow instead of an API key form, and their models stay hidden until you are signed in.
|
||||||
|
- **Sessions:** archived sessions can now be restored to the active list — from the sidebar context menu, the archived-sessions page, or the bulk-selection bar — instead of only offering permanent deletion (thanks to @makeittech).
|
||||||
|
- Walkthrough: models without a working provider login no longer appear in the walkthrough picker, and Generate stays disabled until a usable model is selected instead of failing with a raw provider error.
|
||||||
|
- Providers: sign-ins that need extra details (such as GitHub Copilot Enterprise) now ask for them before opening the browser, and device codes come with a working copy button.
|
||||||
|
- Walkthrough: connecting to a server older than the app now says the server needs updating instead of showing a raw HTML parsing error, and the "Critical" tag is now "Key change" with a tooltip so it no longer reads as a problem found in your code.
|
||||||
|
- Chat: Ctrl/Cmd+L now adds the selected text to the chat input, or focuses it when nothing is selected; the toggle-sidebar shortcut moved to Ctrl/Cmd+Alt+L.
|
||||||
|
- Chat: a manually chosen model now stays selected after a delegated subtask finishes, instead of reverting to the agent's default model.
|
||||||
|
- Agents/CLI: sending a prompt that never reaches its session is now reported as failed, and an unavailable model, agent, or variant is rejected with a clear error before anything is created.
|
||||||
|
- Desktop/Linux: "Open in Terminal" no longer launches a non-terminal app that is set as the terminal launcher (thanks to @kydorn).
|
||||||
|
|
||||||
## [1.18.0] - 2026-08-04
|
## [1.18.0] - 2026-08-04
|
||||||
|
|
||||||
- **Walkthrough:** a new guided walkthrough reorders a diff into a sequence of stops — the model groups related changes, explains what each one does, and orders them so each builds on the last. Start one from the Changes and pull-request views for uncommitted work, a branch against its base, or a pull request; nothing runs on its own. Walkthroughs are written in your interface language by default, and the panel can generate one in any other supported language.
|
- **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.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@heroui/theme": "^2.4.23",
|
"@heroui/theme": "^2.4.23",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "1.18.11",
|
"@opencode-ai/sdk": "1.18.12",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron": {
|
"packages/electron": {
|
||||||
"name": "@openchamber/electron",
|
"name": "@openchamber/electron",
|
||||||
"version": "1.17.2",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/web": "workspace:*",
|
"@openchamber/web": "workspace:*",
|
||||||
"electron-context-menu": "^4.1.2",
|
"electron-context-menu": "^4.1.2",
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.17.2",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "1.18.11",
|
"@opencode-ai/sdk": "1.18.12",
|
||||||
"@pierre/diffs": "1.3.0-beta.6",
|
"@pierre/diffs": "1.3.0-beta.6",
|
||||||
"@replit/codemirror-vim": "^6.3.0",
|
"@replit/codemirror-vim": "^6.3.0",
|
||||||
"@simplewebauthn/browser": "13.3.0",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
@@ -236,11 +236,11 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.17.2",
|
"version": "1.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "1.18.11",
|
"@opencode-ai/sdk": "1.18.12",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.6.0",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
@@ -259,16 +259,16 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.17.2",
|
"version": "1.18.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.1.0",
|
"@clack/prompts": "^1.1.0",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "1.18.11",
|
"@opencode-ai/sdk": "1.18.12",
|
||||||
"@simplewebauthn/server": "13.3.1",
|
"@simplewebauthn/server": "13.3.1",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.6.0",
|
||||||
"bun-pty": "^0.4.5",
|
"bun-pty": "^0.4.5",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"cron-parser": "^4.9.0",
|
"cron-parser": "^4.9.0",
|
||||||
@@ -997,7 +997,7 @@
|
|||||||
|
|
||||||
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
|
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
|
||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.11", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-yDImmNv4PhxdMgtiHVNWQWEVwQlAm7Dr0y4XU7CT4dOIbzgO+VP+9I02lAP7Zva1FhGeyI7oKMI2tzB9RUsWaQ=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.12", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Skjm0uRWqIiL9BQliZSrvnBflT99q1aGhT2pATNwli2WU8XSKm4lhZJFay7dIzjrA5C9SfgK+YSeoES2PbFugA=="],
|
||||||
|
|
||||||
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
|
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
|
||||||
|
|
||||||
@@ -1487,7 +1487,7 @@
|
|||||||
|
|
||||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||||
|
|
||||||
"adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="],
|
"adm-zip": ["adm-zip@0.6.0", "", {}, "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg=="],
|
||||||
|
|
||||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openchamber-monorepo",
|
"name": "openchamber-monorepo",
|
||||||
"version": "1.18.0",
|
"version": "1.18.1",
|
||||||
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
|
"description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
"@heroui/theme": "^2.4.23",
|
"@heroui/theme": "^2.4.23",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "1.18.11",
|
"@opencode-ai/sdk": "1.18.12",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ Es erklärt und ordnet. Es bewertet Ihren Code nicht und fällt kein Urteil —
|
|||||||
|
|
||||||
Öffnen Sie es über das **Walkthrough**-Symbol in der rechten Leiste oder über die Schaltfläche **AI walkthrough** in den Bereichen Changes und Pull Request. Beides öffnet nur das Panel; generiert wird erst, wenn Sie **Generate walkthrough** drücken.
|
Öffnen Sie es über das **Walkthrough**-Symbol in der rechten Leiste oder über die Schaltfläche **AI walkthrough** in den Bereichen Changes und Pull Request. Beides öffnet nur das Panel; generiert wird erst, wenn Sie **Generate walkthrough** drücken.
|
||||||
|
|
||||||
|
## Wie ein Stop markiert ist
|
||||||
|
|
||||||
|
Jeder Stop benennt sein Thema, erklärt es in ein bis zwei Sätzen und zeigt danach genau den Code, den er beschreibt. Manche Stops tragen eine kleine Markierung neben dem Titel:
|
||||||
|
|
||||||
|
| Markierung | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| **Kernänderung** | Dieser Stop trägt die eigentliche Änderung oder den größten Teil ihres Risikos. Lesen Sie ihn genau und zuerst. |
|
||||||
|
| **Kontext** | Eine unterstützende Änderung, damit der Rest verständlich bleibt. Kann überflogen werden. |
|
||||||
|
| *(ohne Markierung)* | Ein gewöhnlicher Schritt in der Lesereihenfolge. |
|
||||||
|
|
||||||
|
Die Markierung sagt, **wo Sie Ihre Aufmerksamkeit investieren sollten**, und nichts über die Qualität des Codes. Ein Stop wird nie markiert, weil darin etwas Falsches gefunden wurde — das Walkthrough meldet keine Funde, keine Schweregrade und keine Urteile. Wenn Code bewertet werden soll, ist das die Aktion **Review** in [Git & GitHub](/git/).
|
||||||
|
|
||||||
|
Die einzigen Markierungen, die tatsächlich auf ein Problem hinweisen, sind **Veraltet** und **Nicht abgedeckt** — und beide betreffen das Veralten des Walkthroughs selbst, nicht Ihren Code. Siehe unten.
|
||||||
|
|
||||||
## Was es prüfen kann
|
## Was es prüfen kann
|
||||||
|
|
||||||
| Bereich | Was enthalten ist |
|
| Bereich | Was enthalten ist |
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ A scheduled task runs a prompt for you on a schedule — for example, a daily "s
|
|||||||
- **daily** — at one or more times each day
|
- **daily** — at one or more times each day
|
||||||
- **weekly** — on chosen weekdays and times
|
- **weekly** — on chosen weekdays and times
|
||||||
- **once** — a single date and time
|
- **once** — a single date and time
|
||||||
|
- **cron** — an arbitrary cron expression
|
||||||
4. Set what it does: the prompt to send, and the provider, model, and agent to use. The prompt can be a slash command, like `/review`.
|
4. Set what it does: the prompt to send, and the provider, model, and agent to use. The prompt can be a slash command, like `/review`.
|
||||||
5. Save, and make sure the task is enabled.
|
5. Save, and make sure the task is enabled.
|
||||||
|
|
||||||
@@ -22,6 +23,48 @@ You can run any task immediately with **run now** to check it does what you expe
|
|||||||
|
|
||||||
Check **Run as goal** to make the run pursue its prompt to completion instead of stopping after one reply — see [Session Goals](/session-goals/).
|
Check **Run as goal** to make the run pursue its prompt to completion instead of stopping after one reply — see [Session Goals](/session-goals/).
|
||||||
|
|
||||||
|
## Loops: scheduled tasks as markdown files
|
||||||
|
|
||||||
|
A **loop** is a scheduled task defined as a portable markdown file you can commit to your repo. Drop a file into `.agents/loops/` and the task appears on the next sync — no dialog needed:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: daily-digest
|
||||||
|
schedule: "0 9 * * *"
|
||||||
|
enabled: true
|
||||||
|
model: anthropic/claude-sonnet-4-5
|
||||||
|
agent: plan
|
||||||
|
timezone: Europe/Kyiv
|
||||||
|
---
|
||||||
|
Summarize repository changes since yesterday and post the digest.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Where files live
|
||||||
|
|
||||||
|
- **Project scope** — `.agents/loops/*.md` in the project directory or any ancestor directory up to the git worktree root.
|
||||||
|
- **User scope** — `~/.agents/loops/*.md` applies to every project you open.
|
||||||
|
|
||||||
|
If a project loop and a user loop share a name, the project loop wins.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `name` | Task name (required, max 80 characters). |
|
||||||
|
| `schedule` | Cron expression (required) — loop files are cron-only. |
|
||||||
|
| `enabled` | Set `true` to run. Loops are **off by default**, so committing a file never starts running a task on its own. |
|
||||||
|
| `model` | `provider/model` (required), e.g. `anthropic/claude-sonnet-4-5`. |
|
||||||
|
| `agent` | Agent to use (optional). |
|
||||||
|
| `timezone` | IANA timezone (optional, defaults to the server zone). |
|
||||||
|
| body | The execution prompt (required). Can be a slash command, like `/review src/`. |
|
||||||
|
|
||||||
|
### How loops behave
|
||||||
|
|
||||||
|
- The **file is authoritative** while it exists: edits made in the UI are reverted on the next sync. The scheduled-tasks dialog marks loop tasks and disables their edit/enable/delete actions — **run now** still works. To stop a loop, delete the file (or set `enabled: false`).
|
||||||
|
- Runtime state (last run, next run, status) lives in the project config and is never written back into the markdown file.
|
||||||
|
- Renaming the `name` field renames the task in place. If a loop file temporarily fails to parse (mid-edit, merge conflict), its task is kept with the last good definition until the file is fixed.
|
||||||
|
- `daily`/`weekly`/`once` schedules and goal settings remain UI-only; loop files are always cron.
|
||||||
|
|
||||||
## What success looks like
|
## What success looks like
|
||||||
|
|
||||||
After a run, the task shows when it last ran, whether it succeeded, and a link to the session it created. If a run fails, the error is shown there too.
|
After a run, the task shows when it last ran, whether it succeeded, and a link to the session it created. If a run fails, the error is shown there too.
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ It explains and orders. It does not judge your code or hand out verdicts — tha
|
|||||||
|
|
||||||
Open it from the **Walkthrough** icon in the right rail, or from the **AI walkthrough** button in the Changes and Pull Request panels. Both just open the panel; nothing is generated until you press **Generate walkthrough**.
|
Open it from the **Walkthrough** icon in the right rail, or from the **AI walkthrough** button in the Changes and Pull Request panels. Both just open the panel; nothing is generated until you press **Generate walkthrough**.
|
||||||
|
|
||||||
|
## How a stop is marked
|
||||||
|
|
||||||
|
Each stop names what it is about, explains it in a sentence or two, and then shows exactly the code it describes. Some stops carry a small tag next to the title:
|
||||||
|
|
||||||
|
| Tag | What it means |
|
||||||
|
| --- | --- |
|
||||||
|
| **Key change** | This stop drives the rest of the change, or carries most of its risk. Read it closely and read it first. |
|
||||||
|
| **Context** | A supporting change, included so the rest makes sense. Safe to skim. |
|
||||||
|
| *(no tag)* | An ordinary step in the reading order. |
|
||||||
|
|
||||||
|
The tag is about **where to spend your attention**, not about the quality of the code. A stop is never marked because something was found wrong in it — the walkthrough reports no findings, no severities, and no verdicts. If you want code judged, that is the **Review** action in [Git & GitHub](/git/).
|
||||||
|
|
||||||
|
The only marks that do report a problem are **Outdated** and **Not covered**, and both are about the walkthrough itself going out of date rather than about your code — see below.
|
||||||
|
|
||||||
## What it can review
|
## What it can review
|
||||||
|
|
||||||
| Scope | What it covers |
|
| Scope | What it covers |
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
|
|||||||
- Floating Mini Chat windows.
|
- Floating Mini Chat windows.
|
||||||
- Multiple native windows.
|
- Multiple native windows.
|
||||||
- Native notifications.
|
- Native notifications.
|
||||||
|
- User-confirmed local folder selection. The shared UI supplies the requested directory as the picker `defaultPath`; confirmation is required before filesystem access is retried.
|
||||||
- One-click open/reveal/open-in-app actions.
|
- One-click open/reveal/open-in-app actions.
|
||||||
- Desktop host switcher and deep-link imports.
|
- Desktop host switcher and deep-link imports.
|
||||||
- Local and remote instance handling.
|
- Local and remote instance handling.
|
||||||
|
|||||||
@@ -2463,12 +2463,6 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
|||||||
browserWindow.on('move', () => {
|
browserWindow.on('move', () => {
|
||||||
debounceWindowStatePersist(browserWindow, false);
|
debounceWindowStatePersist(browserWindow, false);
|
||||||
});
|
});
|
||||||
browserWindow.on('minimize', (event) => {
|
|
||||||
if (!shouldHideMainWindowToTray(browserWindow)) return;
|
|
||||||
debounceWindowStatePersist(browserWindow, true);
|
|
||||||
event.preventDefault();
|
|
||||||
browserWindow.hide();
|
|
||||||
});
|
|
||||||
browserWindow.on('close', (event) => {
|
browserWindow.on('close', (event) => {
|
||||||
if (!state.quitRequested && shouldHideMainWindowToTray(browserWindow)) {
|
if (!state.quitRequested && shouldHideMainWindowToTray(browserWindow)) {
|
||||||
debounceWindowStatePersist(browserWindow, true);
|
debounceWindowStatePersist(browserWindow, true);
|
||||||
@@ -4448,7 +4442,12 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
|||||||
|
|
||||||
case 'desktop_minimize_current_window':
|
case 'desktop_minimize_current_window':
|
||||||
if (browserWindow && !browserWindow.isDestroyed()) {
|
if (browserWindow && !browserWindow.isDestroyed()) {
|
||||||
browserWindow.minimize();
|
if (shouldHideMainWindowToTray(browserWindow)) {
|
||||||
|
debounceWindowStatePersist(browserWindow, true);
|
||||||
|
browserWindow.hide();
|
||||||
|
} else {
|
||||||
|
browserWindow.minimize();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@openchamber/electron",
|
"name": "@openchamber/electron",
|
||||||
"version": "1.18.0",
|
"version": "1.18.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Electron desktop runtime for OpenChamber",
|
"description": "Electron desktop runtime for OpenChamber",
|
||||||
"author": "OpenChamber",
|
"author": "OpenChamber",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.18.0",
|
"version": "1.18.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/main.tsx",
|
"main": "src/main.tsx",
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "1.18.11",
|
"@opencode-ai/sdk": "1.18.12",
|
||||||
"@pierre/diffs": "1.3.0-beta.6",
|
"@pierre/diffs": "1.3.0-beta.6",
|
||||||
"@replit/codemirror-vim": "^6.3.0",
|
"@replit/codemirror-vim": "^6.3.0",
|
||||||
"@simplewebauthn/browser": "13.3.0",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
import { Icon } from '@/components/icon/Icon';
|
import { SessionActivityDuration } from '@/components/session/SessionActivityDuration';
|
||||||
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
|
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
|
||||||
import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
||||||
import { useTabletLayout } from '@/lib/device';
|
import { useTabletLayout } from '@/lib/device';
|
||||||
@@ -10,6 +10,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||||
|
import { useHasSessionActivityDuration } from '@/sync/session-activity-timing';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ const SwitcherRow: React.FC<{
|
|||||||
const statusType = status?.type ?? 'idle';
|
const statusType = status?.type ?? 'idle';
|
||||||
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||||||
const showUnreadDot = !isStreaming && unseenCount > 0 && !active;
|
const showUnreadDot = !isStreaming && unseenCount > 0 && !active;
|
||||||
|
const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming);
|
||||||
|
const showActivityDuration = (isStreaming || showUnreadDot) && hasActivityDuration;
|
||||||
const timeLabel = formatSessionCompactDateLabel(session.time?.updated ?? session.time?.created ?? 0);
|
const timeLabel = formatSessionCompactDateLabel(session.time?.updated ?? session.time?.created ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,12 +59,24 @@ const SwitcherRow: React.FC<{
|
|||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
{/* Activity sits on the right, before the time — no reserved left gutter. */}
|
{/* Activity sits on the right, before the time — no reserved left gutter. */}
|
||||||
{isStreaming ? (
|
{isStreaming || showUnreadDot ? (
|
||||||
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin text-primary" aria-hidden />
|
<span
|
||||||
) : showUnreadDot ? (
|
className={cn(
|
||||||
<span className="size-1.5 shrink-0 rounded-full bg-[var(--status-info)]" aria-hidden />
|
'size-1.5 shrink-0 rounded-full',
|
||||||
|
isStreaming ? 'bg-primary' : 'bg-[var(--status-info)]',
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{timeLabel ? (
|
{/* The elapsed turn takes the time slot while it matters, then hands it
|
||||||
|
back to the relative timestamp. */}
|
||||||
|
{showActivityDuration ? (
|
||||||
|
<SessionActivityDuration
|
||||||
|
sessionId={session.id}
|
||||||
|
running={isStreaming}
|
||||||
|
className="typography-micro"
|
||||||
|
/>
|
||||||
|
) : timeLabel ? (
|
||||||
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{timeLabel}</span>
|
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{timeLabel}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ import {
|
|||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useAllLiveSessions, useGlobalSessionStatus } from '@/sync/sync-context';
|
import { useAllLiveSessions, useGlobalSessionStatus } from '@/sync/sync-context';
|
||||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||||
|
import { useHasSessionActivityDuration } from '@/sync/session-activity-timing';
|
||||||
|
import { SessionActivityDuration } from '@/components/session/SessionActivityDuration';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
|
|
||||||
import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog';
|
import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog';
|
||||||
@@ -476,6 +478,8 @@ const SessionRow: React.FC<{
|
|||||||
const statusType = liveStatus?.type ?? 'idle';
|
const statusType = liveStatus?.type ?? 'idle';
|
||||||
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||||||
const showUnreadDot = !isStreaming && unseenCount > 0 && !active;
|
const showUnreadDot = !isStreaming && unseenCount > 0 && !active;
|
||||||
|
const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming);
|
||||||
|
const showActivityDuration = (isStreaming || showUnreadDot) && hasActivityDuration;
|
||||||
|
|
||||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
const startRef = React.useRef<{ x: number; y: number } | null>(null);
|
const startRef = React.useRef<{ x: number; y: number } | null>(null);
|
||||||
@@ -616,10 +620,14 @@ const SessionRow: React.FC<{
|
|||||||
onToggleChildren?.();
|
onToggleChildren?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isStreaming ? (
|
{isStreaming || showUnreadDot ? (
|
||||||
<Icon name="loader-4" className="size-3.5 animate-spin text-primary" />
|
<span
|
||||||
) : showUnreadDot ? (
|
className={cn(
|
||||||
<span className="size-1.5 rounded-full bg-[var(--status-info)]" aria-hidden />
|
'size-1.5 rounded-full',
|
||||||
|
isStreaming ? 'bg-primary' : 'bg-[var(--status-info)]',
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<RiArrowDownSLine className={cn('size-[18px] transition-transform duration-150', expanded ? 'rotate-0' : '-rotate-90')} />
|
<RiArrowDownSLine className={cn('size-[18px] transition-transform duration-150', expanded ? 'rotate-0' : '-rotate-90')} />
|
||||||
)}
|
)}
|
||||||
@@ -664,7 +672,15 @@ const SessionRow: React.FC<{
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{time ? (
|
{/* The elapsed turn takes the time slot while it matters, then
|
||||||
|
hands it back to the relative timestamp. */}
|
||||||
|
{showActivityDuration ? (
|
||||||
|
<SessionActivityDuration
|
||||||
|
sessionId={session.id}
|
||||||
|
running={isStreaming}
|
||||||
|
className="typography-micro"
|
||||||
|
/>
|
||||||
|
) : time ? (
|
||||||
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{time}</span>
|
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{time}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -83,6 +83,42 @@ describe('scanConnectionQr on Android', () => {
|
|||||||
expect(removeCalls).toBe(2);
|
expect(removeCalls).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('falls back to string parsing when the WebView URL parser rejects the link (old Android WebView)', async () => {
|
||||||
|
// Old Android WebViews resolve openchamber://connect?... with hostname "" and
|
||||||
|
// pathname "//connect", so the URL-based parse fails on an intact string. The test
|
||||||
|
// runtime's URL parser handles the canonical form fine, so simulate the rejection
|
||||||
|
// with a case variant the URL parser refuses while the string parser accepts.
|
||||||
|
const url = encodePairingConnectionPayload(buildPairingConnectionPayload({
|
||||||
|
pairingId: 'pair_abc',
|
||||||
|
secret: 'one-time',
|
||||||
|
candidates: [{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 }],
|
||||||
|
}));
|
||||||
|
const mixedCase = url.replace('openchamber://connect', 'OpenChamber://CONNECT');
|
||||||
|
const listeners = new Map<string, (event: { barcodes?: Array<{ rawValue?: string }> }) => void>();
|
||||||
|
const plugin = {
|
||||||
|
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||||
|
startScan: mock(async () => {
|
||||||
|
listeners.get('barcodesScanned')?.({ barcodes: [{ rawValue: mixedCase }] });
|
||||||
|
}),
|
||||||
|
stopScan: mock(async () => undefined),
|
||||||
|
addListener: mock((event: string, callback: (info: { barcodes?: Array<{ rawValue?: string }> }) => void) => {
|
||||||
|
listeners.set(event, callback);
|
||||||
|
return { remove: () => undefined };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanConnectionQr();
|
||||||
|
expect(result.status).toBe('pairing');
|
||||||
|
if (result.status === 'pairing') {
|
||||||
|
expect(result.pairing.pairingId).toBe('pair_abc');
|
||||||
|
expect(result.pairing.candidates).toEqual([{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 10 }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('stops scanning when the caller aborts', async () => {
|
test('stops scanning when the caller aborts', async () => {
|
||||||
let stopCalls = 0;
|
let stopCalls = 0;
|
||||||
const stopScan = async () => { stopCalls += 1; };
|
const stopScan = async () => { stopCalls += 1; };
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// scan() activity, this path bundles the barcode model in the app and does not need
|
// 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.
|
// Google Play Services. iOS keeps the native ready-made scanner.
|
||||||
|
|
||||||
import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload';
|
import { parsePairingConnectionPayload, parsePairingConnectionPayloadString, type PairingConnectionPayload } from '@/lib/connectionPayload';
|
||||||
|
|
||||||
export type MobileConnectionPayload = {
|
export type MobileConnectionPayload = {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -65,8 +65,15 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | M
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resultFromRawValue = (raw: string): QrScanResult => {
|
const resultFromRawValue = (raw: string, options?: { pairingStringFallback?: boolean }): QrScanResult => {
|
||||||
const payload = parseConnectionPayload(raw);
|
const payload = parseConnectionPayload(raw);
|
||||||
|
if (!payload && options?.pairingStringFallback) {
|
||||||
|
// Old Android WebViews resolve openchamber://… with hostname "" / pathname "//connect",
|
||||||
|
// so the URL-based parse above fails even though the scanned string is intact. Retry
|
||||||
|
// with the URL-API-free string parser before declaring the scan invalid.
|
||||||
|
const pairing = parsePairingConnectionPayloadString(raw);
|
||||||
|
if (pairing) return { status: 'pairing', pairing };
|
||||||
|
}
|
||||||
if (!payload) return { status: 'invalid' };
|
if (!payload) return { status: 'invalid' };
|
||||||
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
||||||
return { status: 'ok', ...payload };
|
return { status: 'ok', ...payload };
|
||||||
@@ -100,7 +107,7 @@ const scanWithBundledAndroidScanner = async (
|
|||||||
Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => {
|
Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => {
|
||||||
const barcode = barcodes?.[0];
|
const barcode = barcodes?.[0];
|
||||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||||
if (raw) finish(resultFromRawValue(raw));
|
if (raw) finish(resultFromRawValue(raw, { pairingStringFallback: true }));
|
||||||
})).then((handle) => { barcodeListener = handle; }),
|
})).then((handle) => { barcodeListener = handle; }),
|
||||||
Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' })))
|
Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' })))
|
||||||
.then((handle) => { errorListener = handle; }),
|
.then((handle) => { errorListener = handle; }),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
|||||||
import { resetStreamingState } from '@/sync/streaming';
|
import { resetStreamingState } from '@/sync/streaming';
|
||||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||||
import { resetSessionOrdering } from '@/sync/session-ordering';
|
import { resetSessionOrdering } from '@/sync/session-ordering';
|
||||||
|
import { resetSessionActivityTiming } from '@/sync/session-activity-timing';
|
||||||
import { syncDesktopSettings } from '@/lib/persistence';
|
import { syncDesktopSettings } from '@/lib/persistence';
|
||||||
|
|
||||||
// Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK
|
// Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK
|
||||||
@@ -56,6 +57,9 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
|||||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
|
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
|
||||||
useGlobalSessionStatusStore.setState({ statusById: new Map() });
|
useGlobalSessionStatusStore.setState({ statusById: new Map() });
|
||||||
resetSessionOrdering();
|
resetSessionOrdering();
|
||||||
|
// Turn timings belong to the previous instance's sessions, and the reset also
|
||||||
|
// restarts the resume window so the switch is treated as a fresh load.
|
||||||
|
resetSessionActivityTiming();
|
||||||
usePermissionStore.getState().reset();
|
usePermissionStore.getState().reset();
|
||||||
useFileSearchStore.getState().resetForRuntimeSwitch();
|
useFileSearchStore.getState().resetForRuntimeSwitch();
|
||||||
useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
} from '@/lib/chatDraftPersistence';
|
} from '@/lib/chatDraftPersistence';
|
||||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||||
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||||
import ToolOutputDialog from './message/ToolOutputDialog';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
import type { ToolPopupContent } from './message/types';
|
import type { ToolPopupContent } from './message/types';
|
||||||
import { QueuedMessageChips } from './QueuedMessageChips';
|
import { QueuedMessageChips } from './QueuedMessageChips';
|
||||||
import { AutoReviewBanner } from './AutoReviewBanner';
|
import { AutoReviewBanner } from './AutoReviewBanner';
|
||||||
@@ -142,6 +142,10 @@ import { RevertedMessageDock } from './composer/ui/RevertedMessageDock';
|
|||||||
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
|
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
|
||||||
import { SessionGoalRow } from '@/components/chat/SessionGoalRow';
|
import { SessionGoalRow } from '@/components/chat/SessionGoalRow';
|
||||||
|
|
||||||
|
// Lazy like in ChatMessage: a static import would pull the @pierre/diffs and
|
||||||
|
// Shiki stacks into the eager startup graph for a dialog opened on demand.
|
||||||
|
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
|
||||||
|
|
||||||
const MAX_VISIBLE_COMPOSER_LINES = 8;
|
const MAX_VISIBLE_COMPOSER_LINES = 8;
|
||||||
/**
|
/**
|
||||||
* Mobile grows the composer with content instead of offering a fullscreen
|
* Mobile grows the composer with content instead of offering a fullscreen
|
||||||
@@ -383,6 +387,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
title: '',
|
title: '',
|
||||||
content: '',
|
content: '',
|
||||||
});
|
});
|
||||||
|
// Mount the lazy preview dialog only after its first open; rendering it
|
||||||
|
// closed would fetch the ToolOutputDialog chunk (with the @pierre/diffs
|
||||||
|
// stack) on the draft screen before any preview is requested.
|
||||||
|
const [attachmentPreviewMounted, setAttachmentPreviewMounted] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (attachmentPreview.open) {
|
||||||
|
setAttachmentPreviewMounted(true);
|
||||||
|
}
|
||||||
|
}, [attachmentPreview.open]);
|
||||||
const attachmentCompatibilityRef = React.useRef({
|
const attachmentCompatibilityRef = React.useRef({
|
||||||
modelKey: `${currentProviderId ?? ''}/${currentModelId ?? ''}`,
|
modelKey: `${currentProviderId ?? ''}/${currentModelId ?? ''}`,
|
||||||
modalitySignature: currentModelMetadata?.modalities?.input?.slice().sort().join(',') ?? null,
|
modalitySignature: currentModelMetadata?.modalities?.input?.slice().sort().join(',') ?? null,
|
||||||
@@ -936,10 +949,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
setPrPickerOpen(true);
|
setPrPickerOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const getSubmitErrorMessage = (error: unknown, fallback: string) => {
|
||||||
|
const message = error instanceof Error ? error.message : '';
|
||||||
|
return message.toLowerCase().includes('runtime changed')
|
||||||
|
? t('chat.chatInput.toast.messageSendFailed')
|
||||||
|
: message || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (options?: SubmitOptions) => {
|
const handleSubmit = async (options?: SubmitOptions) => {
|
||||||
const queuedOnly = options?.queuedOnly ?? false;
|
const queuedOnly = options?.queuedOnly ?? false;
|
||||||
const queuedMessageId = options?.queuedMessageId;
|
const queuedMessageId = options?.queuedMessageId;
|
||||||
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
||||||
|
const capturedTarget = messageQueueTarget;
|
||||||
const inputSnapshot = options?.presetText != null
|
const inputSnapshot = options?.presetText != null
|
||||||
? {
|
? {
|
||||||
message: options.presetText,
|
message: options.presetText,
|
||||||
@@ -1012,7 +1033,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendMessageOptions = delivery ? { delivery } : undefined;
|
const sendMessageOptions = capturedTarget
|
||||||
|
? { target: capturedTarget, ...(delivery ? { delivery } : {}) }
|
||||||
|
: delivery ? { delivery } : undefined;
|
||||||
|
|
||||||
// Inline review comments and synthetic context are consumed before
|
// Inline review comments and synthetic context are consumed before
|
||||||
// assembly so a failed send can restore exactly what it took.
|
// assembly so a failed send can restore exactly what it took.
|
||||||
@@ -1058,10 +1081,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
if (outgoing.isEmpty) return;
|
if (outgoing.isEmpty) return;
|
||||||
|
|
||||||
// Clear queue and input
|
// Clear queue and input
|
||||||
if (messageQueueTarget && queuedMessageId) {
|
if (capturedTarget && queuedMessageId) {
|
||||||
removeFromQueue(messageQueueTarget, queuedMessageId);
|
removeFromQueue(capturedTarget, queuedMessageId);
|
||||||
} else if (messageQueueTarget && hasQueuedMessages) {
|
} else if (capturedTarget && hasQueuedMessages) {
|
||||||
clearQueue(messageQueueTarget);
|
clearQueue(capturedTarget);
|
||||||
}
|
}
|
||||||
if (!queuedOnly) {
|
if (!queuedOnly) {
|
||||||
setMessage('');
|
setMessage('');
|
||||||
@@ -1111,7 +1134,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined;
|
const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined;
|
||||||
await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory);
|
await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed'));
|
toast.error(getSubmitErrorMessage(error, t('chat.chatInput.toast.compactFailed')));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1143,15 +1166,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
);
|
);
|
||||||
scrollToBottom?.();
|
scrollToBottom?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : t(command.errorToastKey));
|
toast.error(getSubmitErrorMessage(error, t(command.errorToastKey)));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSessionDirectory = currentSessionId
|
const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory;
|
||||||
? useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory
|
|
||||||
: currentDirectory;
|
|
||||||
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
|
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
|
||||||
if (shouldAddResponseStyle) {
|
if (shouldAddResponseStyle) {
|
||||||
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
|
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
|
||||||
@@ -1258,6 +1279,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('runtime changed')) {
|
||||||
|
if (allAttachments.length > 0) {
|
||||||
|
useInputStore.getState().setAttachedFiles(allAttachments);
|
||||||
|
}
|
||||||
|
toast.error(t('chat.chatInput.toast.messageSendFailed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (allAttachments.length > 0) {
|
if (allAttachments.length > 0) {
|
||||||
useInputStore.getState().setAttachedFiles(allAttachments);
|
useInputStore.getState().setAttachedFiles(allAttachments);
|
||||||
}
|
}
|
||||||
@@ -2770,11 +2799,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
submitting={reviewFlowSubmitting}
|
submitting={reviewFlowSubmitting}
|
||||||
onConfirm={handleStartReviewFlow}
|
onConfirm={handleStartReviewFlow}
|
||||||
/>
|
/>
|
||||||
<ToolOutputDialog
|
{attachmentPreviewMounted ? (
|
||||||
popup={attachmentPreview}
|
<React.Suspense fallback={null}>
|
||||||
onOpenChange={handleAttachmentPreviewOpenChange}
|
<ToolOutputDialog
|
||||||
isMobile={isMobile}
|
popup={attachmentPreview}
|
||||||
/>
|
onOpenChange={handleAttachmentPreviewOpenChange}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Single always-mounted picker input. It must NOT live inside
|
{/* Single always-mounted picker input. It must NOT live inside
|
||||||
ComposerAttachmentControls: that component mounts once per composer
|
ComposerAttachmentControls: that component mounts once per composer
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
|||||||
import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages';
|
import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages';
|
||||||
import { setContextObligatoryMessage } from '@/sync/session-actions';
|
import { setContextObligatoryMessage } from '@/sync/session-actions';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import { focusChatInput } from './composer/editor/dom';
|
||||||
|
|
||||||
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
|
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
|
||||||
|
|
||||||
@@ -416,6 +417,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
createdAt: messageCreatedAt,
|
createdAt: messageCreatedAt,
|
||||||
role: isUser ? 'user' : 'assistant',
|
role: isUser ? 'user' : 'assistant',
|
||||||
}, !isPinnedIntoContext);
|
}, !isPinnedIntoContext);
|
||||||
|
// Return focus to the composer so the user can keep typing right
|
||||||
|
// after adding the message to context (matches the refocus pattern
|
||||||
|
// used by the model/agent selectors).
|
||||||
|
requestAnimationFrame(focusChatInput);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[chat-message] failed to update context pin', error);
|
console.error('[chat-message] failed to update context pin', error);
|
||||||
toast.error(t('chat.messageBody.actions.contextPinFailed'));
|
toast.error(t('chat.messageBody.actions.contextPinFailed'));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
|
||||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||||
import { parseDiffToUnified } from './message/toolRenderers';
|
import { parseDiffToUnified } from './message/toolRenderers';
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
|||||||
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
import { ensureOutsideFileGrantForDesktop } from '@/lib/outsideFileGrants';
|
||||||
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
import { getDirectoryForFilePath, isFilePathWithinDirectory, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||||
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
import { renderMarkdownBlocks, renderMarkdownSync } from './markdown/markdownCore';
|
||||||
import { ensureMarkdownShikiTheme, getMarkdownSyntaxVars } from './markdown/markdownTheme';
|
import { ensureMarkdownShikiTheme } from './markdown/markdownTheme';
|
||||||
|
import { getMarkdownSyntaxVars } from './markdown/markdownSyntaxVars';
|
||||||
import {
|
import {
|
||||||
attachMarkdownInteractions,
|
attachMarkdownInteractions,
|
||||||
applyMarkdownCodeBlockWrapState,
|
applyMarkdownCodeBlockWrapState,
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
|||||||
import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
|
import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness';
|
||||||
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
||||||
import { markStartupTrace } from '@/lib/startupTrace';
|
import { markStartupTrace } from '@/lib/startupTrace';
|
||||||
|
import {
|
||||||
|
findLatestUserModelChoice,
|
||||||
|
shouldPreserveManualModelOverride,
|
||||||
|
} from '@/lib/messages/userModelChoice';
|
||||||
|
import { getSyncParts } from '@/sync/sync-refs';
|
||||||
|
|
||||||
type IconComponent = IconName;
|
type IconComponent = IconName;
|
||||||
|
|
||||||
@@ -645,37 +650,14 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
currentSessionDirectory ?? undefined,
|
currentSessionDirectory ?? undefined,
|
||||||
);
|
);
|
||||||
const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined);
|
const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined);
|
||||||
|
// Skip synthetic subagent-completion nudges — restoring from them resets a
|
||||||
|
// manual model override back to the agent default (issue #2404).
|
||||||
const latestLoadedUserChoice = React.useMemo(() => {
|
const latestLoadedUserChoice = React.useMemo(() => {
|
||||||
for (let i = currentSessionMessagesFromSync.length - 1; i >= 0; i -= 1) {
|
return findLatestUserModelChoice(
|
||||||
const message = currentSessionMessagesFromSync[i] as typeof currentSessionMessagesFromSync[number] & {
|
currentSessionMessagesFromSync,
|
||||||
model?: { providerID?: string; modelID?: string; variant?: string };
|
(messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined),
|
||||||
variant?: string;
|
);
|
||||||
mode?: string;
|
}, [currentSessionDirectory, currentSessionMessagesFromSync]);
|
||||||
};
|
|
||||||
if (message.role !== 'user') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0
|
|
||||||
? message.model.providerID
|
|
||||||
: undefined;
|
|
||||||
const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0
|
|
||||||
? message.model.modelID
|
|
||||||
: undefined;
|
|
||||||
const agent = typeof message.agent === 'string' && message.agent.trim().length > 0
|
|
||||||
? message.agent
|
|
||||||
: (typeof message.mode === 'string' && message.mode.trim().length > 0 ? message.mode : undefined);
|
|
||||||
// OpenCode 1.4.0 moved variant from top-level to model.variant.
|
|
||||||
// Prefer the new location, fall back to the legacy one for older servers.
|
|
||||||
const variantCandidate = message.model?.variant ?? message.variant;
|
|
||||||
const variant = typeof variantCandidate === 'string' && variantCandidate.trim().length > 0
|
|
||||||
? variantCandidate
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return { id: message.id, agent, providerID, modelID, variant };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [currentSessionMessagesFromSync]);
|
|
||||||
|
|
||||||
const tryApplyModelSelection = React.useCallback(
|
const tryApplyModelSelection = React.useCallback(
|
||||||
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
|
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
|
||||||
@@ -828,6 +810,25 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manual session override wins over historical / synthetic message metadata.
|
||||||
|
const savedSessionModel = getSessionModelSelection(currentSessionId);
|
||||||
|
if (shouldPreserveManualModelOverride({
|
||||||
|
selectionSource: useConfigStore.getState().selectionSource,
|
||||||
|
savedSessionModel,
|
||||||
|
candidate: latestLoadedUserChoice,
|
||||||
|
})) {
|
||||||
|
if (savedSessionModel) {
|
||||||
|
applyModelSelectionWithVariant(
|
||||||
|
savedSessionModel.providerId,
|
||||||
|
savedSessionModel.modelId,
|
||||||
|
resolveModelVariantSelection(savedSessionModel.providerId, savedSessionModel.modelId),
|
||||||
|
currentAgentName || undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
latestLoadedUserChoiceRestoreRef.current = restoreKey;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) {
|
if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) {
|
||||||
setAgent(latestLoadedUserChoice.agent);
|
setAgent(latestLoadedUserChoice.agent);
|
||||||
}
|
}
|
||||||
@@ -869,6 +870,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
setAgent,
|
setAgent,
|
||||||
applyModelSelectionWithVariant,
|
applyModelSelectionWithVariant,
|
||||||
getModelVariantOptions,
|
getModelVariantOptions,
|
||||||
|
getSessionModelSelection,
|
||||||
|
resolveModelVariantSelection,
|
||||||
saveSessionAgentSelection,
|
saveSessionAgentSelection,
|
||||||
saveAgentModelVariantForSession,
|
saveAgentModelVariantForSession,
|
||||||
saveSessionModelSelection,
|
saveSessionModelSelection,
|
||||||
|
|||||||
@@ -130,6 +130,16 @@ function insertedTextOf(transaction: { changes: { iterChanges: (fn: (fromA: numb
|
|||||||
return inserted;
|
return inserted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for keydown events CodeMirror re-dispatches after deferring the real
|
||||||
|
* one (iOS Enter/Backspace/Delete, Chrome Android Enter): `dispatchKey`
|
||||||
|
* stamps the replacement event with a `synthetic` expando. These events are
|
||||||
|
* built from the key name alone, so they carry no modifier keys.
|
||||||
|
*/
|
||||||
|
function isDeferredSyntheticEvent(event: KeyboardEvent): boolean {
|
||||||
|
return Boolean((event as unknown as { synthetic?: boolean }).synthetic);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compartments are configuration keys, not per-view state, so one set can serve
|
* Compartments are configuration keys, not per-view state, so one set can serve
|
||||||
* every editor. They live at module scope because a kept-alive view outlives
|
* every editor. They live at module scope because a kept-alive view outlives
|
||||||
@@ -160,6 +170,14 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
|||||||
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const viewRef = React.useRef<EditorView | null>(null);
|
const viewRef = React.useRef<EditorView | null>(null);
|
||||||
|
|
||||||
|
// The real keydown's shift state for the LAST Enter that reached the
|
||||||
|
// editor. CodeMirror defers Enter on iOS (and Chrome Android) and
|
||||||
|
// re-dispatches it as a synthetic keydown built from the key name
|
||||||
|
// alone, dropping every modifier (see `trackRealEnterShift` and the
|
||||||
|
// `interceptKeys` handler below); this ref is what lets the deferred
|
||||||
|
// event still tell Shift+Enter from Enter.
|
||||||
|
const lastRealEnterShiftRef = React.useRef(false);
|
||||||
|
|
||||||
// Callbacks reach the CodeMirror extensions through a ref: the view is
|
// Callbacks reach the CodeMirror extensions through a ref: the view is
|
||||||
// built once and must not be torn down when a handler identity changes,
|
// built once and must not be torn down when a handler identity changes,
|
||||||
// which would drop focus mid-typing. When a view store is supplied the
|
// which would drop focus mid-typing. When a view store is supplied the
|
||||||
@@ -200,7 +218,15 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const interceptKeys: KeyBinding[] = [{
|
const interceptKeys: KeyBinding[] = [{
|
||||||
any: (_view, event) => handlersRef.current.onKeyDown?.(event) ?? false,
|
any: (_view, event) => {
|
||||||
|
// A deferred Enter lost its modifiers in the re-dispatch;
|
||||||
|
// give the caller's policy (Enter vs Shift+Enter) back the
|
||||||
|
// shift state it saw on the real keydown.
|
||||||
|
if (event.key === 'Enter' && isDeferredSyntheticEvent(event) && lastRealEnterShiftRef.current) {
|
||||||
|
Object.defineProperty(event, 'shiftKey', { value: true });
|
||||||
|
}
|
||||||
|
return handlersRef.current.onKeyDown?.(event) ?? false;
|
||||||
|
},
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const view = new EditorView({
|
const view = new EditorView({
|
||||||
@@ -276,6 +302,25 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
|||||||
viewRef.current = view;
|
viewRef.current = view;
|
||||||
if (store) store.view = view;
|
if (store) store.view = view;
|
||||||
|
|
||||||
|
// CodeMirror defers Enter on iOS (and Chrome Android): the real
|
||||||
|
// keydown is captured without running the keymaps, the browser's
|
||||||
|
// native newline goes through, and the keymaps then run against a
|
||||||
|
// synthetic keydown `dispatchKey` builds from the key name alone —
|
||||||
|
// which has NO modifiers. Recording the real shift state here (a
|
||||||
|
// plain listener, registered after CodeMirror's own, so it runs
|
||||||
|
// after the deferral decision but before the deferred dispatch)
|
||||||
|
// lets the deferred Enter be re-presented with Shift+Enter intact
|
||||||
|
// instead of arriving as a plain Enter that "sends" where Enter
|
||||||
|
// sends. Without it, Shift+Enter on iOS/Android submits the
|
||||||
|
// message instead of inserting a newline. The listener lives on
|
||||||
|
// the kept-alive view's contentDOM, so it stays across mounts and
|
||||||
|
// keeps feeding the same ref the `interceptKeys` closure reads.
|
||||||
|
const trackRealEnterShift = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Enter' || isDeferredSyntheticEvent(event)) return;
|
||||||
|
lastRealEnterShiftRef.current = event.shiftKey;
|
||||||
|
};
|
||||||
|
view.contentDOM.addEventListener('keydown', trackRealEnterShift);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
viewRef.current = null;
|
viewRef.current = null;
|
||||||
// A stored view is detached, not destroyed: the store owns its
|
// A stored view is detached, not destroyed: the store owns its
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Theme } from '@/types/theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `--md-syntax-*` CSS custom properties for the given app theme.
|
||||||
|
* Apply the result as inline styles on the markdown container so the static
|
||||||
|
* Shiki theme resolves to the active palette.
|
||||||
|
*
|
||||||
|
* Lives apart from `markdownTheme.ts` because that module imports
|
||||||
|
* `@pierre/diffs` for theme registration; eager consumers of these CSS vars
|
||||||
|
* (tool output, code blocks) must not pull that stack into the startup graph.
|
||||||
|
*/
|
||||||
|
export const getMarkdownSyntaxVars = (theme: Theme): Record<string, string> => {
|
||||||
|
const base = theme.colors.syntax.base;
|
||||||
|
const tokens = theme.colors.syntax.tokens ?? {};
|
||||||
|
const status = theme.colors.status;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'--md-syntax-foreground': base.foreground,
|
||||||
|
'--md-syntax-comment': base.comment,
|
||||||
|
'--md-syntax-string': base.string,
|
||||||
|
'--md-syntax-number': base.number,
|
||||||
|
'--md-syntax-keyword': base.keyword,
|
||||||
|
'--md-syntax-operator': base.operator,
|
||||||
|
'--md-syntax-function': base.function,
|
||||||
|
'--md-syntax-type': base.type,
|
||||||
|
'--md-syntax-variable': base.variable,
|
||||||
|
'--md-syntax-property': tokens.variableProperty ?? base.variable,
|
||||||
|
'--md-syntax-inserted': status.success,
|
||||||
|
'--md-syntax-deleted': status.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { registerCustomTheme, type ThemeRegistrationResolved } from '@pierre/diffs';
|
import { registerCustomTheme, type ThemeRegistrationResolved } from '@pierre/diffs';
|
||||||
import type { Theme } from '@/types/theme';
|
|
||||||
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
|
import { MARKDOWN_SHIKI_THEME, MARKDOWN_SHIKI_THEME_DEFINITION } from './markdownShikiThemeDefinition';
|
||||||
|
|
||||||
// The static Shiki theme name. Its definition (token colors referencing
|
// The static Shiki theme name. Its definition (token colors referencing
|
||||||
@@ -27,29 +26,3 @@ export const ensureMarkdownShikiTheme = (): void => {
|
|||||||
Promise.resolve(MARKDOWN_SHIKI_THEME_DEFINITION as unknown as ThemeRegistrationResolved),
|
Promise.resolve(MARKDOWN_SHIKI_THEME_DEFINITION as unknown as ThemeRegistrationResolved),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the `--md-syntax-*` CSS custom properties for the given app theme.
|
|
||||||
* Apply the result as inline styles on the markdown container so the static
|
|
||||||
* Shiki theme resolves to the active palette.
|
|
||||||
*/
|
|
||||||
export const getMarkdownSyntaxVars = (theme: Theme): Record<string, string> => {
|
|
||||||
const base = theme.colors.syntax.base;
|
|
||||||
const tokens = theme.colors.syntax.tokens ?? {};
|
|
||||||
const status = theme.colors.status;
|
|
||||||
|
|
||||||
return {
|
|
||||||
'--md-syntax-foreground': base.foreground,
|
|
||||||
'--md-syntax-comment': base.comment,
|
|
||||||
'--md-syntax-string': base.string,
|
|
||||||
'--md-syntax-number': base.number,
|
|
||||||
'--md-syntax-keyword': base.keyword,
|
|
||||||
'--md-syntax-operator': base.operator,
|
|
||||||
'--md-syntax-function': base.function,
|
|
||||||
'--md-syntax-type': base.type,
|
|
||||||
'--md-syntax-variable': base.variable,
|
|
||||||
'--md-syntax-property': tokens.variableProperty ?? base.variable,
|
|
||||||
'--md-syntax-inserted': status.success,
|
|
||||||
'--md-syntax-deleted': status.error,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -284,16 +284,15 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
|
|||||||
const SHELL_CODE_TAG_STYLE: React.CSSProperties = { background: 'transparent', backgroundColor: 'transparent' };
|
const SHELL_CODE_TAG_STYLE: React.CSSProperties = { background: 'transparent', backgroundColor: 'transparent' };
|
||||||
|
|
||||||
const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => {
|
const UserShellActionPart: React.FC<{ part: ShellActionPartLike }> = ({ part }) => {
|
||||||
const [expanded, setExpanded] = React.useState(false);
|
const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : '';
|
||||||
|
const [expanded, setExpanded] = React.useState(true);
|
||||||
const [copiedOutput, setCopiedOutput] = React.useState(false);
|
const [copiedOutput, setCopiedOutput] = React.useState(false);
|
||||||
const copiedResetTimeoutRef = React.useRef<number | null>(null);
|
const copiedResetTimeoutRef = React.useRef<number | null>(null);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : '';
|
const command = typeof part.shellAction?.command === 'string' ? part.shellAction.command.trim() : '';
|
||||||
const output = typeof part.shellAction?.output === 'string' ? part.shellAction.output : '';
|
|
||||||
const status = typeof part.shellAction?.status === 'string' ? part.shellAction.status.trim().toLowerCase() : '';
|
const status = typeof part.shellAction?.status === 'string' ? part.shellAction.status.trim().toLowerCase() : '';
|
||||||
const hasOutput = output.trim().length > 0;
|
const hasOutput = output.trim().length > 0;
|
||||||
|
|
||||||
const clearCopiedResetTimeout = React.useCallback(() => {
|
const clearCopiedResetTimeout = React.useCallback(() => {
|
||||||
if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') {
|
if (copiedResetTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||||
window.clearTimeout(copiedResetTimeoutRef.current);
|
window.clearTimeout(copiedResetTimeoutRef.current);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
|
|||||||
- Every other tool, including search/fetch, OpenCode built-ins, custom tools, plugins, and MCP tools, is **expandable** and renders through `ToolPart`.
|
- 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.
|
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
|
||||||
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
|
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
|
||||||
|
- The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`.
|
||||||
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
|
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
|
||||||
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
|
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ Why: only navigation tools use the compact static path; all other tools need obs
|
|||||||
## Quick map of files in this folder
|
## Quick map of files in this folder
|
||||||
|
|
||||||
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
|
- Text: `AssistantTextPart.tsx`, `UserTextPart.tsx`
|
||||||
- Tools: `ToolPart.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
- Tools: `ToolPart.tsx`, `ToolPartDiffPreview.tsx`, `PlainDiffFallback.tsx`, `ProgressiveGroup.tsx`, `toolPresentation.tsx`, `toolRenderUtils.ts`, `ToolRevealOnMount.tsx`
|
||||||
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
- Reasoning/justification: `ReasoningPart.tsx`, `JustificationBlock.tsx`
|
||||||
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
- Status/placeholders: `WorkingPlaceholder.tsx`, `SessionActiveSpinner.tsx`, `MigratingPart.tsx`, `BusyDots.tsx`
|
||||||
- Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx`
|
- Utility renderers: `VirtualizedCodeBlock.tsx`, `MinDurationShineText.tsx`
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-text patch rendering used when the rich `@pierre/diffs` preview is
|
||||||
|
* unavailable: non-diff render modes, preview errors, and while the lazily
|
||||||
|
* loaded diff preview chunk is still downloading. Lives in its own module so
|
||||||
|
* `ToolPart` can render it without importing the @pierre/diffs stack.
|
||||||
|
*/
|
||||||
|
export const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => (
|
||||||
|
<pre
|
||||||
|
className="m-0 overflow-auto whitespace-pre-wrap break-words rounded-lg p-2 typography-code"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--syntax-base-background)',
|
||||||
|
color: 'var(--syntax-base-foreground)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{diff}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useMobileAppActions } from '@/apps/mobileAppContext';
|
import { useMobileAppActions } from '@/apps/mobileAppContext';
|
||||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||||
import { PatchDiff } from '@pierre/diffs/react';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import { MessageFilesDisplay } from '../../FileAttachment';
|
import { MessageFilesDisplay } from '../../FileAttachment';
|
||||||
@@ -10,7 +9,6 @@ import { getToolMetadata } from '@/lib/toolHelpers';
|
|||||||
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2';
|
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion, FilePart } from '@opencode-ai/sdk/v2';
|
||||||
import { toolDisplayStyles } from '@/lib/typography';
|
import { toolDisplayStyles } from '@/lib/typography';
|
||||||
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
import { WorkerHighlightedCode } from '@/components/code/WorkerHighlightedCode';
|
||||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
|
||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
|
import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context';
|
||||||
@@ -24,8 +22,8 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||||
import type { ToolPopupContent } from '../types';
|
import type { ToolPopupContent } from '../types';
|
||||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
import { PlainDiffFallback } from './PlainDiffFallback';
|
||||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatEditOutput,
|
formatEditOutput,
|
||||||
@@ -42,7 +40,7 @@ import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
|
|||||||
import { MinDurationShineText } from './MinDurationShineText';
|
import { MinDurationShineText } from './MinDurationShineText';
|
||||||
import { ToolRevealOnMount } from './ToolRevealOnMount';
|
import { ToolRevealOnMount } from './ToolRevealOnMount';
|
||||||
import { getToolIcon } from './toolPresentation';
|
import { getToolIcon } from './toolPresentation';
|
||||||
import { useDurationTickerNow } from './useDurationTicker';
|
import { useDurationTickerNow } from '@/hooks/useDurationTicker';
|
||||||
import {
|
import {
|
||||||
buildTaskSummaryEntriesFromSession,
|
buildTaskSummaryEntriesFromSession,
|
||||||
normalizeTaskSummaryEntries,
|
normalizeTaskSummaryEntries,
|
||||||
@@ -385,40 +383,6 @@ const getToolDiagnosticSection = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const usePierreThemeConfig = () => {
|
|
||||||
const themeSystem = useOptionalThemeSystem();
|
|
||||||
const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []);
|
|
||||||
const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []);
|
|
||||||
|
|
||||||
const availableThemes = React.useMemo(
|
|
||||||
() => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme],
|
|
||||||
[fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes],
|
|
||||||
);
|
|
||||||
const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id;
|
|
||||||
const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id;
|
|
||||||
|
|
||||||
const lightTheme = React.useMemo(
|
|
||||||
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme,
|
|
||||||
[availableThemes, fallbackLightTheme, lightThemeId],
|
|
||||||
);
|
|
||||||
const darkTheme = React.useMemo(
|
|
||||||
() => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme,
|
|
||||||
[availableThemes, darkThemeId, fallbackDarkTheme],
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
ensurePierreThemeRegistered(lightTheme);
|
|
||||||
ensurePierreThemeRegistered(darkTheme);
|
|
||||||
}, [darkTheme, lightTheme]);
|
|
||||||
|
|
||||||
const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light';
|
|
||||||
|
|
||||||
return {
|
|
||||||
pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id },
|
|
||||||
pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse question tool output: "User has answered your questions: "Q1"="A1", "Q2"="A2". You can now..."
|
// Parse question tool output: "User has answered your questions: "Q1"="A1", "Q2"="A2". You can now..."
|
||||||
const parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => {
|
const parseQuestionOutput = (output: string): Array<{ question: string; answer: string }> | null => {
|
||||||
const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s);
|
const match = output.match(/^User has answered your questions:\s*(.+?)\.\s*You can now/s);
|
||||||
@@ -1138,30 +1102,6 @@ const TaskToolSummary: React.FC<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DiffPreviewProps {
|
|
||||||
diff: string;
|
|
||||||
pierreTheme: { light: string; dark: string };
|
|
||||||
pierreThemeType: 'light' | 'dark';
|
|
||||||
diffViewMode: DiffViewMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOOL_DIFF_UNSAFE_CSS = `
|
|
||||||
[data-diff-header],
|
|
||||||
[data-diff] {
|
|
||||||
[data-separator] {
|
|
||||||
height: 24px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const TOOL_DIFF_METRICS = {
|
|
||||||
hunkLineCount: 50,
|
|
||||||
lineHeight: 24,
|
|
||||||
diffHeaderHeight: 44,
|
|
||||||
hunkSeparatorHeight: 24,
|
|
||||||
spacing: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const TOOL_COLLAPSED_CUSTOM_STYLE: React.CSSProperties = {
|
const TOOL_COLLAPSED_CUSTOM_STYLE: React.CSSProperties = {
|
||||||
...toolDisplayStyles.getCollapsedStyles(),
|
...toolDisplayStyles.getCollapsedStyles(),
|
||||||
padding: 0,
|
padding: 0,
|
||||||
@@ -1260,85 +1200,18 @@ const renderAnimatedPathWithIcon = (path: string, animate = true, grow = true, s
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PlainDiffFallback: React.FC<{ diff: string }> = ({ diff }) => (
|
// The rich diff preview is the only tool-card piece that needs the
|
||||||
<pre
|
// @pierre/diffs + Shiki stack; lazy-loading it keeps that stack out of the
|
||||||
className="m-0 overflow-auto whitespace-pre-wrap break-words rounded-lg p-2 typography-code"
|
// eager chat graph. While the chunk loads, the plain-text patch renders as the
|
||||||
style={{
|
// Suspense fallback, mirroring the preview's own error fallback.
|
||||||
backgroundColor: 'var(--syntax-base-background)',
|
const LazyToolPartDiffPreview = lazyWithChunkRecovery(() => import('./ToolPartDiffPreview'));
|
||||||
color: 'var(--syntax-base-foreground)',
|
|
||||||
}}
|
const DiffPreview: React.FC<{ diff: string; diffViewMode: DiffViewMode }> = ({ diff, diffViewMode }) => (
|
||||||
>
|
<React.Suspense fallback={<PlainDiffFallback diff={diff} />}>
|
||||||
{diff}
|
<LazyToolPartDiffPreview diff={diff} diffViewMode={diffViewMode} />
|
||||||
</pre>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
|
|
||||||
class DiffPreviewErrorBoundary extends React.Component<{
|
|
||||||
resetKey: string;
|
|
||||||
fallback: React.ReactNode;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}, { hasError: boolean }> {
|
|
||||||
state = { hasError: false };
|
|
||||||
|
|
||||||
static getDerivedStateFromError(): { hasError: boolean } {
|
|
||||||
return { hasError: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps: { resetKey: string }) {
|
|
||||||
if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
|
|
||||||
this.setState({ hasError: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Tool diff preview failed; rendering raw patch instead.', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return this.props.fallback;
|
|
||||||
}
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const DiffPreview: React.FC<DiffPreviewProps> = React.memo(({ diff, pierreTheme, pierreThemeType, diffViewMode }) => {
|
|
||||||
const options = React.useMemo(
|
|
||||||
() => ({
|
|
||||||
diffStyle: diffViewMode === 'side-by-side' ? 'split' as const : 'unified' as const,
|
|
||||||
diffIndicators: 'none' as const,
|
|
||||||
hunkSeparators: 'line-info-basic' as const,
|
|
||||||
lineDiffType: 'none' as const,
|
|
||||||
disableFileHeader: true,
|
|
||||||
maxLineDiffLength: 1000,
|
|
||||||
expansionLineCount: 20,
|
|
||||||
overflow: 'wrap' as const,
|
|
||||||
theme: pierreTheme,
|
|
||||||
themeType: pierreThemeType,
|
|
||||||
unsafeCSS: TOOL_DIFF_UNSAFE_CSS,
|
|
||||||
}),
|
|
||||||
[diffViewMode, pierreTheme, pierreThemeType]
|
|
||||||
);
|
|
||||||
|
|
||||||
const fallback = <PlainDiffFallback diff={diff} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="typography-code px-1 pb-1 pt-0">
|
|
||||||
<DiffPreviewErrorBoundary resetKey={diff} fallback={fallback}>
|
|
||||||
<PatchDiff
|
|
||||||
patch={diff}
|
|
||||||
metrics={TOOL_DIFF_METRICS}
|
|
||||||
options={options}
|
|
||||||
className="block w-full"
|
|
||||||
/>
|
|
||||||
</DiffPreviewErrorBoundary>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
DiffPreview.displayName = 'DiffPreview';
|
|
||||||
|
|
||||||
interface ToolExpandedContentProps {
|
interface ToolExpandedContentProps {
|
||||||
part: ToolPartType;
|
part: ToolPartType;
|
||||||
state: ToolStateUnion;
|
state: ToolStateUnion;
|
||||||
@@ -1357,7 +1230,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const runtime = React.useContext(RuntimeAPIContext);
|
const runtime = React.useContext(RuntimeAPIContext);
|
||||||
const mobileActions = useMobileAppActions();
|
const mobileActions = useMobileAppActions();
|
||||||
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
|
|
||||||
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
|
const [diffViewMode, setDiffViewMode] = React.useState<DiffViewMode>('unified');
|
||||||
const stateWithData = state as ToolStateWithMetadata;
|
const stateWithData = state as ToolStateWithMetadata;
|
||||||
const metadata = stateWithData.metadata;
|
const metadata = stateWithData.metadata;
|
||||||
@@ -1633,8 +1505,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
{entry.renderMode === 'diff' ? (
|
{entry.renderMode === 'diff' ? (
|
||||||
<DiffPreview
|
<DiffPreview
|
||||||
diff={entry.patch}
|
diff={entry.patch}
|
||||||
pierreTheme={pierreTheme}
|
|
||||||
pierreThemeType={pierreThemeType}
|
|
||||||
diffViewMode={diffViewMode}
|
diffViewMode={diffViewMode}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1753,8 +1623,6 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
|
|||||||
) : isWriteLikeTool && writeLikeInputPatch ? (
|
) : isWriteLikeTool && writeLikeInputPatch ? (
|
||||||
<DiffPreview
|
<DiffPreview
|
||||||
diff={writeLikeInputPatch}
|
diff={writeLikeInputPatch}
|
||||||
pierreTheme={pierreTheme}
|
|
||||||
pierreThemeType={pierreThemeType}
|
|
||||||
diffViewMode={diffViewMode}
|
diffViewMode={diffViewMode}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { PatchDiff } from '@pierre/diffs/react';
|
||||||
|
|
||||||
|
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||||
|
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||||
|
import type { DiffViewMode } from '../DiffViewToggle';
|
||||||
|
import { PlainDiffFallback } from './PlainDiffFallback';
|
||||||
|
|
||||||
|
// Loaded lazily from ToolPart: this is the only part of the tool card that
|
||||||
|
// needs @pierre/diffs' rendering stack (Shiki core + regex engines), so the
|
||||||
|
// eager chat graph stays free of it and the chunk downloads on the first
|
||||||
|
// rendered tool diff.
|
||||||
|
|
||||||
|
const TOOL_DIFF_UNSAFE_CSS = `
|
||||||
|
[data-diff-header],
|
||||||
|
[data-diff] {
|
||||||
|
[data-separator] {
|
||||||
|
height: 24px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TOOL_DIFF_METRICS = {
|
||||||
|
hunkLineCount: 50,
|
||||||
|
lineHeight: 24,
|
||||||
|
diffHeaderHeight: 44,
|
||||||
|
hunkSeparatorHeight: 24,
|
||||||
|
spacing: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const usePierreThemeConfig = () => {
|
||||||
|
const themeSystem = useOptionalThemeSystem();
|
||||||
|
const fallbackLightTheme = React.useMemo(() => getDefaultTheme(false), []);
|
||||||
|
const fallbackDarkTheme = React.useMemo(() => getDefaultTheme(true), []);
|
||||||
|
|
||||||
|
const availableThemes = React.useMemo(
|
||||||
|
() => themeSystem?.availableThemes ?? [fallbackLightTheme, fallbackDarkTheme],
|
||||||
|
[fallbackDarkTheme, fallbackLightTheme, themeSystem?.availableThemes],
|
||||||
|
);
|
||||||
|
const lightThemeId = themeSystem?.lightThemeId ?? fallbackLightTheme.metadata.id;
|
||||||
|
const darkThemeId = themeSystem?.darkThemeId ?? fallbackDarkTheme.metadata.id;
|
||||||
|
|
||||||
|
const lightTheme = React.useMemo(
|
||||||
|
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? fallbackLightTheme,
|
||||||
|
[availableThemes, fallbackLightTheme, lightThemeId],
|
||||||
|
);
|
||||||
|
const darkTheme = React.useMemo(
|
||||||
|
() => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? fallbackDarkTheme,
|
||||||
|
[availableThemes, darkThemeId, fallbackDarkTheme],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Registration is synchronous module state inside @pierre/diffs; rendering
|
||||||
|
// a PatchDiff with these theme ids requires it to have happened first, so
|
||||||
|
// register during render rather than in an effect.
|
||||||
|
ensurePierreThemeRegistered(lightTheme);
|
||||||
|
ensurePierreThemeRegistered(darkTheme);
|
||||||
|
|
||||||
|
const currentVariant = themeSystem?.currentTheme.metadata.variant ?? 'light';
|
||||||
|
|
||||||
|
return {
|
||||||
|
pierreTheme: { light: lightTheme.metadata.id, dark: darkTheme.metadata.id },
|
||||||
|
pierreThemeType: currentVariant === 'dark' ? ('dark' as const) : ('light' as const),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
class DiffPreviewErrorBoundary extends React.Component<{
|
||||||
|
resetKey: string;
|
||||||
|
fallback: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}, { hasError: boolean }> {
|
||||||
|
state = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): { hasError: boolean } {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: { resetKey: string }) {
|
||||||
|
if (prevProps.resetKey !== this.props.resetKey && this.state.hasError) {
|
||||||
|
this.setState({ hasError: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error) {
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.warn('Tool diff preview failed; rendering raw patch instead.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolPartDiffPreviewProps {
|
||||||
|
diff: string;
|
||||||
|
diffViewMode: DiffViewMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolPartDiffPreview: React.FC<ToolPartDiffPreviewProps> = React.memo(({ diff, diffViewMode }) => {
|
||||||
|
const { pierreTheme, pierreThemeType } = usePierreThemeConfig();
|
||||||
|
const options = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
diffStyle: diffViewMode === 'side-by-side' ? 'split' as const : 'unified' as const,
|
||||||
|
diffIndicators: 'none' as const,
|
||||||
|
hunkSeparators: 'line-info-basic' as const,
|
||||||
|
lineDiffType: 'none' as const,
|
||||||
|
disableFileHeader: true,
|
||||||
|
maxLineDiffLength: 1000,
|
||||||
|
expansionLineCount: 20,
|
||||||
|
overflow: 'wrap' as const,
|
||||||
|
theme: pierreTheme,
|
||||||
|
themeType: pierreThemeType,
|
||||||
|
unsafeCSS: TOOL_DIFF_UNSAFE_CSS,
|
||||||
|
}),
|
||||||
|
[diffViewMode, pierreTheme, pierreThemeType]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fallback = <PlainDiffFallback diff={diff} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="typography-code px-1 pb-1 pt-0">
|
||||||
|
<DiffPreviewErrorBoundary resetKey={diff} fallback={fallback}>
|
||||||
|
<PatchDiff
|
||||||
|
patch={diff}
|
||||||
|
metrics={TOOL_DIFF_METRICS}
|
||||||
|
options={options}
|
||||||
|
className="block w-full"
|
||||||
|
/>
|
||||||
|
</DiffPreviewErrorBoundary>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ToolPartDiffPreview.displayName = 'ToolPartDiffPreview';
|
||||||
|
|
||||||
|
export default ToolPartDiffPreview;
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
|
||||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||||
|
|
||||||
// ── Threshold: files smaller than this render without virtualization ──
|
// ── Threshold: files smaller than this render without virtualization ──
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
|
||||||
import { highlightCodeInWorker } from '@/components/chat/markdown/markdown-worker';
|
import { highlightCodeInWorker } from '@/components/chat/markdown/markdown-worker';
|
||||||
|
|
||||||
// Shared static code highlighter backed by the markdown Shiki Web Worker.
|
// Shared static code highlighter backed by the markdown Shiki Web Worker.
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
|||||||
import { DiffViewIcon } from '@/components/icons/DiffIcon';
|
import { DiffViewIcon } from '@/components/icons/DiffIcon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||||||
import { DiffView } from '@/components/views/DiffView';
|
|
||||||
import { FilesView } from '@/components/views/FilesView';
|
|
||||||
import { GitView } from '@/components/views/GitView';
|
|
||||||
import { PullRequestView } from '@/components/views/PullRequestView';
|
import { PullRequestView } from '@/components/views/PullRequestView';
|
||||||
import { TerminalView } from '@/components/views/TerminalView';
|
import { TerminalView } from '@/components/views/TerminalView';
|
||||||
import { WalkthroughView } from '@/components/views/walkthrough/WalkthroughView';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
import { PlanView } from '@/components/views/PlanView';
|
|
||||||
|
// Heavy views stay on-demand (same as MainLayout): importing DiffView/FilesView
|
||||||
|
// or the walkthrough statically pulls the CodeMirror and @pierre/diffs stacks
|
||||||
|
// into the eager startup graph even when no such tab is open.
|
||||||
|
const WalkthroughView = lazyWithChunkRecovery(() => import('@/components/views/walkthrough/WalkthroughView').then((m) => ({ default: m.WalkthroughView })));
|
||||||
|
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then((m) => ({ default: m.DiffView })));
|
||||||
|
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then((m) => ({ default: m.FilesView })));
|
||||||
|
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then((m) => ({ default: m.GitView })));
|
||||||
|
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then((m) => ({ default: m.PlanView })));
|
||||||
import { ProjectContextPanel } from './RightSidebarTabs';
|
import { ProjectContextPanel } from './RightSidebarTabs';
|
||||||
import { SidebarFilesTree } from './SidebarFilesTree';
|
import { SidebarFilesTree } from './SidebarFilesTree';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
@@ -45,6 +50,7 @@ import {
|
|||||||
type EmbeddedSessionRuntimeBootstrap,
|
type EmbeddedSessionRuntimeBootstrap,
|
||||||
} from './contextPanelEmbeddedChat';
|
} from './contextPanelEmbeddedChat';
|
||||||
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
|
import { getContextSurfaceWidthFraction } from '@/lib/surfaces/registry';
|
||||||
|
import { isTerminalEventTarget } from '@/lib/terminalFocus';
|
||||||
import {
|
import {
|
||||||
type PreviewElementMetadata,
|
type PreviewElementMetadata,
|
||||||
isPreviewElementMetadata,
|
isPreviewElementMetadata,
|
||||||
@@ -2453,6 +2459,13 @@ export const ContextPanel: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminal owns Escape so the PTY receives it (e.g. Vim Normal mode).
|
||||||
|
// ghostty-web listens in the bubble phase; stopping capture here would
|
||||||
|
// swallow the key before the terminal ever sees it (issue #2644).
|
||||||
|
if (isTerminalEventTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleClose();
|
handleClose();
|
||||||
@@ -2691,13 +2704,13 @@ export const ContextPanel: React.FC = () => {
|
|||||||
const activeNonChatContent = activeTab?.mode === 'context'
|
const activeNonChatContent = activeTab?.mode === 'context'
|
||||||
? <ContextPanelContent />
|
? <ContextPanelContent />
|
||||||
: activeTab?.mode === 'git'
|
: activeTab?.mode === 'git'
|
||||||
? <GitView isActive={isOpen} />
|
? <React.Suspense fallback={null}><GitView isActive={isOpen} /></React.Suspense>
|
||||||
: activeTab?.mode === 'pr'
|
: activeTab?.mode === 'pr'
|
||||||
? <PullRequestView />
|
? <PullRequestView />
|
||||||
: activeTab?.mode === 'notes'
|
: activeTab?.mode === 'notes'
|
||||||
? <ProjectContextPanel />
|
? <ProjectContextPanel />
|
||||||
: activeTab?.mode === 'plan'
|
: activeTab?.mode === 'plan'
|
||||||
? <PlanView targetPath={activeTab.targetPath} />
|
? <React.Suspense fallback={null}><PlanView targetPath={activeTab.targetPath} /></React.Suspense>
|
||||||
: activeTab?.mode === 'preview'
|
: activeTab?.mode === 'preview'
|
||||||
? <PreviewPane rawUrl={activeTab.targetPath ?? ''} onNavigate={(url) => openContextPreview(effectiveDirectory, url)} />
|
? <PreviewPane rawUrl={activeTab.targetPath ?? ''} onNavigate={(url) => openContextPreview(effectiveDirectory, url)} />
|
||||||
: (
|
: (
|
||||||
@@ -2909,7 +2922,7 @@ export const ContextPanel: React.FC = () => {
|
|||||||
<div className={cn('absolute inset-0 flex', isFileTabActive ? 'flex' : 'hidden')}>
|
<div className={cn('absolute inset-0 flex', isFileTabActive ? 'flex' : 'hidden')}>
|
||||||
<div className="h-full min-w-0 flex-1">
|
<div className="h-full min-w-0 flex-1">
|
||||||
{hasOpenEditorFile ? (
|
{hasOpenEditorFile ? (
|
||||||
<FilesView mode="editor-only" />
|
<React.Suspense fallback={null}><FilesView mode="editor-only" /></React.Suspense>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
<Icon name="file-code" className="h-12 w-12 text-muted-foreground/50" />
|
<Icon name="file-code" className="h-12 w-12 text-muted-foreground/50" />
|
||||||
@@ -2975,16 +2988,18 @@ export const ContextPanel: React.FC = () => {
|
|||||||
activeTab?.id !== tab.id && 'hidden'
|
activeTab?.id !== tab.id && 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DiffView
|
<React.Suspense fallback={null}>
|
||||||
hideStackedFileSidebar
|
<DiffView
|
||||||
stackedDefaultCollapsedAll
|
hideStackedFileSidebar
|
||||||
pinSelectedFileHeaderToTopOnNavigate
|
stackedDefaultCollapsedAll
|
||||||
showOpenInEditorAction
|
pinSelectedFileHeaderToTopOnNavigate
|
||||||
diffScope={tab.diffScope ?? (tab.stagedDiff ? 'staged' : 'working')}
|
showOpenInEditorAction
|
||||||
onDiffScopeChange={handleDiffScopeChange}
|
diffScope={tab.diffScope ?? (tab.stagedDiff ? 'staged' : 'working')}
|
||||||
targetFilePath={tab.targetPath}
|
onDiffScopeChange={handleDiffScopeChange}
|
||||||
flushContent
|
targetFilePath={tab.targetPath}
|
||||||
/>
|
flushContent
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{hasTerminalTab ? (
|
{hasTerminalTab ? (
|
||||||
@@ -2994,7 +3009,9 @@ export const ContextPanel: React.FC = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
{hasWalkthroughTab ? (
|
{hasWalkthroughTab ? (
|
||||||
<div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}>
|
<div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}>
|
||||||
<WalkthroughView directory={effectiveDirectory} />
|
<React.Suspense fallback={null}>
|
||||||
|
<WalkthroughView directory={effectiveDirectory} />
|
||||||
|
</React.Suspense>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null}
|
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null}
|
||||||
|
|||||||
@@ -24,18 +24,23 @@ import { useDeviceInfo } from '@/lib/device';
|
|||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
|
getVisibleContextRailSurfaces,
|
||||||
sortContextSurfaces,
|
sortContextSurfaces,
|
||||||
type ContextSurfaceDescriptor,
|
type ContextSurfaceDescriptor,
|
||||||
} from '@/lib/surfaces/registry';
|
} from '@/lib/surfaces/registry';
|
||||||
|
import {
|
||||||
|
getEffectiveShortcutPrefix,
|
||||||
|
isShortcutPrefixHeld,
|
||||||
|
} from '@/lib/shortcuts';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||||
import { useGitStatus } from '@/stores/useGitStore';
|
import { useGitStatus } from '@/stores/useGitStore';
|
||||||
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
|
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
const RAIL_TOOLTIP_DELAY_MS = 150;
|
const RAIL_TOOLTIP_DELAY_MS = 150;
|
||||||
// Tablet width and up: below this the walkthrough cannot show a stop and its
|
// Hold the surface-switch modifier for this long before revealing the order
|
||||||
// code side by side, which is the whole point of the surface.
|
// number badges on the rail icons.
|
||||||
const WALKTHROUGH_MIN_WIDTH = 768;
|
const RAIL_NUMBER_HOLD_DELAY_MS = 500;
|
||||||
const EMPTY_TABS: never[] = [];
|
const EMPTY_TABS: never[] = [];
|
||||||
|
|
||||||
type RailItemProps = {
|
type RailItemProps = {
|
||||||
@@ -44,21 +49,40 @@ type RailItemProps = {
|
|||||||
showActivityDot: boolean;
|
showActivityDot: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
/** Numeric badge (e.g. the Git changed-files count); takes precedence over the activity dot. */
|
||||||
|
badgeCount?: number | null;
|
||||||
|
/** Accessible label that includes the badge count; falls back to `label`. */
|
||||||
|
badgeAriaLabel?: string | null;
|
||||||
|
/** Extra tooltip line describing the badge; rendered under the description. */
|
||||||
|
badgeDescription?: string | null;
|
||||||
|
orderNumber?: number | null;
|
||||||
|
showOrderNumber?: boolean;
|
||||||
onSelect: (surface: ContextSurfaceDescriptor) => void;
|
onSelect: (surface: ContextSurfaceDescriptor) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The badge corner is 16px tall; cap large counts so the pill stays compact
|
||||||
|
// on the 36px rail button (matching the order-number badge's footprint).
|
||||||
|
const formatRailBadgeCount = (count: number): string => (count > 99 ? '99+' : String(count));
|
||||||
|
|
||||||
const ContextPanelRailItem: React.FC<RailItemProps> = ({
|
const ContextPanelRailItem: React.FC<RailItemProps> = ({
|
||||||
surface,
|
surface,
|
||||||
isActive,
|
isActive,
|
||||||
showActivityDot,
|
showActivityDot,
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
|
badgeCount,
|
||||||
|
badgeAriaLabel,
|
||||||
|
badgeDescription,
|
||||||
|
orderNumber,
|
||||||
|
showOrderNumber,
|
||||||
onSelect,
|
onSelect,
|
||||||
}) => {
|
}) => {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: surface.id,
|
id: surface.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const displayBadgeCount = badgeCount != null && badgeCount > 0 ? formatRailBadgeCount(badgeCount) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
@@ -72,7 +96,7 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
onClick={() => onSelect(surface)}
|
onClick={() => onSelect(surface)}
|
||||||
aria-label={label}
|
aria-label={badgeAriaLabel ?? label}
|
||||||
aria-pressed={isActive}
|
aria-pressed={isActive}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-9 touch-none select-none items-center justify-center rounded-md transition-colors',
|
'flex h-9 w-9 touch-none select-none items-center justify-center rounded-md transition-colors',
|
||||||
@@ -86,7 +110,21 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<Icon name={surface.icon} className="h-[18px] w-[18px]" />
|
<Icon name={surface.icon} className="h-[18px] w-[18px]" />
|
||||||
)}
|
)}
|
||||||
{showActivityDot ? (
|
{showOrderNumber && orderNumber != null ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute right-0 top-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-surface-muted px-1 text-[0.625rem] font-medium leading-none text-muted-foreground"
|
||||||
|
>
|
||||||
|
{orderNumber === 10 ? '0' : orderNumber}
|
||||||
|
</span>
|
||||||
|
) : displayBadgeCount ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute right-0 top-0 flex h-4 min-w-4 items-center justify-center rounded-full bg-surface-muted px-1 text-[0.625rem] font-medium leading-none text-muted-foreground"
|
||||||
|
>
|
||||||
|
{displayBadgeCount}
|
||||||
|
</span>
|
||||||
|
) : showActivityDot ? (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
|
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
|
||||||
@@ -98,6 +136,9 @@ const ContextPanelRailItem: React.FC<RailItemProps> = ({
|
|||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<span className="typography-micro text-muted-foreground">{description}</span>
|
<span className="typography-micro text-muted-foreground">{description}</span>
|
||||||
|
{badgeDescription ? (
|
||||||
|
<span className="typography-micro text-muted-foreground">{badgeDescription}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -114,10 +155,86 @@ export const ContextPanelRail: React.FC = () => {
|
|||||||
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
|
const contextRailOrder = useUIStore((state) => state.contextRailOrder);
|
||||||
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
|
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
|
||||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||||
|
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
||||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||||
const { screenWidth } = useDeviceInfo();
|
const { screenWidth } = useDeviceInfo();
|
||||||
const gitStatus = useGitStatus(directoryKey || null);
|
const gitStatus = useGitStatus(directoryKey || null);
|
||||||
|
|
||||||
|
const surfaceSwitchPrefix = React.useMemo(
|
||||||
|
() => getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides),
|
||||||
|
[shortcutOverrides],
|
||||||
|
);
|
||||||
|
const [revealNumbers, setRevealNumbers] = React.useState(false);
|
||||||
|
|
||||||
|
// While the surface-switch modifier is held for RAIL_NUMBER_HOLD_DELAY_MS,
|
||||||
|
// reveal the order number badges so users can see which digit maps to which
|
||||||
|
// rail icon. Releasing (or losing focus) dismisses them, and pressing a
|
||||||
|
// number key while the chord is armed consumes them for this hold — they
|
||||||
|
// only come back on the next press-and-hold.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const held = new Set<string>();
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let consumedWhileHeld = false;
|
||||||
|
|
||||||
|
const isDigitKey = (key: string) => key.length === 1 && key >= '0' && key <= '9';
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
const armed = isShortcutPrefixHeld(surfaceSwitchPrefix, held);
|
||||||
|
if (armed) {
|
||||||
|
if (!consumedWhileHeld && timer === null) {
|
||||||
|
timer = setTimeout(() => setRevealNumbers(true), RAIL_NUMBER_HOLD_DELAY_MS);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consumedWhileHeld = false;
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
setRevealNumbers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
held.add(e.key.toLowerCase());
|
||||||
|
if (isDigitKey(e.key) && isShortcutPrefixHeld(surfaceSwitchPrefix, held)) {
|
||||||
|
consumedWhileHeld = true;
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
setRevealNumbers(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
held.delete(e.key.toLowerCase());
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
const onWindowBlur = () => {
|
||||||
|
held.clear();
|
||||||
|
consumedWhileHeld = false;
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
setRevealNumbers(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown, true);
|
||||||
|
window.addEventListener('keyup', onKeyUp, true);
|
||||||
|
window.addEventListener('blur', onWindowBlur);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown, true);
|
||||||
|
window.removeEventListener('keyup', onKeyUp, true);
|
||||||
|
window.removeEventListener('blur', onWindowBlur);
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [surfaceSwitchPrefix]);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
||||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
|
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
|
||||||
@@ -128,22 +245,13 @@ export const ContextPanelRail: React.FC = () => {
|
|||||||
const activeMode = panelState?.isOpen ? activeTab?.mode ?? null : null;
|
const activeMode = panelState?.isOpen ? activeTab?.mode ?? null : null;
|
||||||
const changedFilesCount = gitStatus?.files.length ?? 0;
|
const changedFilesCount = gitStatus?.files.length ?? 0;
|
||||||
|
|
||||||
// Content-driven surfaces are hidden (not disabled) until content exists;
|
|
||||||
// an existing tab keeps them visible even if the content source went away.
|
|
||||||
const surfaces = React.useMemo(() => {
|
const surfaces = React.useMemo(() => {
|
||||||
return sortContextSurfaces(contextRailOrder).filter((surface) => {
|
return getVisibleContextRailSurfaces({
|
||||||
if (surface.id === 'plan' && !planModeEnabled) {
|
railOrder: contextRailOrder,
|
||||||
return false;
|
planModeEnabled,
|
||||||
}
|
isVSCode: isVSCodeRuntime(),
|
||||||
// The walkthrough needs room for a stop list beside real code, and its
|
screenWidth,
|
||||||
// diffs come from OpenChamber's Git routes, which VS Code does not serve.
|
tabs,
|
||||||
if (surface.id === 'walkthrough' && (isVSCodeRuntime() || screenWidth < WALKTHROUGH_MIN_WIDTH)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (surface.availability === 'has-content') {
|
|
||||||
return tabs.some((tab) => tab.mode === surface.mode);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
});
|
||||||
}, [contextRailOrder, planModeEnabled, screenWidth, tabs]);
|
}, [contextRailOrder, planModeEnabled, screenWidth, tabs]);
|
||||||
|
|
||||||
@@ -174,17 +282,43 @@ export const ContextPanelRail: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<SortableContext items={surfaces.map((surface) => surface.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={surfaces.map((surface) => surface.id)} strategy={verticalListSortingStrategy}>
|
||||||
{surfaces.map((surface) => (
|
{surfaces.map((surface, index) => {
|
||||||
<ContextPanelRailItem
|
const label = t(surface.labelKey);
|
||||||
key={surface.id}
|
// Git shows a numeric badge instead of the old activity dot.
|
||||||
surface={surface}
|
// Other surfaces never inherit git's changed-files signal.
|
||||||
isActive={activeMode === surface.mode}
|
const gitChangedCount = surface.id === 'git' ? changedFilesCount : 0;
|
||||||
showActivityDot={surface.id === 'git' && changedFilesCount > 0}
|
const badgeCount = gitChangedCount > 0 ? gitChangedCount : null;
|
||||||
label={t(surface.labelKey)}
|
return (
|
||||||
description={t(surface.descriptionKey)}
|
<ContextPanelRailItem
|
||||||
onSelect={(selected) => openContextSurface(directoryKey, selected.mode)}
|
key={surface.id}
|
||||||
/>
|
surface={surface}
|
||||||
))}
|
isActive={activeMode === surface.mode}
|
||||||
|
showActivityDot={false}
|
||||||
|
label={label}
|
||||||
|
description={t(surface.descriptionKey)}
|
||||||
|
badgeCount={badgeCount}
|
||||||
|
badgeAriaLabel={badgeCount !== null
|
||||||
|
? t(
|
||||||
|
badgeCount === 1
|
||||||
|
? 'contextRail.surface.git.changesCountAriaSingle'
|
||||||
|
: 'contextRail.surface.git.changesCountAriaPlural',
|
||||||
|
{ label, count: badgeCount },
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
badgeDescription={badgeCount !== null
|
||||||
|
? t(
|
||||||
|
badgeCount === 1
|
||||||
|
? 'contextRail.surface.git.changesCountTooltipSingle'
|
||||||
|
: 'contextRail.surface.git.changesCountTooltipPlural',
|
||||||
|
{ count: badgeCount },
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
orderNumber={index + 1}
|
||||||
|
showOrderNumber={revealNumbers}
|
||||||
|
onSelect={(selected) => openContextSurface(directoryKey, selected.mode)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -29,14 +29,16 @@ import { cn } from '@/lib/utils';
|
|||||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
|
|
||||||
import { ChatView } from '@/components/views/ChatView';
|
import { ChatView } from '@/components/views/ChatView';
|
||||||
import { DiffView } from '@/components/views/DiffView';
|
|
||||||
import { FilesView } from '@/components/views/FilesView';
|
|
||||||
import { GitView } from '@/components/views/GitView';
|
|
||||||
import { PlanView } from '@/components/views/PlanView';
|
|
||||||
|
|
||||||
// Keep TerminalView eager: the bottom dock reserves its height immediately, so
|
// Keep TerminalView eager: the bottom dock reserves its height immediately, so
|
||||||
// suspending here leaves a large blank panel on slower machines.
|
// suspending here leaves a large blank panel on slower machines.
|
||||||
// Other heavy views stay on-demand to reduce initial bundle parse time.
|
// Other heavy views stay on-demand to reduce initial bundle parse time:
|
||||||
|
// DiffView/FilesView pull the CodeMirror and @pierre/diffs stacks into the
|
||||||
|
// startup graph when imported statically.
|
||||||
|
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView })));
|
||||||
|
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
|
||||||
|
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
|
||||||
|
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
|
||||||
const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
|
const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
|
||||||
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||||
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
||||||
@@ -48,6 +50,17 @@ export const MainLayout: React.FC = () => {
|
|||||||
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
|
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
|
||||||
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
||||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||||
|
// Mount the windowed settings dialog only after its first open: rendering
|
||||||
|
// the lazy component (even closed) makes React fetch the SettingsView
|
||||||
|
// chunk graph (CodeMirror editor, vim mode, theme tooling) on startup.
|
||||||
|
// Once opened it stays mounted so the close animation and state behave as
|
||||||
|
// before.
|
||||||
|
const [settingsWindowMounted, setSettingsWindowMounted] = React.useState(false);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isSettingsDialogOpen) {
|
||||||
|
setSettingsWindowMounted(true);
|
||||||
|
}
|
||||||
|
}, [isSettingsDialogOpen]);
|
||||||
const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
|
const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
|
||||||
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
|
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
|
||||||
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
|
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
|
||||||
@@ -464,12 +477,14 @@ export const MainLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop settings: windowed dialog with blur */}
|
{/* Desktop settings: windowed dialog with blur */}
|
||||||
<React.Suspense fallback={null}>
|
{settingsWindowMounted ? (
|
||||||
<SettingsWindow
|
<React.Suspense fallback={null}>
|
||||||
open={isSettingsDialogOpen}
|
<SettingsWindow
|
||||||
onOpenChange={setSettingsDialogOpen}
|
open={isSettingsDialogOpen}
|
||||||
/>
|
onOpenChange={setSettingsDialogOpen}
|
||||||
</React.Suspense>
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Regression guard for https://github.com/openchamber/openchamber/issues/2644
|
||||||
|
*
|
||||||
|
* Escape while focus is inside the terminal must reach the PTY (e.g. Vim
|
||||||
|
* Normal mode). The context panel still closes on Escape when focus is on
|
||||||
|
* non-terminal panel chrome.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const contextPanelSource = readFileSync(join(__dirname, '..', 'ContextPanel.tsx'), 'utf-8');
|
||||||
|
const mobileWorkspaceDrawerSource = readFileSync(
|
||||||
|
join(__dirname, '..', '..', '..', 'apps', 'MobileWorkspaceDrawer.tsx'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('issue #2644: Escape in terminal must not close the context panel', () => {
|
||||||
|
test('the context panel captures Escape at the panel level', () => {
|
||||||
|
expect(contextPanelSource).toContain('onKeyDownCapture={handlePanelKeyDownCapture}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the capture handler skips closing when the event target is inside the terminal', () => {
|
||||||
|
const start = contextPanelSource.indexOf('const handlePanelKeyDownCapture = React.useCallback(');
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
const end = contextPanelSource.indexOf('}, [handleClose]);', start);
|
||||||
|
expect(end).toBeGreaterThan(start);
|
||||||
|
const handler = contextPanelSource.slice(start, end);
|
||||||
|
|
||||||
|
expect(handler).toContain("event.key !== 'Escape'");
|
||||||
|
expect(handler).toContain('isTerminalEventTarget(event.target)');
|
||||||
|
expect(handler).toContain('event.preventDefault()');
|
||||||
|
expect(handler).toContain('event.stopPropagation()');
|
||||||
|
expect(handler).toContain('handleClose()');
|
||||||
|
|
||||||
|
// Guard must return before preventDefault/stopPropagation so ghostty-web's
|
||||||
|
// bubble-phase keydown listener can forward Escape to the PTY.
|
||||||
|
const guardIndex = handler.indexOf('isTerminalEventTarget(event.target)');
|
||||||
|
const preventIndex = handler.indexOf('event.preventDefault()');
|
||||||
|
expect(guardIndex).toBeGreaterThan(-1);
|
||||||
|
expect(preventIndex).toBeGreaterThan(guardIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ContextPanel imports the shared terminal focus helper', () => {
|
||||||
|
expect(contextPanelSource).toContain("from '@/lib/terminalFocus'");
|
||||||
|
expect(contextPanelSource).toContain('isTerminalEventTarget');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile drawer keeps its terminal Escape exception', () => {
|
||||||
|
const handlerStart = mobileWorkspaceDrawerSource.indexOf("if (event.key === 'Escape'");
|
||||||
|
expect(handlerStart).toBeGreaterThan(-1);
|
||||||
|
const handler = mobileWorkspaceDrawerSource.slice(handlerStart, handlerStart + 200);
|
||||||
|
expect(handler).toContain("tabRef.current !== 'terminal'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
type Listener = { capture: boolean; onEvent: (event: SimulatedEvent) => void };
|
||||||
|
type SimulatedEvent = {
|
||||||
|
type: string;
|
||||||
|
defaultPrevented: boolean;
|
||||||
|
propagationStopped: boolean;
|
||||||
|
target: SimNode;
|
||||||
|
preventDefault(): void;
|
||||||
|
stopPropagation(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SimNode {
|
||||||
|
readonly children: SimNode[] = [];
|
||||||
|
private listeners: Listener[] = [];
|
||||||
|
private parent: SimNode | null = null;
|
||||||
|
|
||||||
|
addListener(listener: Listener): void {
|
||||||
|
this.listeners.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
attach(child: SimNode): void {
|
||||||
|
child.parent = this;
|
||||||
|
this.children.push(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(type: string): SimulatedEvent {
|
||||||
|
const buildPath = (target: SimNode): SimNode[] => {
|
||||||
|
const ancestors: SimNode[] = [];
|
||||||
|
let cursor: SimNode | null = target;
|
||||||
|
while (cursor !== null) {
|
||||||
|
ancestors.push(cursor);
|
||||||
|
cursor = cursor.parent;
|
||||||
|
}
|
||||||
|
ancestors.reverse();
|
||||||
|
return ancestors;
|
||||||
|
};
|
||||||
|
const path = buildPath(this);
|
||||||
|
|
||||||
|
const event: SimulatedEvent = {
|
||||||
|
type,
|
||||||
|
defaultPrevented: false,
|
||||||
|
propagationStopped: false,
|
||||||
|
target: this,
|
||||||
|
preventDefault() {
|
||||||
|
event.defaultPrevented = true;
|
||||||
|
},
|
||||||
|
stopPropagation() {
|
||||||
|
event.propagationStopped = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < path.length; i += 1) {
|
||||||
|
if (event.propagationStopped) return event;
|
||||||
|
for (const listener of path[i].listeners) {
|
||||||
|
if (!listener.capture) continue;
|
||||||
|
listener.onEvent(event);
|
||||||
|
if (event.propagationStopped) return event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = path.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (event.propagationStopped) return event;
|
||||||
|
for (const listener of path[i].listeners) {
|
||||||
|
if (listener.capture) continue;
|
||||||
|
listener.onEvent(event);
|
||||||
|
if (event.propagationStopped) return event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('issue #2644: fixed Escape propagation to the terminal', () => {
|
||||||
|
test('when the panel skips terminal Escape, the terminal bubble handler receives it', () => {
|
||||||
|
const panel = new SimNode();
|
||||||
|
const terminalContainer = new SimNode();
|
||||||
|
panel.attach(terminalContainer);
|
||||||
|
|
||||||
|
const calls: string[] = [];
|
||||||
|
const panelEscapeHandler = (event: SimulatedEvent) => {
|
||||||
|
// Fixed behavior: do not close / stop when the target is the terminal.
|
||||||
|
if (event.target === terminalContainer) {
|
||||||
|
calls.push('panel-capture-skipped');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
calls.push('panel-capture-closed');
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
const terminalKeydownHandler = () => {
|
||||||
|
calls.push('terminal-bubble');
|
||||||
|
};
|
||||||
|
|
||||||
|
panel.addListener({ capture: true, onEvent: panelEscapeHandler });
|
||||||
|
terminalContainer.addListener({ capture: false, onEvent: terminalKeydownHandler });
|
||||||
|
|
||||||
|
const event = terminalContainer.dispatch('keydown');
|
||||||
|
|
||||||
|
expect(calls).toEqual(['panel-capture-skipped', 'terminal-bubble']);
|
||||||
|
expect(event.propagationStopped).toBe(false);
|
||||||
|
expect(event.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape outside the terminal still closes via the capture handler', () => {
|
||||||
|
const panel = new SimNode();
|
||||||
|
const headerButton = new SimNode();
|
||||||
|
const terminalContainer = new SimNode();
|
||||||
|
panel.attach(headerButton);
|
||||||
|
panel.attach(terminalContainer);
|
||||||
|
|
||||||
|
const calls: string[] = [];
|
||||||
|
panel.addListener({
|
||||||
|
capture: true,
|
||||||
|
onEvent: (event) => {
|
||||||
|
if (event.target === terminalContainer) return;
|
||||||
|
calls.push('panel-capture-closed');
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
terminalContainer.addListener({
|
||||||
|
capture: false,
|
||||||
|
onEvent: () => calls.push('terminal-bubble'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = headerButton.dispatch('keydown');
|
||||||
|
expect(calls).toEqual(['panel-capture-closed']);
|
||||||
|
expect(event.propagationStopped).toBe(true);
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -436,7 +436,10 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allowedProviderSet = React.useMemo(() => {
|
const allowedProviderSet = React.useMemo(() => {
|
||||||
if (!allowedProviderIds || allowedProviderIds.length === 0) return null;
|
// undefined = no restriction; [] = allow none. Treating empty like
|
||||||
|
// "unrestricted" would resurface providers without a login in pickers that
|
||||||
|
// intentionally pass the authenticated-only list.
|
||||||
|
if (!allowedProviderIds) return null;
|
||||||
return new Set(allowedProviderIds);
|
return new Set(allowedProviderIds);
|
||||||
}, [allowedProviderIds]);
|
}, [allowedProviderIds]);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Icon } from "@/components/icon/Icon";
|
|||||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
|
import { InstanceServiceUrls } from './InstanceServiceUrls';
|
||||||
import {
|
import {
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
SETTINGS_BRAND_TITLE_CLASS,
|
SETTINGS_BRAND_TITLE_CLASS,
|
||||||
@@ -135,6 +136,7 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
|
|||||||
<p>{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}</p>
|
<p>{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}</p>
|
||||||
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</p>
|
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<InstanceServiceUrls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
@@ -278,6 +280,11 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-b border-border/40 px-4 py-3 @xl:flex-row @xl:items-center @xl:justify-between">
|
||||||
|
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.about.field.instanceUrls')}</span>
|
||||||
|
<InstanceServiceUrls />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 px-4 py-4">
|
<div className="flex items-center gap-4 px-4 py-4">
|
||||||
<a
|
<a
|
||||||
href={GITHUB_URL}
|
href={GITHUB_URL}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
|
import { openExternalUrl } from '@/lib/url';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Icon } from '@/components/icon/Icon';
|
||||||
|
|
||||||
|
type InstanceServiceInfo = {
|
||||||
|
port: number | null;
|
||||||
|
tunnelUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InstanceService = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the active instance's service URLs (local server port + tunnel URL,
|
||||||
|
* when a tunnel is active) as labeled buttons that open the URL in the
|
||||||
|
* browser. The data comes from `/api/system/info`, which the server derives
|
||||||
|
* from its own runtime state — this is what makes each Git-worktree instance
|
||||||
|
* distinguishable in the UI without reading terminal output.
|
||||||
|
*
|
||||||
|
* The section stays hidden when the endpoint is unavailable or reports no
|
||||||
|
* port/tunnel (e.g. VS Code runtime), so a failed fetch never renders stale
|
||||||
|
* or wrong URLs.
|
||||||
|
*/
|
||||||
|
export const InstanceServiceUrls: React.FC = () => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [info, setInfo] = React.useState<InstanceServiceInfo | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const response = await runtimeFetch('/api/system/info', {
|
||||||
|
signal: controller?.signal,
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json().catch(() => null) as { port?: unknown; tunnelUrl?: unknown } | null;
|
||||||
|
if (!data || cancelled) return;
|
||||||
|
const port = typeof data.port === 'number' && Number.isFinite(data.port) && data.port > 0 ? data.port : null;
|
||||||
|
const tunnelUrl = typeof data.tunnelUrl === 'string' && data.tunnelUrl.trim().length > 0
|
||||||
|
? data.tunnelUrl.trim()
|
||||||
|
: null;
|
||||||
|
setInfo({ port, tunnelUrl });
|
||||||
|
} catch {
|
||||||
|
// Best-effort: a failed fetch keeps the section hidden instead of
|
||||||
|
// showing data we cannot verify.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
controller?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const services: InstanceService[] = [];
|
||||||
|
if (info?.port !== null && info?.port !== undefined) {
|
||||||
|
services.push({
|
||||||
|
key: 'application',
|
||||||
|
label: t('settings.openchamber.about.field.applicationUrl'),
|
||||||
|
url: `http://localhost:${info.port}/`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (info?.tunnelUrl) {
|
||||||
|
services.push({
|
||||||
|
key: 'tunnel',
|
||||||
|
label: t('settings.openchamber.about.field.tunnelUrl'),
|
||||||
|
url: info.tunnelUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (services.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{services.map((service) => (
|
||||||
|
<Button
|
||||||
|
key={service.key}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
title={service.label}
|
||||||
|
className="max-w-full gap-1.5 px-2.5"
|
||||||
|
onClick={() => {
|
||||||
|
void openExternalUrl(service.url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="external-link" className="size-3.5 shrink-0" />
|
||||||
|
<span className="max-w-64 truncate font-mono typography-micro">{service.url}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
formatShortcutForDisplay,
|
formatShortcutForDisplay,
|
||||||
getCustomizableShortcutActions,
|
getCustomizableShortcutActions,
|
||||||
getEffectiveShortcutCombo,
|
getEffectiveShortcutCombo,
|
||||||
|
getEffectiveShortcutPrefix,
|
||||||
isRiskyBrowserShortcut,
|
isRiskyBrowserShortcut,
|
||||||
keyToShortcutToken,
|
keyToShortcutToken,
|
||||||
normalizeCombo,
|
normalizeCombo,
|
||||||
@@ -49,6 +50,35 @@ const keyboardEventToCombo = (event: React.KeyboardEvent<HTMLInputElement>): Sho
|
|||||||
return normalizeCombo(parts.join('+'));
|
return normalizeCombo(parts.join('+'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Prefix capture for chord-style shortcuts (e.g. "switch context panel
|
||||||
|
// surface"): a bare modifier press is accepted so the prefix can be just the
|
||||||
|
// primary modifier (default) or a modifier + key chord like `mod+p`.
|
||||||
|
const keyboardEventToPrefixCombo = (event: React.KeyboardEvent<HTMLInputElement>): ShortcutCombo | null => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (event.metaKey || event.ctrlKey) {
|
||||||
|
parts.push('mod');
|
||||||
|
}
|
||||||
|
if (event.shiftKey) {
|
||||||
|
parts.push('shift');
|
||||||
|
}
|
||||||
|
if (event.altKey) {
|
||||||
|
parts.push('alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MODIFIER_KEYS.has(event.key.toLowerCase())) {
|
||||||
|
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyToken = keyToShortcutToken(event.key);
|
||||||
|
if (!keyToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(keyToken);
|
||||||
|
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||||
|
};
|
||||||
|
|
||||||
export const KeyboardShortcutsSettings: React.FC = () => {
|
export const KeyboardShortcutsSettings: React.FC = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
|
||||||
@@ -211,10 +241,19 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
{actions.map((action, index) => {
|
{actions.map((action, index) => {
|
||||||
const effective = getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
const isSurfaceSwitch = action.id === 'switch_context_surface';
|
||||||
|
const effective = isSurfaceSwitch
|
||||||
|
? getEffectiveShortcutPrefix(action.id, shortcutOverrides)
|
||||||
|
: getEffectiveShortcutCombo(action.id, shortcutOverrides);
|
||||||
const draft = draftByAction[action.id];
|
const draft = draftByAction[action.id];
|
||||||
const displayCombo = draft ?? effective;
|
const displayCombo = draft ?? effective;
|
||||||
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
|
const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective);
|
||||||
|
const isUnassignedDisplay = displayCombo === '' || normalizeCombo(displayCombo) === UNASSIGNED_SHORTCUT;
|
||||||
|
const displayValue = capturingActionId === action.id
|
||||||
|
? t('settings.openchamber.keyboardShortcuts.field.pressKeys')
|
||||||
|
: isSurfaceSwitch && !isUnassignedDisplay
|
||||||
|
? `${formatShortcutForDisplay(displayCombo)}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}`
|
||||||
|
: formatShortcutForDisplay(displayCombo);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
|
<div key={action.id} className={cn("py-1.5", index > 0 && "border-t border-border/40")}>
|
||||||
@@ -224,7 +263,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
value={capturingActionId === action.id ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') : formatShortcutForDisplay(displayCombo)}
|
value={displayValue}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setCapturingActionId(action.id);
|
setCapturingActionId(action.id);
|
||||||
setErrorText('');
|
setErrorText('');
|
||||||
@@ -243,7 +282,7 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const combo = keyboardEventToCombo(event);
|
const combo = isSurfaceSwitch ? keyboardEventToPrefixCombo(event) : keyboardEventToCombo(event);
|
||||||
if (!combo) {
|
if (!combo) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,13 +152,13 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
field: 'title' | 'message',
|
field: 'title' | 'message',
|
||||||
value: string,
|
value: string,
|
||||||
) => {
|
) => {
|
||||||
setNotificationTemplates({
|
setNotificationTemplates((current) => ({
|
||||||
...notificationTemplates,
|
...current,
|
||||||
[event]: {
|
[event]: {
|
||||||
...notificationTemplates[event],
|
...current[event],
|
||||||
[field]: value,
|
[field]: value,
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const base64UrlToUint8Array = (base64Url: string): Uint8Array<ArrayBuffer> => {
|
const base64UrlToUint8Array = (base64Url: string): Uint8Array<ArrayBuffer> => {
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { toast } from '@/components/ui';
|
||||||
|
import { Icon } from '@/components/icon/Icon';
|
||||||
|
import {
|
||||||
|
SETTINGS_SELECT_ROW_TRIGGER_CLASS,
|
||||||
|
SETTINGS_SELECT_SIZE,
|
||||||
|
} from '@/components/sections/shared/SettingsSection';
|
||||||
|
import { useI18n, type I18nKey } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import { openExternalUrl } from '@/lib/url';
|
||||||
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
import {
|
||||||
|
collectPromptInputs,
|
||||||
|
defaultPromptValues,
|
||||||
|
describeOAuthError,
|
||||||
|
firstUnansweredPrompt,
|
||||||
|
parseAuthPrompts,
|
||||||
|
parseAuthorization,
|
||||||
|
visiblePrompts,
|
||||||
|
type AuthPrompt,
|
||||||
|
type OAuthAuthorization,
|
||||||
|
} from './provider-oauth';
|
||||||
|
|
||||||
|
export interface ProviderOAuthMethod {
|
||||||
|
/** Index into the provider's full auth-method list, which is what OpenCode's `method` parameter addresses. */
|
||||||
|
index: number;
|
||||||
|
label: string;
|
||||||
|
prompts?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderOAuthMethodsProps {
|
||||||
|
providerId: string;
|
||||||
|
methods: ProviderOAuthMethod[];
|
||||||
|
/** Called once a credential has been stored, so the caller can reload providers. */
|
||||||
|
onConnected: () => void | Promise<void>;
|
||||||
|
/** Layout only — the caller owns separation from whatever sits above. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Flow =
|
||||||
|
| { phase: 'idle' }
|
||||||
|
| { phase: 'prompting'; methodIndex: number; prompts: AuthPrompt[]; error: string | null }
|
||||||
|
| { phase: 'authorizing'; methodIndex: number }
|
||||||
|
/** `auto`: the callback request is in flight and blocks until the browser sign-in finishes. */
|
||||||
|
| { phase: 'waiting'; methodIndex: number; authorization: OAuthAuthorization }
|
||||||
|
/** `code`: waiting for the user to paste a code out of the browser. */
|
||||||
|
| { phase: 'awaitingCode'; methodIndex: number; authorization: OAuthAuthorization; submitting: boolean }
|
||||||
|
| { phase: 'failed'; methodIndex: number; message: string };
|
||||||
|
|
||||||
|
const IDLE: Flow = { phase: 'idle' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth sign-in for a provider's auth methods.
|
||||||
|
*
|
||||||
|
* The completion method reported by `authorize` drives everything: `auto`
|
||||||
|
* chains straight into `callback` and holds it open until the user finishes in
|
||||||
|
* the browser, `code` collects a pasted code first. See `provider-oauth.ts`.
|
||||||
|
*
|
||||||
|
* Only one method can run at a time, and the in-flight callback is aborted when
|
||||||
|
* this component unmounts. Mount it with `key={providerId}` so switching
|
||||||
|
* providers starts from a clean flow.
|
||||||
|
*/
|
||||||
|
export const ProviderOAuthMethods: React.FC<ProviderOAuthMethodsProps> = ({
|
||||||
|
providerId,
|
||||||
|
methods,
|
||||||
|
onConnected,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [flow, setFlow] = React.useState<Flow>(IDLE);
|
||||||
|
const [promptValues, setPromptValues] = React.useState<Record<string, string>>({});
|
||||||
|
const [codeInput, setCodeInput] = React.useState('');
|
||||||
|
const callbackAbortRef = React.useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => () => callbackAbortRef.current?.abort(), []);
|
||||||
|
|
||||||
|
const activeIndex = flow.phase === 'idle' ? null : flow.methodIndex;
|
||||||
|
const busy = flow.phase === 'authorizing'
|
||||||
|
|| flow.phase === 'waiting'
|
||||||
|
|| (flow.phase === 'awaitingCode' && flow.submitting);
|
||||||
|
|
||||||
|
const copy = async (value: string, successKey: I18nKey, failureKey: I18nKey) => {
|
||||||
|
const result = await copyTextToClipboard(value);
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(t(successKey));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Failed to copy OAuth value:', result.error);
|
||||||
|
toast.error(t(failureKey));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the blocking half of the flow. Never throws: the caller has already
|
||||||
|
* handed control to the user, so a failure here is a flow state, not an
|
||||||
|
* exception to unwind.
|
||||||
|
*/
|
||||||
|
const runCallback = async (methodIndex: number, code?: string) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
callbackAbortRef.current?.abort();
|
||||||
|
callbackAbortRef.current = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await opencodeClient.getSdkClient().provider.oauth.callback(
|
||||||
|
{
|
||||||
|
providerID: providerId,
|
||||||
|
method: methodIndex,
|
||||||
|
...(code ? { code } : {}),
|
||||||
|
},
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlow(IDLE);
|
||||||
|
toast.success(t('settings.providers.page.toast.oauthCompleted'));
|
||||||
|
await onConnected();
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Failed to complete OAuth flow:', error);
|
||||||
|
setFlow({
|
||||||
|
phase: 'failed',
|
||||||
|
methodIndex,
|
||||||
|
message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthCompleteFailed'),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (callbackAbortRef.current === controller) {
|
||||||
|
callbackAbortRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runAuthorize = async (methodIndex: number, inputs: Record<string, string>) => {
|
||||||
|
setFlow({ phase: 'authorizing', methodIndex });
|
||||||
|
|
||||||
|
let authorization: OAuthAuthorization;
|
||||||
|
try {
|
||||||
|
const result = await opencodeClient.getSdkClient().provider.oauth.authorize({
|
||||||
|
providerID: providerId,
|
||||||
|
method: methodIndex,
|
||||||
|
...(Object.keys(inputs).length > 0 ? { inputs } : {}),
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseAuthorization(result.data);
|
||||||
|
if (!parsed) {
|
||||||
|
setFlow({
|
||||||
|
phase: 'failed',
|
||||||
|
methodIndex,
|
||||||
|
message: t('settings.providers.page.toast.oauthDetailsMissing'),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authorization = parsed;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start OAuth flow:', error);
|
||||||
|
setFlow({
|
||||||
|
phase: 'failed',
|
||||||
|
methodIndex,
|
||||||
|
message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthStartFailed'),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorization.url) {
|
||||||
|
void openExternalUrl(authorization.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authorization.method === 'code') {
|
||||||
|
setCodeInput('');
|
||||||
|
setFlow({ phase: 'awaitingCode', methodIndex, authorization, submitting: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlow({ phase: 'waiting', methodIndex, authorization });
|
||||||
|
await runCallback(methodIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginConnect = (method: ProviderOAuthMethod) => {
|
||||||
|
const prompts = parseAuthPrompts(method.prompts);
|
||||||
|
if (prompts.length === 0) {
|
||||||
|
void runAuthorize(method.index, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPromptValues(defaultPromptValues(prompts));
|
||||||
|
setFlow({ phase: 'prompting', methodIndex: method.index, prompts, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPrompts = () => {
|
||||||
|
if (flow.phase !== 'prompting') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const unanswered = firstUnansweredPrompt(flow.prompts, promptValues);
|
||||||
|
if (unanswered) {
|
||||||
|
setFlow({
|
||||||
|
...flow,
|
||||||
|
error: t('settings.providers.page.auth.oauth.promptRequired', { field: unanswered.message }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runAuthorize(flow.methodIndex, collectPromptInputs(flow.prompts, promptValues));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCode = () => {
|
||||||
|
if (flow.phase !== 'awaitingCode') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const code = codeInput.trim();
|
||||||
|
if (!code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFlow({ ...flow, submitting: true });
|
||||||
|
void runCallback(flow.methodIndex, code);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops tracking the attempt. Upstream keeps its pending authorization until
|
||||||
|
* a new `authorize` replaces it, so reconnecting is always safe.
|
||||||
|
*/
|
||||||
|
const cancel = () => {
|
||||||
|
callbackAbortRef.current?.abort();
|
||||||
|
callbackAbortRef.current = null;
|
||||||
|
setFlow(IDLE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPrompt = (prompt: AuthPrompt) => {
|
||||||
|
const value = promptValues[prompt.key] ?? '';
|
||||||
|
const setValue = (next: string) =>
|
||||||
|
setPromptValues((prev) => ({ ...prev, [prompt.key]: next }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={prompt.key} className="space-y-1.5">
|
||||||
|
<label className="typography-ui-label text-foreground">{prompt.message}</label>
|
||||||
|
{prompt.type === 'select' ? (
|
||||||
|
<Select value={value} onValueChange={setValue}>
|
||||||
|
<SelectTrigger size={SETTINGS_SELECT_SIZE} className={SETTINGS_SELECT_ROW_TRIGGER_CLASS}>
|
||||||
|
<SelectValue>
|
||||||
|
{(current) => prompt.options.find((option) => option.value === current)?.label ?? null}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{prompt.options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.hint ? `${option.label} · ${option.hint}` : option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
placeholder={prompt.placeholder}
|
||||||
|
className="max-w-[24rem] text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAuthorizationDetails = (authorization: OAuthAuthorization) => (
|
||||||
|
<>
|
||||||
|
{authorization.instructions && (
|
||||||
|
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
|
||||||
|
{authorization.instructions}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authorization.userCode && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={authorization.userCode}
|
||||||
|
readOnly
|
||||||
|
aria-label={t('settings.providers.page.auth.oauth.deviceCodeLabel')}
|
||||||
|
className="font-mono text-center tracking-widest"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="!font-normal shrink-0"
|
||||||
|
onClick={() => void copy(
|
||||||
|
authorization.userCode ?? '',
|
||||||
|
'settings.providers.page.toast.deviceCodeCopied',
|
||||||
|
'settings.providers.page.toast.deviceCodeCopyFailed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('settings.providers.page.actions.copyCode')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authorization.url && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={authorization.url}
|
||||||
|
readOnly
|
||||||
|
aria-label={t('settings.providers.page.auth.oauth.linkLabel')}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="!font-normal"
|
||||||
|
onClick={() => void openExternalUrl(authorization.url ?? '')}
|
||||||
|
>
|
||||||
|
{t('settings.providers.page.actions.open')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="!font-normal"
|
||||||
|
onClick={() => void copy(
|
||||||
|
authorization.url ?? '',
|
||||||
|
'settings.providers.page.toast.oauthLinkCopied',
|
||||||
|
'settings.providers.page.toast.oauthLinkCopyFailed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('settings.providers.page.actions.copy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{methods.map((method) => {
|
||||||
|
const isActive = activeIndex === method.index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={method.index} className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="typography-ui-label text-foreground">{method.label}</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="!font-normal shrink-0"
|
||||||
|
onClick={() => beginConnect(method)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{t('settings.providers.page.actions.connect')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isActive && flow.phase === 'prompting' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{visiblePrompts(flow.prompts, promptValues).map(renderPrompt)}
|
||||||
|
{flow.error && (
|
||||||
|
<p className="typography-meta text-[var(--status-error)]">{flow.error}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="xs" className="!font-normal" onClick={submitPrompts}>
|
||||||
|
{t('settings.providers.page.actions.continue')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="xs" className="!font-normal" onClick={cancel}>
|
||||||
|
{t('settings.providers.page.actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && flow.phase === 'authorizing' && (
|
||||||
|
<p className="typography-meta text-muted-foreground flex items-center gap-2">
|
||||||
|
<Icon name="loader" className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
{t('settings.providers.page.auth.oauth.starting')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && flow.phase === 'waiting' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{renderAuthorizationDetails(flow.authorization)}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="typography-meta text-muted-foreground flex items-center gap-2">
|
||||||
|
<Icon name="loader" className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
{t('settings.providers.page.auth.oauth.waiting')}
|
||||||
|
</p>
|
||||||
|
<Button variant="ghost" size="xs" className="!font-normal shrink-0" onClick={cancel}>
|
||||||
|
{t('settings.providers.page.actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
{t('settings.providers.page.auth.oauth.waitingHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && flow.phase === 'awaitingCode' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{renderAuthorizationDetails(flow.authorization)}
|
||||||
|
<p className="typography-meta text-muted-foreground">
|
||||||
|
{t('settings.providers.page.auth.oauth.codeHint')}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={codeInput}
|
||||||
|
onChange={(event) => setCodeInput(event.target.value)}
|
||||||
|
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
disabled={flow.submitting}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
className="!font-normal shrink-0"
|
||||||
|
onClick={submitCode}
|
||||||
|
disabled={flow.submitting || codeInput.trim().length === 0}
|
||||||
|
>
|
||||||
|
{flow.submitting
|
||||||
|
? t('settings.providers.page.actions.saving')
|
||||||
|
: t('settings.providers.page.actions.complete')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
className="!font-normal shrink-0"
|
||||||
|
onClick={cancel}
|
||||||
|
disabled={flow.submitting}
|
||||||
|
>
|
||||||
|
{t('settings.providers.page.actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && flow.phase === 'failed' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="typography-meta text-[var(--status-error)]">{flow.message}</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="!font-normal"
|
||||||
|
onClick={() => beginConnect(method)}
|
||||||
|
>
|
||||||
|
{t('settings.providers.page.actions.tryAgain')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,8 +19,6 @@ import { Icon } from "@/components/icon/Icon";
|
|||||||
import type { IconName } from "@/components/icon/icons";
|
import type { IconName } from "@/components/icon/icons";
|
||||||
import { noteDeferredRestartFromPayload, recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
|
import { noteDeferredRestartFromPayload, recordDeferredOpenCodeRestart } from '@/lib/opencode/deferredRestart';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
|
||||||
import { openExternalUrl } from '@/lib/url';
|
|
||||||
import type { ModelMetadata } from '@/types';
|
import type { ModelMetadata } from '@/types';
|
||||||
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
@@ -31,8 +29,10 @@ import {
|
|||||||
parseAuthPayload,
|
parseAuthPayload,
|
||||||
shouldShowApiKeyAuth,
|
shouldShowApiKeyAuth,
|
||||||
type AuthMethod,
|
type AuthMethod,
|
||||||
|
type OAuthAuthMethodEntry,
|
||||||
} from './providerAuth';
|
} from './providerAuth';
|
||||||
import { CustomProviderForm } from './CustomProviderForm';
|
import { CustomProviderForm } from './CustomProviderForm';
|
||||||
|
import { ProviderOAuthMethods, type ProviderOAuthMethod } from './ProviderOAuthMethods';
|
||||||
import {
|
import {
|
||||||
buildAuthSetRequest,
|
buildAuthSetRequest,
|
||||||
buildProviderUpsertRequest,
|
buildProviderUpsertRequest,
|
||||||
@@ -85,6 +85,16 @@ interface ProviderSources {
|
|||||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === 'object' && value !== null;
|
typeof value === 'object' && value !== null;
|
||||||
|
|
||||||
|
const toOAuthMethods = (
|
||||||
|
entries: OAuthAuthMethodEntry[],
|
||||||
|
fallbackLabel: (index: number) => string,
|
||||||
|
): ProviderOAuthMethod[] =>
|
||||||
|
entries.map(({ method, methodIndex }) => ({
|
||||||
|
index: methodIndex,
|
||||||
|
label: method.label || method.name || fallbackLabel(methodIndex),
|
||||||
|
prompts: method.prompts,
|
||||||
|
}));
|
||||||
|
|
||||||
const normalizeProviderEntry = (entry: unknown): ProviderOption | null => {
|
const normalizeProviderEntry = (entry: unknown): ProviderOption | null => {
|
||||||
if (typeof entry === 'string') {
|
if (typeof entry === 'string') {
|
||||||
return { id: entry };
|
return { id: entry };
|
||||||
@@ -147,9 +157,6 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
const [apiKeyInputs, setApiKeyInputs] = React.useState<Record<string, string>>({});
|
const [apiKeyInputs, setApiKeyInputs] = React.useState<Record<string, string>>({});
|
||||||
const [authBusyKey, setAuthBusyKey] = React.useState<string | null>(null);
|
const [authBusyKey, setAuthBusyKey] = React.useState<string | null>(null);
|
||||||
const [modelQuery, setModelQuery] = React.useState('');
|
const [modelQuery, setModelQuery] = React.useState('');
|
||||||
const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null);
|
|
||||||
const [oauthCodes, setOauthCodes] = React.useState<Record<string, string>>({});
|
|
||||||
const [oauthDetails, setOauthDetails] = React.useState<Record<string, { url?: string; instructions?: string; userCode?: string }>>({});
|
|
||||||
const [availableProviders, setAvailableProviders] = React.useState<ProviderOption[]>([]);
|
const [availableProviders, setAvailableProviders] = React.useState<ProviderOption[]>([]);
|
||||||
const [availableLoading, setAvailableLoading] = React.useState(false);
|
const [availableLoading, setAvailableLoading] = React.useState(false);
|
||||||
const [availableError, setAvailableError] = React.useState<string | null>(null);
|
const [availableError, setAvailableError] = React.useState<string | null>(null);
|
||||||
@@ -181,7 +188,8 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Auth methods drive which credential UI to show (API key vs OAuth). Keep
|
// Auth methods drive which credential UI to show (API key vs OAuth). Keep
|
||||||
// them loaded for the active provider view so OAuth-only plugins never fall
|
// them loaded for the active provider view so OAuth-only plugins never fall
|
||||||
// back to an API key form merely because methods were never fetched.
|
// back to an API key form merely because methods were never fetched, and so
|
||||||
|
// an already-listed provider can still offer re-authentication.
|
||||||
if (!selectedProviderId) {
|
if (!selectedProviderId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -458,117 +466,13 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOAuthStart = async (providerId: string, methodIndex: number) => {
|
const oauthMethodFallbackLabel = (index: number) =>
|
||||||
const busyKey = `oauth:${providerId}:${methodIndex}`;
|
t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) });
|
||||||
setAuthBusyKey(busyKey);
|
|
||||||
|
|
||||||
try {
|
const handleOAuthConnected = (providerId: string) => {
|
||||||
const result = await opencodeClient.getSdkClient().provider.oauth.authorize({
|
setShowAuthPanel(false);
|
||||||
providerID: providerId,
|
recordDeferredOpenCodeRestart('providers', { id: providerId });
|
||||||
method: methodIndex,
|
setSelectedProvider(providerId);
|
||||||
});
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(t('settings.providers.page.toast.oauthStartFailed'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadRecord: Record<string, unknown> = isRecord(result.data) ? result.data : {};
|
|
||||||
const nestedData = payloadRecord.data;
|
|
||||||
const dataRecord: Record<string, unknown> = isRecord(nestedData) ? nestedData : payloadRecord;
|
|
||||||
const urlCandidate =
|
|
||||||
(typeof dataRecord.url === 'string' && dataRecord.url) ||
|
|
||||||
(typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) ||
|
|
||||||
(typeof dataRecord.verification_uri === 'string' && dataRecord.verification_uri) ||
|
|
||||||
undefined;
|
|
||||||
const instructions =
|
|
||||||
(typeof dataRecord.instructions === 'string' && dataRecord.instructions) ||
|
|
||||||
(typeof dataRecord.message === 'string' && dataRecord.message) ||
|
|
||||||
undefined;
|
|
||||||
const userCode =
|
|
||||||
(typeof dataRecord.user_code === 'string' && dataRecord.user_code) ||
|
|
||||||
(typeof dataRecord.code === 'string' && dataRecord.code) ||
|
|
||||||
(typeof dataRecord.userCode === 'string' && dataRecord.userCode) ||
|
|
||||||
undefined;
|
|
||||||
|
|
||||||
if (!urlCandidate && !instructions && !userCode) {
|
|
||||||
throw new Error(t('settings.providers.page.toast.oauthDetailsMissing'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const detailsKey = `${providerId}:${methodIndex}`;
|
|
||||||
setOauthDetails((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[detailsKey]: {
|
|
||||||
url: urlCandidate,
|
|
||||||
instructions,
|
|
||||||
userCode,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (urlCandidate) {
|
|
||||||
void openExternalUrl(urlCandidate);
|
|
||||||
}
|
|
||||||
setPendingOAuth({ providerId, methodIndex });
|
|
||||||
toast.message(t('settings.providers.page.toast.completeOAuthInBrowser'));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to start OAuth flow:', error);
|
|
||||||
toast.error(t('settings.providers.page.toast.oauthStartFailed'));
|
|
||||||
} finally {
|
|
||||||
setAuthBusyKey(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOAuthComplete = async (providerId: string, methodIndex: number) => {
|
|
||||||
const codeKey = `${providerId}:${methodIndex}`;
|
|
||||||
const code = oauthCodes[codeKey]?.trim();
|
|
||||||
|
|
||||||
const busyKey = `oauth-complete:${providerId}:${methodIndex}`;
|
|
||||||
setAuthBusyKey(busyKey);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const requestBody: { method: number; code?: string } = { method: methodIndex };
|
|
||||||
if (code) {
|
|
||||||
requestBody.code = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await opencodeClient.getSdkClient().provider.oauth.callback({
|
|
||||||
providerID: providerId,
|
|
||||||
method: requestBody.method,
|
|
||||||
code: requestBody.code,
|
|
||||||
});
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(t('settings.providers.page.toast.oauthCompleteFailed'));
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(t('settings.providers.page.toast.oauthCompleted'));
|
|
||||||
setOauthCodes((prev) => ({ ...prev, [codeKey]: '' }));
|
|
||||||
setPendingOAuth(null);
|
|
||||||
recordDeferredOpenCodeRestart('providers', { id: providerId });
|
|
||||||
setSelectedProvider(providerId);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to complete OAuth flow:', error);
|
|
||||||
toast.error(t('settings.providers.page.toast.oauthCompleteFailed'));
|
|
||||||
} finally {
|
|
||||||
setAuthBusyKey(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyOAuthLink = async (url: string) => {
|
|
||||||
const result = await copyTextToClipboard(url);
|
|
||||||
if (result.ok) {
|
|
||||||
toast.success(t('settings.providers.page.toast.oauthLinkCopied'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error('Failed to copy OAuth link:', result.error);
|
|
||||||
toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyOAuthCode = async (code: string) => {
|
|
||||||
const result = await copyTextToClipboard(code);
|
|
||||||
if (result.ok) {
|
|
||||||
toast.success(t('settings.providers.page.toast.deviceCodeCopied'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error('Failed to copy device code:', result.error);
|
|
||||||
toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed'));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDisconnectProvider = async (providerId: string) => {
|
const handleDisconnectProvider = async (providerId: string) => {
|
||||||
@@ -779,7 +683,10 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
{(() => {
|
{(() => {
|
||||||
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
|
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
|
||||||
const candidateOAuthMethods = getOAuthAuthMethods(candidateAuthMethods);
|
const candidateOAuthMethods = toOAuthMethods(
|
||||||
|
getOAuthAuthMethods(candidateAuthMethods),
|
||||||
|
oauthMethodFallbackLabel,
|
||||||
|
);
|
||||||
const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods);
|
const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -816,85 +723,13 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{candidateOAuthMethods.length > 0 ? (
|
{candidateOAuthMethods.length > 0 ? (
|
||||||
<div className={cn('space-y-4', showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}>
|
<ProviderOAuthMethods
|
||||||
{candidateOAuthMethods.map(({ method, methodIndex }) => {
|
key={candidateProviderId}
|
||||||
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
|
providerId={candidateProviderId}
|
||||||
const codeKey = `${candidateProviderId}:${methodIndex}`;
|
methods={candidateOAuthMethods}
|
||||||
const isPending =
|
onConnected={() => handleOAuthConnected(candidateProviderId)}
|
||||||
pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex;
|
className={cn(showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')}
|
||||||
|
/>
|
||||||
return (
|
|
||||||
<div key={`${candidateProviderId}-${methodIndex}-${methodLabel}`} className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
|
||||||
{(method.description || method.help) && (
|
|
||||||
<div className="typography-meta text-muted-foreground">
|
|
||||||
{String(method.description || method.help)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="xs"
|
|
||||||
className="!font-normal"
|
|
||||||
onClick={() => handleOAuthStart(candidateProviderId, methodIndex)}
|
|
||||||
disabled={authBusyKey === `oauth:${candidateProviderId}:${methodIndex}`}
|
|
||||||
>
|
|
||||||
{t('settings.providers.page.actions.connect')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{oauthDetails[codeKey]?.instructions && (
|
|
||||||
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
|
|
||||||
{oauthDetails[codeKey]?.instructions}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{oauthDetails[codeKey]?.userCode && (
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
|
||||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{oauthDetails[codeKey]?.url && (
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
|
||||||
<div className="flex gap-1 shrink-0">
|
|
||||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
|
|
||||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isPending && (
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Input
|
|
||||||
value={oauthCodes[codeKey] ?? ''}
|
|
||||||
onChange={(event) =>
|
|
||||||
setOauthCodes((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[codeKey]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
className="!font-normal"
|
|
||||||
onClick={() => handleOAuthComplete(candidateProviderId, methodIndex)}
|
|
||||||
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}`}
|
|
||||||
>
|
|
||||||
{authBusyKey === `oauth-complete:${candidateProviderId}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -921,7 +756,10 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
|
|
||||||
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : [];
|
||||||
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? [];
|
||||||
const oauthAuthMethods = getOAuthAuthMethods(providerAuthMethods);
|
const oauthAuthMethods = toOAuthMethods(
|
||||||
|
getOAuthAuthMethods(providerAuthMethods),
|
||||||
|
oauthMethodFallbackLabel,
|
||||||
|
);
|
||||||
const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods);
|
const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods);
|
||||||
const sourcesLoaded = Boolean(selectedSources);
|
const sourcesLoaded = Boolean(selectedSources);
|
||||||
const isEditableCustomProvider = sourcesLoaded
|
const isEditableCustomProvider = sourcesLoaded
|
||||||
@@ -1064,85 +902,13 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{oauthAuthMethods.length > 0 && (
|
{oauthAuthMethods.length > 0 && (
|
||||||
<div className={cn('space-y-4', showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}>
|
<ProviderOAuthMethods
|
||||||
{oauthAuthMethods.map(({ method, methodIndex }) => {
|
key={selectedProvider.id}
|
||||||
const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) });
|
providerId={selectedProvider.id}
|
||||||
const codeKey = `${selectedProvider.id}:${methodIndex}`;
|
methods={oauthAuthMethods}
|
||||||
const isPending =
|
onConnected={() => handleOAuthConnected(selectedProvider.id)}
|
||||||
pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === methodIndex;
|
className={cn(showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')}
|
||||||
|
/>
|
||||||
return (
|
|
||||||
<div key={`${selectedProvider.id}-${methodIndex}-${methodLabel}`} className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<div className="typography-ui-label text-foreground">{methodLabel}</div>
|
|
||||||
{(method.description || method.help) && (
|
|
||||||
<div className="typography-meta text-muted-foreground">
|
|
||||||
{String(method.description || method.help)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="xs"
|
|
||||||
className="!font-normal"
|
|
||||||
onClick={() => handleOAuthStart(selectedProvider.id, methodIndex)}
|
|
||||||
disabled={authBusyKey === `oauth:${selectedProvider.id}:${methodIndex}`}
|
|
||||||
>
|
|
||||||
{t('settings.providers.page.actions.connect')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{oauthDetails[codeKey]?.instructions && (
|
|
||||||
<p className="typography-meta text-[var(--primary-base)] bg-[var(--primary-base)]/10 px-2 py-1.5 rounded">
|
|
||||||
{oauthDetails[codeKey]?.instructions}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{oauthDetails[codeKey]?.userCode && (
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Input value={oauthDetails[codeKey]?.userCode} readOnly className="font-mono text-center tracking-widest" />
|
|
||||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}>{t('settings.providers.page.actions.copyCode')}</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{oauthDetails[codeKey]?.url && (
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Input value={oauthDetails[codeKey]?.url} readOnly className="text-xs text-muted-foreground" />
|
|
||||||
<div className="flex gap-1 shrink-0">
|
|
||||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => openExternalUrl(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.open')}</Button>
|
|
||||||
<Button variant="outline" size="xs" className="!font-normal" onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}>{t('settings.providers.page.actions.copy')}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isPending && (
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<Input
|
|
||||||
value={oauthCodes[codeKey] ?? ''}
|
|
||||||
onChange={(event) =>
|
|
||||||
setOauthCodes((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[codeKey]: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
className="!font-normal"
|
|
||||||
onClick={() => handleOAuthComplete(selectedProvider.id, methodIndex)}
|
|
||||||
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${methodIndex}`}
|
|
||||||
>
|
|
||||||
{authBusyKey === `oauth-complete:${selectedProvider.id}:${methodIndex}` ? t('settings.providers.page.actions.saving') : t('settings.providers.page.actions.complete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import {
|
||||||
|
collectPromptInputs,
|
||||||
|
defaultPromptValues,
|
||||||
|
describeOAuthError,
|
||||||
|
firstUnansweredPrompt,
|
||||||
|
isPromptVisible,
|
||||||
|
parseAuthPrompts,
|
||||||
|
parseAuthorization,
|
||||||
|
visiblePrompts,
|
||||||
|
type AuthPrompt,
|
||||||
|
type ProviderOAuthTranslator,
|
||||||
|
} from './provider-oauth';
|
||||||
|
|
||||||
|
/** Mirrors the github-copilot auth method shipped by OpenCode. */
|
||||||
|
const copilotPrompts = [
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
key: 'deploymentType',
|
||||||
|
message: 'Select GitHub deployment type',
|
||||||
|
options: [
|
||||||
|
{ label: 'GitHub.com', value: 'github.com', hint: 'Public' },
|
||||||
|
{ label: 'GitHub Enterprise', value: 'enterprise' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
key: 'enterpriseUrl',
|
||||||
|
message: 'Enter your GitHub Enterprise URL or domain',
|
||||||
|
placeholder: 'company.ghe.com',
|
||||||
|
when: { key: 'deploymentType', op: 'eq', value: 'enterprise' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('parseAuthPrompts', () => {
|
||||||
|
test('parses select and conditional text prompts', () => {
|
||||||
|
const prompts = parseAuthPrompts(copilotPrompts);
|
||||||
|
|
||||||
|
expect(prompts).toHaveLength(2);
|
||||||
|
expect(prompts[0]).toEqual({
|
||||||
|
type: 'select',
|
||||||
|
key: 'deploymentType',
|
||||||
|
message: 'Select GitHub deployment type',
|
||||||
|
options: [
|
||||||
|
{ value: 'github.com', label: 'GitHub.com', hint: 'Public' },
|
||||||
|
{ value: 'enterprise', label: 'GitHub Enterprise' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(prompts[1]).toEqual({
|
||||||
|
type: 'text',
|
||||||
|
key: 'enterpriseUrl',
|
||||||
|
message: 'Enter your GitHub Enterprise URL or domain',
|
||||||
|
options: [],
|
||||||
|
placeholder: 'company.ghe.com',
|
||||||
|
when: { key: 'deploymentType', op: 'eq', value: 'enterprise' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns an empty list for a method without prompts', () => {
|
||||||
|
expect(parseAuthPrompts(undefined)).toEqual([]);
|
||||||
|
expect(parseAuthPrompts(null)).toEqual([]);
|
||||||
|
expect(parseAuthPrompts({})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops entries that could never be answered', () => {
|
||||||
|
const prompts = parseAuthPrompts([
|
||||||
|
{ type: 'text', message: 'no key' },
|
||||||
|
{ type: 'select', key: 'empty', message: 'no options', options: [] },
|
||||||
|
{ type: 'text', key: 'keep', message: 'keep me' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(prompts.map((prompt) => prompt.key)).toEqual(['keep']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the key when a message is missing', () => {
|
||||||
|
expect(parseAuthPrompts([{ type: 'text', key: 'token' }])[0]?.message).toBe('token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores a malformed when condition instead of hiding the prompt', () => {
|
||||||
|
const [prompt] = parseAuthPrompts([
|
||||||
|
{ type: 'text', key: 'url', message: 'URL', when: { key: 'other', op: 'contains', value: 'x' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(prompt.when).toBe(undefined);
|
||||||
|
expect(isPromptVisible(prompt, {})).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prompt visibility', () => {
|
||||||
|
const prompts = parseAuthPrompts(copilotPrompts);
|
||||||
|
|
||||||
|
test('hides a conditional prompt until its branch is selected', () => {
|
||||||
|
expect(visiblePrompts(prompts, { deploymentType: 'github.com' }).map((p) => p.key))
|
||||||
|
.toEqual(['deploymentType']);
|
||||||
|
expect(visiblePrompts(prompts, { deploymentType: 'enterprise' }).map((p) => p.key))
|
||||||
|
.toEqual(['deploymentType', 'enterpriseUrl']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports neq conditions', () => {
|
||||||
|
const prompt: AuthPrompt = {
|
||||||
|
type: 'text',
|
||||||
|
key: 'custom',
|
||||||
|
message: 'Custom',
|
||||||
|
options: [],
|
||||||
|
when: { key: 'mode', op: 'neq', value: 'default' },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isPromptVisible(prompt, { mode: 'default' })).toBe(false);
|
||||||
|
expect(isPromptVisible(prompt, { mode: 'other' })).toBe(true);
|
||||||
|
expect(isPromptVisible(prompt, {})).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prompt answers', () => {
|
||||||
|
const prompts = parseAuthPrompts(copilotPrompts);
|
||||||
|
|
||||||
|
test('preselects the first select option so the form starts answerable', () => {
|
||||||
|
expect(defaultPromptValues(prompts)).toEqual({ deploymentType: 'github.com', enterpriseUrl: '' });
|
||||||
|
expect(firstUnansweredPrompt(prompts, defaultPromptValues(prompts))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports the hidden-then-revealed field as unanswered', () => {
|
||||||
|
const values = { deploymentType: 'enterprise', enterpriseUrl: ' ' };
|
||||||
|
|
||||||
|
expect(firstUnansweredPrompt(prompts, values)?.key).toBe('enterpriseUrl');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omits answers whose prompt is no longer visible', () => {
|
||||||
|
const values = { deploymentType: 'github.com', enterpriseUrl: 'left-over.ghe.com' };
|
||||||
|
|
||||||
|
expect(collectPromptInputs(prompts, values)).toEqual({ deploymentType: 'github.com' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trims submitted answers', () => {
|
||||||
|
const values = { deploymentType: 'enterprise', enterpriseUrl: ' company.ghe.com ' };
|
||||||
|
|
||||||
|
expect(collectPromptInputs(prompts, values)).toEqual({
|
||||||
|
deploymentType: 'enterprise',
|
||||||
|
enterpriseUrl: 'company.ghe.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseAuthorization', () => {
|
||||||
|
test('reads a device-code authorization and recovers the code from instructions', () => {
|
||||||
|
const authorization = parseAuthorization({
|
||||||
|
url: 'https://github.com/login/device',
|
||||||
|
instructions: 'Enter code: 1A2B-3C4D',
|
||||||
|
method: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(authorization).toEqual({
|
||||||
|
method: 'auto',
|
||||||
|
url: 'https://github.com/login/device',
|
||||||
|
instructions: 'Enter code: 1A2B-3C4D',
|
||||||
|
userCode: '1A2B-3C4D',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps an explicitly reported code over the instructions match', () => {
|
||||||
|
expect(parseAuthorization({
|
||||||
|
url: 'https://example.com',
|
||||||
|
instructions: 'Enter code: AAAA-BBBB',
|
||||||
|
user_code: 'ZZZZ-9999',
|
||||||
|
method: 'auto',
|
||||||
|
})?.userCode).toBe('ZZZZ-9999');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves the code method', () => {
|
||||||
|
expect(parseAuthorization({ url: 'https://example.com', method: 'code' })?.method).toBe('code');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats a missing or unknown method as auto', () => {
|
||||||
|
expect(parseAuthorization({ url: 'https://example.com' })?.method).toBe('auto');
|
||||||
|
expect(parseAuthorization({ url: 'https://example.com', method: 'device' })?.method).toBe('auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unwraps a nested data envelope', () => {
|
||||||
|
expect(parseAuthorization({ data: { url: 'https://example.com', method: 'code' } })).toEqual({
|
||||||
|
method: 'code',
|
||||||
|
url: 'https://example.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts device-authorization field names', () => {
|
||||||
|
expect(parseAuthorization({
|
||||||
|
verification_uri_complete: 'https://example.com/activate?code=1',
|
||||||
|
message: 'Open the link',
|
||||||
|
})).toEqual({
|
||||||
|
method: 'auto',
|
||||||
|
url: 'https://example.com/activate?code=1',
|
||||||
|
instructions: 'Open the link',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when nothing is actionable', () => {
|
||||||
|
expect(parseAuthorization(null)).toBeNull();
|
||||||
|
expect(parseAuthorization({})).toBeNull();
|
||||||
|
expect(parseAuthorization({ method: 'auto' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeOAuthError', () => {
|
||||||
|
const t: ProviderOAuthTranslator = (key) => key;
|
||||||
|
const fallback = 'settings.providers.page.toast.oauthCompleteFailed';
|
||||||
|
|
||||||
|
/** Names come from OpenCode's ProviderAuthApiError schema. */
|
||||||
|
test('maps each provider auth error name to its own message', () => {
|
||||||
|
expect(describeOAuthError({ name: 'ProviderAuthOauthMissing', data: {} }, t, fallback))
|
||||||
|
.toBe('settings.providers.page.auth.oauth.error.sessionExpired');
|
||||||
|
expect(describeOAuthError({ name: 'ProviderAuthOauthCodeMissing', data: {} }, t, fallback))
|
||||||
|
.toBe('settings.providers.page.auth.oauth.error.codeRequired');
|
||||||
|
expect(describeOAuthError({ name: 'ProviderAuthOauthCallbackFailed', data: {} }, t, fallback))
|
||||||
|
.toBe('settings.providers.page.auth.oauth.error.declined');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('surfaces the plugin-authored validation message verbatim', () => {
|
||||||
|
const error = {
|
||||||
|
name: 'ProviderAuthValidationFailed',
|
||||||
|
data: { field: 'enterpriseUrl', message: 'URL or domain is required' },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(describeOAuthError(error, t, fallback)).toBe('URL or domain is required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back when a validation failure carries no message', () => {
|
||||||
|
expect(describeOAuthError({ name: 'ProviderAuthValidationFailed', data: {} }, t, fallback))
|
||||||
|
.toBe('settings.providers.page.auth.oauth.error.invalidInput');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back for unknown, empty, and non-object errors', () => {
|
||||||
|
expect(describeOAuthError({ name: 'BadRequest', data: {} }, t, fallback)).toBe(fallback);
|
||||||
|
expect(describeOAuthError(new Error('network down'), t, fallback)).toBe(fallback);
|
||||||
|
expect(describeOAuthError(undefined, t, fallback)).toBe(fallback);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Provider OAuth flow helpers.
|
||||||
|
*
|
||||||
|
* `POST /provider/{id}/oauth/authorize` answers with the completion method that
|
||||||
|
* decides what the client has to do next:
|
||||||
|
*
|
||||||
|
* - `auto` — the client must call `oauth/callback` right away and hold that
|
||||||
|
* request open. Upstream blocks inside it (device-code polling, or waiting on
|
||||||
|
* a loopback redirect) until the user finishes signing in, and only then
|
||||||
|
* persists the credential. Nothing is stored if the client never calls it.
|
||||||
|
* - `code` — the user copies a code out of the browser and hands it to
|
||||||
|
* `oauth/callback`.
|
||||||
|
*
|
||||||
|
* Every auth plugin shipped with OpenCode uses `auto`; `code` stays supported
|
||||||
|
* for third-party auth plugins that still return it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { I18nKey, I18nParams } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export type OAuthCompletionMethod = 'auto' | 'code';
|
||||||
|
|
||||||
|
export type ProviderOAuthTranslator = (key: I18nKey, params?: I18nParams) => string;
|
||||||
|
|
||||||
|
export interface OAuthAuthorization {
|
||||||
|
method: OAuthCompletionMethod;
|
||||||
|
url?: string;
|
||||||
|
instructions?: string;
|
||||||
|
/** Device code surfaced separately so it can be copied on its own. */
|
||||||
|
userCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthPromptOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthPromptCondition {
|
||||||
|
key: string;
|
||||||
|
op: 'eq' | 'neq';
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthPrompt {
|
||||||
|
type: 'text' | 'select';
|
||||||
|
key: string;
|
||||||
|
message: string;
|
||||||
|
placeholder?: string;
|
||||||
|
options: AuthPromptOption[];
|
||||||
|
when?: AuthPromptCondition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device codes are only carried inside the human-readable instructions
|
||||||
|
* (`Enter code: ABCD-1234`), so they are recovered by shape.
|
||||||
|
*/
|
||||||
|
const DEVICE_CODE_PATTERN = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/;
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const asText = (value: unknown): string | undefined =>
|
||||||
|
typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||||
|
|
||||||
|
const parsePromptOptions = (value: unknown): AuthPromptOption[] => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const options: AuthPromptOption[] = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!isRecord(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const optionValue = asText(entry.value);
|
||||||
|
if (optionValue === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
options.push({
|
||||||
|
value: optionValue,
|
||||||
|
label: asText(entry.label) ?? optionValue,
|
||||||
|
...(asText(entry.hint) ? { hint: asText(entry.hint)! } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsePromptCondition = (value: unknown): AuthPromptCondition | undefined => {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const key = asText(value.key);
|
||||||
|
const op = value.op === 'eq' || value.op === 'neq' ? value.op : undefined;
|
||||||
|
if (!key || !op || typeof value.value !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { key, op, value: value.value };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parses the `prompts` an auth method wants answered before `authorize`. */
|
||||||
|
export const parseAuthPrompts = (value: unknown): AuthPrompt[] => {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const prompts: AuthPrompt[] = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
if (!isRecord(entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = asText(entry.key);
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const type = entry.type === 'select' ? 'select' : 'text';
|
||||||
|
const options = type === 'select' ? parsePromptOptions(entry.options) : [];
|
||||||
|
// A select with no usable option can never be answered; skipping it would
|
||||||
|
// silently drop a required input, so treat the whole method as unusable.
|
||||||
|
if (type === 'select' && options.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const when = parsePromptCondition(entry.when);
|
||||||
|
prompts.push({
|
||||||
|
type,
|
||||||
|
key,
|
||||||
|
message: asText(entry.message) ?? key,
|
||||||
|
options,
|
||||||
|
...(asText(entry.placeholder) ? { placeholder: asText(entry.placeholder)! } : {}),
|
||||||
|
...(when ? { when } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return prompts;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when a prompt's `when` condition is satisfied by the answers so far. */
|
||||||
|
export const isPromptVisible = (prompt: AuthPrompt, values: Record<string, string>): boolean => {
|
||||||
|
if (!prompt.when) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const current = values[prompt.when.key] ?? '';
|
||||||
|
return prompt.when.op === 'eq'
|
||||||
|
? current === prompt.when.value
|
||||||
|
: current !== prompt.when.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const visiblePrompts = (
|
||||||
|
prompts: AuthPrompt[],
|
||||||
|
values: Record<string, string>,
|
||||||
|
): AuthPrompt[] => prompts.filter((prompt) => isPromptVisible(prompt, values));
|
||||||
|
|
||||||
|
/** Selects preselect their first option so the form always starts answerable. */
|
||||||
|
export const defaultPromptValues = (prompts: AuthPrompt[]): Record<string, string> => {
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
for (const prompt of prompts) {
|
||||||
|
values[prompt.key] = prompt.type === 'select' ? (prompt.options[0]?.value ?? '') : '';
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** First visible prompt still left blank, or `null` when the form is complete. */
|
||||||
|
export const firstUnansweredPrompt = (
|
||||||
|
prompts: AuthPrompt[],
|
||||||
|
values: Record<string, string>,
|
||||||
|
): AuthPrompt | null =>
|
||||||
|
visiblePrompts(prompts, values).find((prompt) => (values[prompt.key] ?? '').trim().length === 0) ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the `inputs` payload for `authorize`. Hidden prompts are dropped so a
|
||||||
|
* stale answer from a since-changed branch is never sent upstream.
|
||||||
|
*/
|
||||||
|
export const collectPromptInputs = (
|
||||||
|
prompts: AuthPrompt[],
|
||||||
|
values: Record<string, string>,
|
||||||
|
): Record<string, string> => {
|
||||||
|
const inputs: Record<string, string> = {};
|
||||||
|
for (const prompt of visiblePrompts(prompts, values)) {
|
||||||
|
inputs[prompt.key] = (values[prompt.key] ?? '').trim();
|
||||||
|
}
|
||||||
|
return inputs;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes an `authorize` response.
|
||||||
|
*
|
||||||
|
* Anything that is not explicitly `code` is treated as `auto`: `auto` only
|
||||||
|
* means "call back and wait", which is also the safe reading of an unknown
|
||||||
|
* method, whereas guessing `code` would strand the user at a paste field no
|
||||||
|
* provider can fill.
|
||||||
|
*
|
||||||
|
* Returns `null` when the response carries nothing the user can act on.
|
||||||
|
*/
|
||||||
|
export const parseAuthorization = (payload: unknown): OAuthAuthorization | null => {
|
||||||
|
const outer: Record<string, unknown> = isRecord(payload) ? payload : {};
|
||||||
|
const record: Record<string, unknown> = isRecord(outer.data) ? outer.data : outer;
|
||||||
|
|
||||||
|
const url =
|
||||||
|
asText(record.url)
|
||||||
|
?? asText(record.verification_uri_complete)
|
||||||
|
?? asText(record.verification_uri);
|
||||||
|
const instructions = asText(record.instructions) ?? asText(record.message);
|
||||||
|
|
||||||
|
if (!url && !instructions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userCode =
|
||||||
|
asText(record.user_code)
|
||||||
|
?? asText(record.userCode)
|
||||||
|
?? (instructions ? DEVICE_CODE_PATTERN.exec(instructions)?.[0] : undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
method: record.method === 'code' ? 'code' : 'auto',
|
||||||
|
...(url ? { url } : {}),
|
||||||
|
...(instructions ? { instructions } : {}),
|
||||||
|
...(userCode ? { userCode } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a `ProviderAuthApiError` as user-facing copy.
|
||||||
|
*
|
||||||
|
* Validation failures carry a message authored by the auth plugin (a field
|
||||||
|
* rule such as "URL or domain is required"); it is shown verbatim because only
|
||||||
|
* the plugin knows which input was rejected.
|
||||||
|
*/
|
||||||
|
export const describeOAuthError = (
|
||||||
|
error: unknown,
|
||||||
|
t: ProviderOAuthTranslator,
|
||||||
|
fallbackKey: I18nKey,
|
||||||
|
): string => {
|
||||||
|
const record: Record<string, unknown> = isRecord(error) ? error : {};
|
||||||
|
const data: Record<string, unknown> = isRecord(record.data) ? record.data : {};
|
||||||
|
|
||||||
|
switch (record.name) {
|
||||||
|
case 'ProviderAuthOauthMissing':
|
||||||
|
return t('settings.providers.page.auth.oauth.error.sessionExpired');
|
||||||
|
case 'ProviderAuthOauthCodeMissing':
|
||||||
|
return t('settings.providers.page.auth.oauth.error.codeRequired');
|
||||||
|
case 'ProviderAuthOauthCallbackFailed':
|
||||||
|
return t('settings.providers.page.auth.oauth.error.declined');
|
||||||
|
case 'ProviderAuthValidationFailed':
|
||||||
|
return asText(data.message) ?? t('settings.providers.page.auth.oauth.error.invalidInput');
|
||||||
|
default:
|
||||||
|
return t(fallbackKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,6 +5,8 @@ export interface AuthMethod {
|
|||||||
description?: string;
|
description?: string;
|
||||||
help?: string;
|
help?: string;
|
||||||
method?: number;
|
method?: number;
|
||||||
|
/** Inputs an OAuth method wants answered before authorize; see `provider-oauth.ts`. */
|
||||||
|
prompts?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
isFilesystemError,
|
||||||
|
type FilesystemErrorReason,
|
||||||
|
} from '@/lib/api/files-errors';
|
||||||
|
|
||||||
interface DirectoryExplorerDialogProps {
|
interface DirectoryExplorerDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -148,7 +152,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
const loadGitIdentityProfiles = useGitIdentitiesStore((s) => s.loadProfiles);
|
const loadGitIdentityProfiles = useGitIdentitiesStore((s) => s.loadProfiles);
|
||||||
const loadGlobalGitIdentity = useGitIdentitiesStore((s) => s.loadGlobalIdentity);
|
const loadGlobalGitIdentity = useGitIdentitiesStore((s) => s.loadGlobalIdentity);
|
||||||
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
const loadDefaultGitIdentityId = useGitIdentitiesStore((s) => s.loadDefaultGitIdentityId);
|
||||||
const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess();
|
const { canRequestAccess, requestAccess, startAccessing } = useFileSystemAccess();
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const addButtonRef = React.useRef<HTMLButtonElement>(null);
|
const addButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||||
@@ -158,6 +162,8 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
const [entries, setEntries] = React.useState<BrowseEntry[]>([]);
|
const [entries, setEntries] = React.useState<BrowseEntry[]>([]);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
const [isBrowseDirectoryMissing, setIsBrowseDirectoryMissing] = React.useState(false);
|
const [isBrowseDirectoryMissing, setIsBrowseDirectoryMissing] = React.useState(false);
|
||||||
|
const [browseErrorReason, setBrowseErrorReason] = React.useState<FilesystemErrorReason | null>(null);
|
||||||
|
const [browseReloadKey, setBrowseReloadKey] = React.useState(0);
|
||||||
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
|
const [highlightedIndex, setHighlightedIndex] = React.useState(0);
|
||||||
const [isConfirming, setIsConfirming] = React.useState(false);
|
const [isConfirming, setIsConfirming] = React.useState(false);
|
||||||
const [isOpeningFinder, setIsOpeningFinder] = React.useState(false);
|
const [isOpeningFinder, setIsOpeningFinder] = React.useState(false);
|
||||||
@@ -250,16 +256,19 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open || !browseDirectoryAbsolutePath) {
|
if (!open || !browseDirectoryAbsolutePath) {
|
||||||
setEntries([]);
|
setEntries([]);
|
||||||
|
setBrowseErrorReason(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setIsBrowseDirectoryMissing(false);
|
setIsBrowseDirectoryMissing(false);
|
||||||
|
setBrowseErrorReason(null);
|
||||||
opencodeClient.listLocalDirectory(browseDirectoryAbsolutePath)
|
opencodeClient.listLocalDirectory(browseDirectoryAbsolutePath)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setIsBrowseDirectoryMissing(false);
|
setIsBrowseDirectoryMissing(false);
|
||||||
|
setBrowseErrorReason(null);
|
||||||
const nextEntries = result
|
const nextEntries = result
|
||||||
.filter((entry) => entry.isDirectory)
|
.filter((entry) => entry.isDirectory)
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
@@ -269,10 +278,12 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
.sort((left, right) => left.name.localeCompare(right.name));
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
setEntries(nextEntries);
|
setEntries(nextEntries);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setEntries([]);
|
setEntries([]);
|
||||||
setIsBrowseDirectoryMissing(true);
|
const reason = isFilesystemError(error) ? error.reason : 'unknown';
|
||||||
|
setBrowseErrorReason(reason);
|
||||||
|
setIsBrowseDirectoryMissing(reason === 'not-found');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -282,7 +293,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [browseDirectoryAbsolutePath, open]);
|
}, [browseDirectoryAbsolutePath, browseReloadKey, open]);
|
||||||
|
|
||||||
const filteredEntries = React.useMemo(() => {
|
const filteredEntries = React.useMemo(() => {
|
||||||
const lowerFilter = browseFilterQuery.toLowerCase();
|
const lowerFilter = browseFilterQuery.toLowerCase();
|
||||||
@@ -327,12 +338,19 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
const shouldCreateTarget = Boolean(
|
const shouldCreateTarget = Boolean(
|
||||||
targetPath
|
targetPath
|
||||||
&& !isAlreadyAdded
|
&& !isAlreadyAdded
|
||||||
|
&& (browseErrorReason === null || browseErrorReason === 'not-found')
|
||||||
&& (
|
&& (
|
||||||
(hasTrailingPathSeparator(query) && isBrowseDirectoryMissing)
|
(hasTrailingPathSeparator(query) && isBrowseDirectoryMissing)
|
||||||
|| (!hasTrailingPathSeparator(query) && browseFilterQuery.trim().length > 0 && exactEntry === null)
|
|| (!hasTrailingPathSeparator(query) && browseFilterQuery.trim().length > 0 && exactEntry === null)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const canAddProject = !isConfirming && !isOpeningFinder && !isAlreadyAdded && Boolean(targetPath);
|
const canAddProject = !isConfirming
|
||||||
|
&& !isOpeningFinder
|
||||||
|
&& !isAlreadyAdded
|
||||||
|
&& browseErrorReason !== 'os-permission'
|
||||||
|
&& browseErrorReason !== 'invalid-response'
|
||||||
|
&& browseErrorReason !== 'unknown'
|
||||||
|
&& Boolean(targetPath);
|
||||||
const canSubmitClone = canAddProject && cloneRemoteUrl.trim().length > 0;
|
const canSubmitClone = canAddProject && cloneRemoteUrl.trim().length > 0;
|
||||||
const highlightedRow = rows[highlightedIndex] ?? null;
|
const highlightedRow = rows[highlightedIndex] ?? null;
|
||||||
const hasHighlightedBrowseItem = Boolean(
|
const hasHighlightedBrowseItem = Boolean(
|
||||||
@@ -459,7 +477,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
}, [browseToDisplayPath, browseToEntry]);
|
}, [browseToDisplayPath, browseToEntry]);
|
||||||
|
|
||||||
const handleOpenInFinder = React.useCallback(async () => {
|
const handleOpenInFinder = React.useCallback(async () => {
|
||||||
if (!isDesktop || isOpeningFinder) return;
|
if (!canRequestAccess || isOpeningFinder) return;
|
||||||
setIsOpeningFinder(true);
|
setIsOpeningFinder(true);
|
||||||
try {
|
try {
|
||||||
const result = await requestAccess(targetPath);
|
const result = await requestAccess(targetPath);
|
||||||
@@ -488,7 +506,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
} finally {
|
} finally {
|
||||||
setIsOpeningFinder(false);
|
setIsOpeningFinder(false);
|
||||||
}
|
}
|
||||||
}, [finalizeSelection, isDesktop, isOpeningFinder, requestAccess, startAccessing, t, targetPath]);
|
}, [canRequestAccess, finalizeSelection, isOpeningFinder, requestAccess, startAccessing, t, targetPath]);
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === 'ArrowDown') {
|
if (event.key === 'ArrowDown') {
|
||||||
@@ -596,6 +614,24 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
<div className="py-10 text-center typography-ui-label text-muted-foreground">
|
<div className="py-10 text-center typography-ui-label text-muted-foreground">
|
||||||
{t('directoryExplorerDialog.browse.loading')}
|
{t('directoryExplorerDialog.browse.loading')}
|
||||||
</div>
|
</div>
|
||||||
|
) : browseErrorReason && browseErrorReason !== 'not-found' ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
|
||||||
|
<div className="typography-ui-label text-status-error">
|
||||||
|
{browseErrorReason === 'os-permission'
|
||||||
|
? t('directoryExplorerDialog.browse.permissionDenied')
|
||||||
|
: t('directoryExplorerDialog.browse.loadFailed')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{browseErrorReason === 'os-permission' && canRequestAccess ? (
|
||||||
|
<Button size="xs" onClick={() => void handleOpenInFinder()} disabled={isOpeningFinder}>
|
||||||
|
{t('directoryExplorerDialog.browse.grantAccess')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button variant="outline" size="xs" onClick={() => setBrowseReloadKey((key) => key + 1)}>
|
||||||
|
{t('directoryExplorerDialog.browse.retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="py-10 text-center typography-ui-label text-muted-foreground">
|
<div className="py-10 text-center typography-ui-label text-muted-foreground">
|
||||||
{t('directoryExplorerDialog.browse.empty')}
|
{t('directoryExplorerDialog.browse.empty')}
|
||||||
@@ -688,7 +724,7 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
|
|||||||
<>
|
<>
|
||||||
{!isMobile ? footerHints : null}
|
{!isMobile ? footerHints : null}
|
||||||
<div className={cn('flex w-full flex-row justify-end gap-2 sm:w-auto', isMobile && 'justify-stretch')}>
|
<div className={cn('flex w-full flex-row justify-end gap-2 sm:w-auto', isMobile && 'justify-stretch')}>
|
||||||
{isDesktop ? (
|
{canRequestAccess ? (
|
||||||
<Button variant="ghost" size="xs" onClick={handleOpenInFinder} disabled={isConfirming || isOpeningFinder || isCloneMode}>
|
<Button variant="ghost" size="xs" onClick={handleOpenInFinder} disabled={isConfirming || isOpeningFinder || isCloneMode}>
|
||||||
{isOpeningFinder ? t('directoryExplorerDialog.actions.openingFinder') : t('directoryExplorerDialog.actions.openInFinder')}
|
{isOpeningFinder ? t('directoryExplorerDialog.actions.openingFinder') : t('directoryExplorerDialog.actions.openInFinder')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -463,6 +463,14 @@ export function ScheduledTasksDialog() {
|
|||||||
<div className="typography-micro truncate text-muted-foreground">
|
<div className="typography-micro truncate text-muted-foreground">
|
||||||
{formatSchedule(task, t)}
|
{formatSchedule(task, t)}
|
||||||
</div>
|
</div>
|
||||||
|
{task.loopFile ? (
|
||||||
|
<div
|
||||||
|
className="typography-micro truncate text-muted-foreground/70"
|
||||||
|
title={task.loopFile}
|
||||||
|
>
|
||||||
|
{t('sessions.scheduledTasks.dialog.loopFile.note', { file: task.loopFile })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 typography-micro text-muted-foreground">
|
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 typography-micro text-muted-foreground">
|
||||||
@@ -525,8 +533,11 @@ export function ScheduledTasksDialog() {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium',
|
'inline-flex cursor-pointer items-center gap-2 typography-micro font-medium',
|
||||||
task.enabled ? 'text-foreground' : 'text-muted-foreground',
|
task.enabled ? 'text-foreground' : 'text-muted-foreground',
|
||||||
isBusy && 'cursor-not-allowed opacity-50',
|
(isBusy || task.loopFile) && 'cursor-not-allowed opacity-50',
|
||||||
)}
|
)}
|
||||||
|
title={task.loopFile
|
||||||
|
? t('sessions.scheduledTasks.dialog.loopFile.toggleDisabled')
|
||||||
|
: undefined}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={task.enabled}
|
checked={task.enabled}
|
||||||
@@ -534,7 +545,7 @@ export function ScheduledTasksDialog() {
|
|||||||
ariaLabel={task.enabled
|
ariaLabel={task.enabled
|
||||||
? t('sessions.scheduledTasks.dialog.taskToggle.pauseAria', { taskName: task.name })
|
? t('sessions.scheduledTasks.dialog.taskToggle.pauseAria', { taskName: task.name })
|
||||||
: t('sessions.scheduledTasks.dialog.taskToggle.enableAria', { taskName: task.name })}
|
: t('sessions.scheduledTasks.dialog.taskToggle.enableAria', { taskName: task.name })}
|
||||||
disabled={isBusy}
|
disabled={isBusy || Boolean(task.loopFile)}
|
||||||
/>
|
/>
|
||||||
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
|
{task.enabled ? t('sessions.scheduledTasks.dialog.taskToggle.enabled') : t('sessions.scheduledTasks.dialog.taskToggle.paused')}
|
||||||
</label>
|
</label>
|
||||||
@@ -555,7 +566,10 @@ export function ScheduledTasksDialog() {
|
|||||||
setEditorTask(task);
|
setEditorTask(task);
|
||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
}}
|
}}
|
||||||
disabled={isBusy}
|
disabled={isBusy || Boolean(task.loopFile)}
|
||||||
|
title={task.loopFile
|
||||||
|
? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled')
|
||||||
|
: undefined}
|
||||||
aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })}
|
aria-label={t('sessions.scheduledTasks.dialog.actions.editAria', { taskName: task.name })}
|
||||||
>
|
>
|
||||||
<Icon name="edit-2" className="h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.edit')}
|
<Icon name="edit-2" className="h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.edit')}
|
||||||
@@ -564,7 +578,10 @@ export function ScheduledTasksDialog() {
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => void handleDeleteTask(task)}
|
onClick={() => void handleDeleteTask(task)}
|
||||||
disabled={isBusy}
|
disabled={isBusy || Boolean(task.loopFile)}
|
||||||
|
title={task.loopFile
|
||||||
|
? t('sessions.scheduledTasks.dialog.loopFile.actionsDisabled')
|
||||||
|
: undefined}
|
||||||
aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })}
|
aria-label={t('sessions.scheduledTasks.dialog.actions.deleteAria', { taskName: task.name })}
|
||||||
>
|
>
|
||||||
<Icon name="delete-bin" className="h-4 w-4" />
|
<Icon name="delete-bin" className="h-4 w-4" />
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { useDurationTickerNow } from '@/hooks/useDurationTicker';
|
||||||
|
import {
|
||||||
|
useSessionActivityStartedAt,
|
||||||
|
useSessionSettledDurationMs,
|
||||||
|
} from '@/sync/session-activity-timing';
|
||||||
|
import { formatSessionActivityDuration } from './sessionActivityDurationFormat';
|
||||||
|
|
||||||
|
/** One update per second: the readout is the animation, at 1 fps instead of 60. */
|
||||||
|
const TICK_MS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elapsed time of a session's current turn, or of the turn that just finished.
|
||||||
|
* Colored to match the row's status dot in each state, so the pair reads as one
|
||||||
|
* indicator rather than two.
|
||||||
|
*
|
||||||
|
* Deliberately a leaf. The tick re-renders this span alone rather than the
|
||||||
|
* session row around it, which is what makes a live counter cheaper than the
|
||||||
|
* spinner it replaced — that spinner repainted a composited layer per row every
|
||||||
|
* frame for as long as the session ran.
|
||||||
|
*/
|
||||||
|
export const SessionActivityDuration: React.FC<{
|
||||||
|
sessionId: string;
|
||||||
|
/** Turn still running (`busy` or `retry`); false renders the settled total. */
|
||||||
|
running: boolean;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ sessionId, running, className }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const startedAt = useSessionActivityStartedAt(sessionId);
|
||||||
|
const settledMs = useSessionSettledDurationMs(sessionId);
|
||||||
|
const now = useDurationTickerNow(running, TICK_MS);
|
||||||
|
|
||||||
|
const durationMs = running ? Math.max(0, now - (startedAt ?? now)) : settledMs;
|
||||||
|
if (durationMs === undefined) return null;
|
||||||
|
|
||||||
|
const label = formatSessionActivityDuration(durationMs, t);
|
||||||
|
const description = running
|
||||||
|
? t('sessions.sidebar.session.status.activeFor', { duration: label })
|
||||||
|
: t('sessions.sidebar.session.status.lastTurnDuration', { duration: label });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 tabular-nums',
|
||||||
|
// The readout wears its dot's color, so the row reads as one signal:
|
||||||
|
// primary while the turn runs, info once it is waiting to be read.
|
||||||
|
running ? 'text-primary' : 'text-[var(--status-info)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={description}
|
||||||
|
title={description}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,7 +17,6 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||||
import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
|
import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { Icon } from '@/components/icon/Icon';
|
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { NewWorktreeDialog } from './NewWorktreeDialog';
|
import { NewWorktreeDialog } from './NewWorktreeDialog';
|
||||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
@@ -243,12 +242,14 @@ const ProjectAggregateStatusIndicator: React.FC<{ directories: Array<string | nu
|
|||||||
return false;
|
return false;
|
||||||
}, [directorySet]));
|
}, [directorySet]));
|
||||||
|
|
||||||
|
// Aggregate header: dot only. A collapsed project can hold several running
|
||||||
|
// turns, so a single elapsed counter would have nothing to count.
|
||||||
if (hasBusySession) {
|
if (hasBusySession) {
|
||||||
return (
|
return (
|
||||||
<Icon
|
<span
|
||||||
name="loader-4"
|
className="h-1.5 w-1.5 rounded-full bg-primary"
|
||||||
className="h-3 w-3 animate-spin text-primary"
|
|
||||||
aria-label={t('sessions.sidebar.session.status.active')}
|
aria-label={t('sessions.sidebar.session.status.active')}
|
||||||
|
title={t('sessions.sidebar.session.status.active')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { dict as enDict } from '@/lib/i18n/messages/en';
|
||||||
|
import { formatMessage, type I18nKey, type I18nParams } from '@/lib/i18n';
|
||||||
|
import { formatSessionActivityDuration } from './sessionActivityDurationFormat';
|
||||||
|
|
||||||
|
const t = (key: I18nKey, params?: I18nParams): string => formatMessage(enDict, key, params);
|
||||||
|
|
||||||
|
const format = (ms: number): string => formatSessionActivityDuration(ms, t);
|
||||||
|
|
||||||
|
describe('formatSessionActivityDuration', () => {
|
||||||
|
test('renders seconds below a minute', () => {
|
||||||
|
expect(format(0)).toBe('0s');
|
||||||
|
expect(format(999)).toBe('0s');
|
||||||
|
expect(format(7_400)).toBe('7s');
|
||||||
|
expect(format(59_999)).toBe('59s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders minutes and seconds below an hour', () => {
|
||||||
|
expect(format(60_000)).toBe('1m 0s');
|
||||||
|
expect(format(83_000)).toBe('1m 23s');
|
||||||
|
expect(format(59 * 60_000 + 59_000)).toBe('59m 59s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops seconds past an hour so the label stays narrow', () => {
|
||||||
|
expect(format(3_600_000)).toBe('1h 0m');
|
||||||
|
expect(format(3_600_000 + 2 * 60_000 + 33_000)).toBe('1h 2m');
|
||||||
|
expect(format(25 * 3_600_000)).toBe('25h 0m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps a negative duration rather than rendering a negative count', () => {
|
||||||
|
expect(format(-5_000)).toBe('0s');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { I18nKey, I18nParams } from '@/lib/i18n';
|
||||||
|
|
||||||
|
type Translate = (key: I18nKey, params?: I18nParams) => string;
|
||||||
|
|
||||||
|
const SECOND_MS = 1000;
|
||||||
|
const MINUTE_MS = 60 * SECOND_MS;
|
||||||
|
const HOUR_MS = 60 * MINUTE_MS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact turn duration for a session row: `7s`, `1m 23s`, `1h 2m`.
|
||||||
|
*
|
||||||
|
* Seconds are dropped past an hour so the label cannot outgrow the row's
|
||||||
|
* metadata slot, and the unit suffixes are translated rather than concatenated
|
||||||
|
* so locales that place or spell them differently stay correct.
|
||||||
|
*/
|
||||||
|
export const formatSessionActivityDuration = (durationMs: number, t: Translate): string => {
|
||||||
|
const total = Math.max(0, durationMs);
|
||||||
|
|
||||||
|
if (total < MINUTE_MS) {
|
||||||
|
return t('common.duration.secondsCompact', { seconds: Math.floor(total / SECOND_MS) });
|
||||||
|
}
|
||||||
|
if (total < HOUR_MS) {
|
||||||
|
return t('common.duration.minutesSecondsCompact', {
|
||||||
|
minutes: Math.floor(total / MINUTE_MS),
|
||||||
|
seconds: Math.floor((total % MINUTE_MS) / SECOND_MS),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return t('common.duration.hoursMinutesCompact', {
|
||||||
|
hours: Math.floor(total / HOUR_MS),
|
||||||
|
minutes: Math.floor((total % HOUR_MS) / MINUTE_MS),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
- Layout (web/desktop): top navigation (`SidebarNav`: New session, Scheduled, Multi-run, Archive), then the `recent` zone, then one zone per project with a **flat** session list. There is no rendered worktree grouping level.
|
- Layout (web/desktop): top navigation (`SidebarNav`: New session, Scheduled, Multi-run, Archive), then the `recent` zone, then one zone per project with a **flat** session list. There is no rendered worktree grouping level.
|
||||||
- **Two grouping display modes** (`useSessionDisplayStore.sessionGroupingMode`, toggled in the view dropdown): `'by-worktree'` (default) renders the worktree-grouped `sectionsForRender` with slim PR-aware branch sub-headers inside each project zone; `'flat'` renders `flatSectionsForRender` — one merged non-archived group per project (`id: 'flat'`, `folderScopes` listing every contributing scope) with per-row branch markers. Both derive from the same `projectSections` data layer, which alone feeds bootstrap demand planning and PR polling.
|
- **Two grouping display modes** (`useSessionDisplayStore.sessionGroupingMode`, toggled in the view dropdown): `'by-worktree'` (default) renders the worktree-grouped `sectionsForRender` with slim PR-aware branch sub-headers inside each project zone; `'flat'` renders `flatSectionsForRender` — one merged non-archived group per project (`id: 'flat'`, `folderScopes` listing every contributing scope) with per-row branch markers. Both derive from the same `projectSections` data layer, which alone feeds bootstrap demand planning and PR polling.
|
||||||
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
||||||
|
- **Activity is a dot plus a counter, never a spinner.** The row's left gutter shows a static dot — primary while the session runs (`busy`/`retry`), info while it is unread — and the metadata slot on the right swaps the goal/branch/date group for the elapsed time of the turn (`SessionActivityDuration`, ticking once per second). The readout takes the dot's color in each state — primary while running, info once it is waiting to be read — so the pair reads as one indicator. A running spinner repainted a composited layer per row every frame for the whole turn; the counter conveys the same "something is happening" at 1 fps. The counter follows the unread marker's lifetime exactly: it survives the turn ending, disappears when the session is read, and never lingers on the session being watched (which is marked read as it goes idle). Aggregate indicators for collapsed groups, folders, and projects show the dot only — a group may hold several running turns, so a single counter would have nothing to count. The same treatment applies to the mobile sessions sheet and session switcher rows. The worktree-move indicator stays a spinner: it marks a short user-initiated operation, not a session state.
|
||||||
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
|
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
|
||||||
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
|
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
|
||||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`).
|
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`).
|
||||||
@@ -31,7 +32,8 @@
|
|||||||
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
|
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
|
||||||
- `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder.
|
- `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder.
|
||||||
- `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows.
|
- `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows.
|
||||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Rows do not initiate directory bootstrap on mount.
|
- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Pending-question counts stay per-session while expanded and roll up hidden descendants from their owning directory stores while collapsed. Rows do not initiate directory bootstrap on mount.
|
||||||
|
- `collapsedActivityIndicator.tsx`: Aggregate busy/unseen dot for collapsed groups and folders.
|
||||||
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
|
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
|
||||||
- `sortableItems.tsx`: DnD sortable wrapper for project ordering plus the sticky zone-band project header and its action affordances.
|
- `sortableItems.tsx`: DnD sortable wrapper for project ordering plus the sticky zone-band project header and its action affordances.
|
||||||
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
|
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
|
||||||
@@ -76,4 +78,5 @@
|
|||||||
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
|
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
|
||||||
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
|
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
|
||||||
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
|
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
|
||||||
|
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
|
||||||
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
|
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
|
|||||||
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
|
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
|
||||||
const ARCHIVED_ROW_ESTIMATE_PX = 28;
|
const ARCHIVED_ROW_ESTIMATE_PX = 28;
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
@@ -32,6 +33,7 @@ import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
|||||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useChildStoreManager } from '@/sync/sync-context';
|
import { useChildStoreManager } from '@/sync/sync-context';
|
||||||
|
import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop';
|
||||||
import { CollapsedActivityIndicator } from './collapsedActivityIndicator';
|
import { CollapsedActivityIndicator } from './collapsedActivityIndicator';
|
||||||
import {
|
import {
|
||||||
getSessionNodesActivityState,
|
getSessionNodesActivityState,
|
||||||
@@ -348,18 +350,57 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
const groupPrSummary = usePrVisualSummary(groupPrKey);
|
const groupPrSummary = usePrVisualSummary(groupPrKey);
|
||||||
const groupPrColor = groupPrSummary ? `var(--pr-${groupPrSummary.visualState})` : undefined;
|
const groupPrColor = groupPrSummary ? `var(--pr-${groupPrSummary.visualState})` : undefined;
|
||||||
const childStores = useChildStoreManager();
|
const childStores = useChildStoreManager();
|
||||||
const bootstrapDirectory = normalizePath(group.directory ?? null);
|
const bootstrapDirectories = React.useMemo(() => {
|
||||||
const bootstrapState = React.useSyncExternalStore(
|
const directories = group.folderScopes?.map((scope) => normalizePath(scope.directory))
|
||||||
|
?? [normalizePath(group.directory ?? null)];
|
||||||
|
return [...new Set(directories.filter((directory): directory is string => Boolean(directory)))];
|
||||||
|
}, [group.directory, group.folderScopes]);
|
||||||
|
React.useSyncExternalStore(
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
(notify) => bootstrapDirectory ? childStores.subscribeBootstrap(notify) : () => undefined,
|
(notify) => bootstrapDirectories.length > 0 ? childStores.subscribeBootstrap(notify) : () => undefined,
|
||||||
[bootstrapDirectory, childStores],
|
[bootstrapDirectories.length, childStores],
|
||||||
),
|
),
|
||||||
React.useCallback(
|
React.useCallback(
|
||||||
() => bootstrapDirectory ? childStores.getBootstrapState(bootstrapDirectory) : undefined,
|
() => bootstrapDirectories.map((directory) => (
|
||||||
[bootstrapDirectory, childStores],
|
`${directory}\u0000${childStores.getBootstrapState(directory) ?? ''}\u0000${childStores.getBootstrapFailure(directory) ?? ''}`
|
||||||
|
)).join('\u0001'),
|
||||||
|
[bootstrapDirectories, childStores],
|
||||||
),
|
),
|
||||||
React.useCallback(() => undefined, []),
|
React.useCallback(() => '', []),
|
||||||
);
|
);
|
||||||
|
const bootstrapLoading = bootstrapDirectories.some((directory) => {
|
||||||
|
const state = childStores.getBootstrapState(directory);
|
||||||
|
return state === 'queued' || state === 'running';
|
||||||
|
});
|
||||||
|
const failedBootstrapDirectory = bootstrapDirectories.find(
|
||||||
|
(directory) => childStores.getBootstrapState(directory) === 'failed',
|
||||||
|
) ?? null;
|
||||||
|
const bootstrapFailure = failedBootstrapDirectory
|
||||||
|
? childStores.getBootstrapFailure(failedBootstrapDirectory)
|
||||||
|
: undefined;
|
||||||
|
const canGrantBootstrapAccess = bootstrapFailure === 'os-permission' && canRequestNativeDirectoryAccess();
|
||||||
|
const [isRequestingBootstrapAccess, setIsRequestingBootstrapAccess] = React.useState(false);
|
||||||
|
|
||||||
|
const retryFailedBootstrap = React.useCallback(() => {
|
||||||
|
if (!failedBootstrapDirectory) return;
|
||||||
|
childStores.requestBootstrap({
|
||||||
|
directory: failedBootstrapDirectory,
|
||||||
|
priority: isCollapsed ? 'visible' : 'expanded',
|
||||||
|
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
}, [childStores, failedBootstrapDirectory, group.isMain, isCollapsed]);
|
||||||
|
|
||||||
|
const grantFailedBootstrapAccess = React.useCallback(async () => {
|
||||||
|
if (!failedBootstrapDirectory || !canGrantBootstrapAccess || isRequestingBootstrapAccess) return;
|
||||||
|
setIsRequestingBootstrapAccess(true);
|
||||||
|
try {
|
||||||
|
const result = await requestDirectoryAccess(failedBootstrapDirectory);
|
||||||
|
if (result.success) retryFailedBootstrap();
|
||||||
|
} finally {
|
||||||
|
setIsRequestingBootstrapAccess(false);
|
||||||
|
}
|
||||||
|
}, [canGrantBootstrapAccess, failedBootstrapDirectory, isRequestingBootstrapAccess, retryFailedBootstrap]);
|
||||||
const maxVisible = hideDirectoryControls ? 10 : 5;
|
const maxVisible = hideDirectoryControls ? 10 : 5;
|
||||||
const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
|
const nonArchivedVisibleCount = Math.max(maxVisible, visibleSessionCount ?? maxVisible);
|
||||||
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
|
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
|
||||||
@@ -879,6 +920,33 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
||||||
: 'pr-2 group-hover/gh:pr-7 group-focus-within/gh:pr-7');
|
: 'pr-2 group-hover/gh:pr-7 group-focus-within/gh:pr-7');
|
||||||
|
|
||||||
|
const bootstrapFailureNotice = failedBootstrapDirectory ? (
|
||||||
|
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||||||
|
{bootstrapFailure === 'os-permission'
|
||||||
|
? t('sessions.sidebar.group.empty.permissionDenied')
|
||||||
|
: t('sessions.sidebar.group.empty.loadFailed')}
|
||||||
|
{canGrantBootstrapAccess ? (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="xs"
|
||||||
|
className="h-auto p-0 typography-micro"
|
||||||
|
disabled={isRequestingBootstrapAccess}
|
||||||
|
onClick={() => void grantFailedBootstrapAccess()}
|
||||||
|
>
|
||||||
|
{t('sessions.sidebar.group.empty.grantAccess')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
size="xs"
|
||||||
|
className="h-auto p-0 typography-micro"
|
||||||
|
onClick={retryFailedBootstrap}
|
||||||
|
>
|
||||||
|
{t('sessions.sidebar.group.empty.retry')}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<SessionFolderDndScope
|
<SessionFolderDndScope
|
||||||
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
|
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
|
||||||
@@ -971,34 +1039,23 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
<div className="py-1 pl-[26px] text-left typography-micro text-muted-foreground">
|
<div className="py-1 pl-[26px] text-left typography-micro text-muted-foreground">
|
||||||
{group.isArchivedBucket
|
{group.isArchivedBucket
|
||||||
? t('sessions.sidebar.group.empty.noArchivedSessions')
|
? t('sessions.sidebar.group.empty.noArchivedSessions')
|
||||||
: bootstrapState === 'queued' || bootstrapState === 'running'
|
: bootstrapLoading
|
||||||
? (
|
? (
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<Icon name="loader-4" className="size-3 animate-spin" />
|
<Icon name="loader-4" className="size-3 animate-spin" />
|
||||||
{t('sessions.sidebar.group.empty.loadingSessions')}
|
{t('sessions.sidebar.group.empty.loadingSessions')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
: bootstrapState === 'failed' && bootstrapDirectory
|
: bootstrapFailureNotice
|
||||||
? (
|
? bootstrapFailureNotice
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{t('sessions.sidebar.group.empty.loadFailed')}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-foreground hover:underline"
|
|
||||||
onClick={() => childStores.requestBootstrap({
|
|
||||||
directory: bootstrapDirectory,
|
|
||||||
priority: isCollapsed ? 'visible' : 'expanded',
|
|
||||||
reason: group.isMain ? 'project-expanded' : 'worktree-expanded',
|
|
||||||
force: true,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t('sessions.sidebar.group.empty.retry')}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
|
: t('sessions.sidebar.group.empty.noSessionsInWorkspace')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{totalSessions > 0 && bootstrapFailureNotice ? (
|
||||||
|
<div className="py-1 pl-[26px] text-left typography-micro text-status-error">
|
||||||
|
{bootstrapFailureNotice}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{remainingCount > 0 ? (
|
{remainingCount > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ import { isSessionPinned, type SessionPinnedTarget } from '@/stores/useSessionPi
|
|||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
||||||
import type { ChildSessionExport } from '@/lib/exportSession';
|
import type { ChildSessionExport } from '@/lib/exportSession';
|
||||||
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions } from '@/sync/sync-context';
|
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
|
||||||
import { useSync } from '@/sync/use-sync';
|
import { useSync } from '@/sync/use-sync';
|
||||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
|
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||||
import type { SessionNode } from './types';
|
import type { SessionNode } from './types';
|
||||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||||||
@@ -34,6 +34,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
|||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||||
|
import { useHasSessionActivityDuration } from '@/sync/session-activity-timing';
|
||||||
|
import { SessionActivityDuration } from '@/components/session/SessionActivityDuration';
|
||||||
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useShiftKeyHeld } from '@/hooks/useShiftKeyHeld';
|
import { useShiftKeyHeld } from '@/hooks/useShiftKeyHeld';
|
||||||
@@ -443,6 +445,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]),
|
React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]),
|
||||||
);
|
);
|
||||||
const sessionStatus = useGlobalSessionStatus(session.id);
|
const sessionStatus = useGlobalSessionStatus(session.id);
|
||||||
|
const statusType = sessionStatus?.type ?? 'idle';
|
||||||
|
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||||||
|
// Read as a boolean, not as the value: the row must not re-render on every
|
||||||
|
// tick of the counter it only decides to mount.
|
||||||
|
const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming);
|
||||||
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
|
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
|
||||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
|
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
|
||||||
const sessionGoal = getSessionGoal(resolvedSession);
|
const sessionGoal = getSessionGoal(resolvedSession);
|
||||||
@@ -463,6 +470,11 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
// expand the other. Matches the format of menuInstanceKey.
|
// expand the other. Matches the format of menuInstanceKey.
|
||||||
const expansionKey = menuInstanceKey;
|
const expansionKey = menuInstanceKey;
|
||||||
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(expansionKey);
|
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(expansionKey);
|
||||||
|
const questionBadgeSessionScopes = React.useMemo(
|
||||||
|
() => selectQuestionBadgeSessionScopes(node, isExpanded, sessionDirectory),
|
||||||
|
[isExpanded, node, sessionDirectory],
|
||||||
|
);
|
||||||
|
const pendingQuestionCount = useSessionQuestionCount(questionBadgeSessionScopes);
|
||||||
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
|
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
|
||||||
const unseenCount = useSessionUnseenCount(session.id);
|
const unseenCount = useSessionUnseenCount(session.id);
|
||||||
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
||||||
@@ -668,26 +680,31 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusType = sessionStatus?.type ?? 'idle';
|
|
||||||
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
|
||||||
const pendingPermissionCount = sessionPermissions.length;
|
const pendingPermissionCount = sessionPermissions.length;
|
||||||
|
const pendingQuestionLabel = pendingQuestionCount === 1
|
||||||
|
? t('sessions.sidebar.session.status.questionPendingSingle')
|
||||||
|
: t('sessions.sidebar.session.status.questionPendingMany', { count: pendingQuestionCount });
|
||||||
const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive;
|
const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive;
|
||||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||||
const statusMarkerContent = isStreaming
|
// Both states are the same static dot; only the color separates "running"
|
||||||
? (
|
// from "unread". The elapsed-turn readout on the right carries the motion
|
||||||
<Icon
|
// that a spinner used to, at one repaint per second instead of per frame.
|
||||||
name="loader-4"
|
const statusMarkerLabel = isStreaming
|
||||||
className="h-3 w-3 animate-spin text-primary"
|
? t('sessions.sidebar.session.status.active')
|
||||||
aria-label={t('sessions.sidebar.session.status.active')}
|
: t('sessions.sidebar.session.status.unread');
|
||||||
/>
|
const statusMarkerContent = (
|
||||||
)
|
<span
|
||||||
: (
|
className={cn(
|
||||||
<span
|
'h-1.5 w-1.5 rounded-full',
|
||||||
className="h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
|
isStreaming ? 'bg-primary' : 'bg-[var(--status-info)]',
|
||||||
aria-label={t('sessions.sidebar.session.status.unread')}
|
)}
|
||||||
title={t('sessions.sidebar.session.status.unread')}
|
aria-label={statusMarkerLabel}
|
||||||
/>
|
title={statusMarkerLabel}
|
||||||
);
|
/>
|
||||||
|
);
|
||||||
|
// The settled duration lives exactly as long as the unread marker does, so a
|
||||||
|
// session read (or watched) while it finishes never keeps a stale total.
|
||||||
|
const showActivityDuration = (isStreaming || showUnreadStatus) && hasActivityDuration;
|
||||||
const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (isMovingToWorktree || showStatusMarker || isPinnedSession);
|
const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (isMovingToWorktree || showStatusMarker || isPinnedSession);
|
||||||
const showPinnedMarker = isPinnedSession && !isMovingToWorktree && !showStatusMarker;
|
const showPinnedMarker = isPinnedSession && !isMovingToWorktree && !showStatusMarker;
|
||||||
const pinnedMarkerContent = (
|
const pinnedMarkerContent = (
|
||||||
@@ -1224,21 +1241,31 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
would reflow the truncated title and cause a micro
|
would reflow the truncated title and cause a micro
|
||||||
horizontal shift when the status flips. */}
|
horizontal shift when the status flips. */}
|
||||||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : needsAttention ? 'text-foreground' : 'text-foreground/80')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : needsAttention ? 'text-foreground' : 'text-foreground/80')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||||
|
{/* While a turn runs (and until its result is read) the
|
||||||
|
elapsed counter takes over this slot from the usual
|
||||||
|
goal/branch/date metadata, which stays one hover or
|
||||||
|
one read away. */}
|
||||||
{alwaysShowActions ? (
|
{alwaysShowActions ? (
|
||||||
// Touch runtimes have no hover tooltip, so the compact
|
// Touch runtimes have no hover tooltip, so the compact
|
||||||
// date stays inline there.
|
// date stays inline there.
|
||||||
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 text-[0.72rem] text-muted-foreground/75">
|
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 text-[0.72rem] text-muted-foreground/75">
|
||||||
{sessionGoalGlyph}
|
{showActivityDuration ? (
|
||||||
{showInlineBranchMarker ? (
|
<SessionActivityDuration sessionId={session.id} running={isStreaming} />
|
||||||
<Icon
|
) : (
|
||||||
name="git-branch"
|
<>
|
||||||
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
|
{sessionGoalGlyph}
|
||||||
style={prIconColor ? { color: prIconColor } : undefined}
|
{showInlineBranchMarker ? (
|
||||||
/>
|
<Icon
|
||||||
) : null}
|
name="git-branch"
|
||||||
{sessionCompactUpdatedLabel}
|
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
|
||||||
|
style={prIconColor ? { color: prIconColor } : undefined}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{sessionCompactUpdatedLabel}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (sessionGoalGlyph || showInlineBranchMarker) ? (
|
) : (showActivityDuration || sessionGoalGlyph || showInlineBranchMarker) ? (
|
||||||
<div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end">
|
<div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'inline-flex items-center gap-1 whitespace-nowrap text-right transition-opacity duration-150',
|
'inline-flex items-center gap-1 whitespace-nowrap text-right transition-opacity duration-150',
|
||||||
@@ -1246,14 +1273,24 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
? 'opacity-0'
|
? 'opacity-0'
|
||||||
: hideOnHoverClass,
|
: hideOnHoverClass,
|
||||||
)}>
|
)}>
|
||||||
{sessionGoalGlyph}
|
{showActivityDuration ? (
|
||||||
{showInlineBranchMarker ? (
|
<SessionActivityDuration
|
||||||
<Icon
|
sessionId={session.id}
|
||||||
name="git-branch"
|
running={isStreaming}
|
||||||
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
|
className="text-[0.72rem]"
|
||||||
style={prIconColor ? { color: prIconColor } : undefined}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
|
<>
|
||||||
|
{sessionGoalGlyph}
|
||||||
|
{showInlineBranchMarker ? (
|
||||||
|
<Icon
|
||||||
|
name="git-branch"
|
||||||
|
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
|
||||||
|
style={prIconColor ? { color: prIconColor } : undefined}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1263,6 +1300,12 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
<span className="leading-none">{pendingPermissionCount}</span>
|
<span className="leading-none">{pendingPermissionCount}</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{pendingQuestionCount > 0 ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-status-info/10 px-1 py-0.5 text-[0.7rem] text-status-info flex-shrink-0" title={pendingQuestionLabel} aria-label={pendingQuestionLabel}>
|
||||||
|
<Icon name="question" className="h-3 w-3" />
|
||||||
|
<span className="leading-none">{pendingQuestionCount}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function SidebarFooter({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label={t('sessions.sidebar.footer.actions.shortcuts')}>
|
<button type="button" onClick={onOpenShortcuts} className={footerButtonClassName} aria-label={t('sessions.sidebar.footer.actions.shortcuts')}>
|
||||||
<Icon name="question" className="h-4.5 w-4.5" />
|
<Icon name="command" className="h-4.5 w-4.5" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.footer.actions.shortcuts')}</p></TooltipContent>
|
<TooltipContent side="top" sideOffset={4}><p>{t('sessions.sidebar.footer.actions.shortcuts')}</p></TooltipContent>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon } from '@/components/icon/Icon';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { CollapsedActivityState } from './collapsedActivityState';
|
import type { CollapsedActivityState } from './collapsedActivityState';
|
||||||
|
|
||||||
@@ -15,21 +14,13 @@ export function CollapsedActivityIndicator({
|
|||||||
className?: string;
|
className?: string;
|
||||||
}): React.ReactNode {
|
}): React.ReactNode {
|
||||||
const label = state === 'active' ? activeLabel : unreadLabel;
|
const label = state === 'active' ? activeLabel : unreadLabel;
|
||||||
if (state === 'active') {
|
// Aggregate rows carry the dot only; the elapsed counter is per session and
|
||||||
return (
|
// has no meaning for a collapsed group that may hold several running turns.
|
||||||
<Icon
|
|
||||||
name="loader-4"
|
|
||||||
className={cn('h-3 w-3 shrink-0 animate-spin text-primary', className)}
|
|
||||||
aria-label={label}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-1.5 w-1.5 shrink-0 rounded-full',
|
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||||
'bg-[var(--status-info)]',
|
state === 'active' ? 'bg-primary' : 'bg-[var(--status-info)]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
||||||
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes } from './sessionNodeItemUtils';
|
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||||
import type { SessionNode } from './types';
|
import type { SessionNode } from './types';
|
||||||
|
|
||||||
const session = (id: string, title: string): Session => ({
|
const session = (id: string, title: string): Session => ({
|
||||||
@@ -32,6 +32,41 @@ describe('computeNodeStructureKey', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('selectQuestionBadgeSessionScopes', () => {
|
||||||
|
const withDirectory = (node: SessionNode, directory: string | null): SessionNode => ({
|
||||||
|
...node,
|
||||||
|
session: { ...node.session, directory } as Session,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rolls up the hidden subtree by owning directory when a parent is collapsed', () => {
|
||||||
|
const grandchild = withDirectory({ session: session('grandchild', 'Grandchild'), children: [], worktree: null }, '/worktrees/feature');
|
||||||
|
const child = withDirectory({ session: session('child', 'Child'), children: [grandchild], worktree: null }, '/worktrees/feature');
|
||||||
|
const root = withDirectory({ session: session('root', 'Root'), children: [child], worktree: null }, '/repo');
|
||||||
|
|
||||||
|
expect(selectQuestionBadgeSessionScopes(root, false, '/repo')).toEqual([
|
||||||
|
{ directory: '/repo', sessionIDs: ['root'] },
|
||||||
|
{ directory: '/worktrees/feature', sessionIDs: ['child', 'grandchild'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps expanded rows accurate to their own session only', () => {
|
||||||
|
const child = withDirectory({ session: session('child', 'Child'), children: [], worktree: null }, '/worktrees/feature');
|
||||||
|
const root = withDirectory({ session: session('root', 'Root'), children: [child], worktree: null }, '/repo');
|
||||||
|
|
||||||
|
expect(selectQuestionBadgeSessionScopes(root, true, '/repo')).toEqual([
|
||||||
|
{ directory: '/repo', sessionIDs: ['root'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the group directory when the session has none', () => {
|
||||||
|
const root: SessionNode = { session: session('root', 'Root'), children: [], worktree: null };
|
||||||
|
|
||||||
|
expect(selectQuestionBadgeSessionScopes(root, false, '/fallback')).toEqual([
|
||||||
|
{ directory: '/fallback', sessionIDs: ['root'] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('nodeHasPinnedMembershipChange', () => {
|
describe('nodeHasPinnedMembershipChange', () => {
|
||||||
test('detects composite pin changes using the group directory fallback', () => {
|
test('detects composite pin changes using the group directory fallback', () => {
|
||||||
const node: SessionNode = {
|
const node: SessionNode = {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
import { normalizePath } from '@/lib/pathNormalization';
|
||||||
|
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
||||||
import type { SessionNode } from './types';
|
import type { SessionNode } from './types';
|
||||||
|
|
||||||
@@ -70,6 +72,41 @@ export const nodeContainsSessionId = (node: SessionNode, sessionId: string | nul
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QuestionBadgeSessionScope = {
|
||||||
|
directory: string;
|
||||||
|
sessionIDs: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose which (directory, sessionIDs) scopes a sidebar row's pending-question
|
||||||
|
* badge should count. An expanded row counts only its own session; a collapsed
|
||||||
|
* parent row additionally rolls up the hidden descendants of its subtree,
|
||||||
|
* grouped by the directory store each descendant actually lives in, so badges
|
||||||
|
* stay correct for worktree/subtask sessions without bootstrapping their
|
||||||
|
* directory stores.
|
||||||
|
*/
|
||||||
|
export const selectQuestionBadgeSessionScopes = (
|
||||||
|
node: SessionNode,
|
||||||
|
isExpanded: boolean,
|
||||||
|
fallbackDirectory: string | null,
|
||||||
|
): QuestionBadgeSessionScope[] => {
|
||||||
|
const sessionIDsByDirectory = new Map<string, string[]>();
|
||||||
|
const visit = (current: SessionNode): void => {
|
||||||
|
const directory = resolveGlobalSessionDirectory(current.session)
|
||||||
|
?? normalizePath(current.worktree?.path)
|
||||||
|
?? fallbackDirectory;
|
||||||
|
if (directory) {
|
||||||
|
const sessionIDs = sessionIDsByDirectory.get(directory) ?? [];
|
||||||
|
sessionIDs.push(current.session.id);
|
||||||
|
sessionIDsByDirectory.set(directory, sessionIDs);
|
||||||
|
}
|
||||||
|
if (current === node && isExpanded) return;
|
||||||
|
for (const child of current.children) visit(child);
|
||||||
|
};
|
||||||
|
visit(node);
|
||||||
|
return [...sessionIDsByDirectory].map(([directory, sessionIDs]) => ({ directory, sessionIDs }));
|
||||||
|
};
|
||||||
|
|
||||||
export const selectFolderRootNodes = (
|
export const selectFolderRootNodes = (
|
||||||
sessionIds: string[],
|
sessionIds: string[],
|
||||||
nodeBySessionId: ReadonlyMap<string, SessionNode>,
|
nodeBySessionId: ReadonlyMap<string, SessionNode>,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
|
import type { FitAddon, Ghostty, Terminal as GhosttyTerminal } from 'ghostty-web';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { TerminalTheme } from '@/lib/terminalTheme';
|
import type { TerminalTheme } from '@/lib/terminalTheme';
|
||||||
@@ -15,8 +15,33 @@ import {
|
|||||||
} from '@/lib/terminalTouchSelection';
|
} from '@/lib/terminalTouchSelection';
|
||||||
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||||
|
|
||||||
let ghosttyPromise: Promise<Ghostty> | null = null;
|
// ghostty-web (638 KB raw of JS + the WASM VT) loads on demand: TerminalView
|
||||||
const loadGhostty = (): Promise<Ghostty> => ghosttyPromise ??= Ghostty.load();
|
// stays eagerly importable for the bottom dock without pulling the emulator
|
||||||
|
// into the startup graph before a terminal is actually mounted.
|
||||||
|
type GhosttyModule = typeof import('ghostty-web');
|
||||||
|
type GhosttyRuntime = { module: GhosttyModule; ghostty: Ghostty };
|
||||||
|
let ghosttyRuntimePromise: Promise<GhosttyRuntime> | null = null;
|
||||||
|
const loadGhostty = (): Promise<GhosttyRuntime> =>
|
||||||
|
ghosttyRuntimePromise ??= import('ghostty-web').then(async (module) => ({
|
||||||
|
module,
|
||||||
|
ghostty: await module.Ghostty.load(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The web entry defers its ~2 MB Nerd Font download until a terminal actually
|
||||||
|
// mounts (see the `__openchamberEnsureNerdFonts` hook in index.html). Wait for
|
||||||
|
// it with a short bound so a cached font is in place before the glyph atlas is
|
||||||
|
// built, while a cold CDN fetch never blocks the terminal from opening; the
|
||||||
|
// runtimes without the hook (VS Code, mobile) resolve immediately.
|
||||||
|
const NERD_FONT_WAIT_MS = 2000;
|
||||||
|
const ensureNerdFonts = (): Promise<void> => {
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve();
|
||||||
|
const loader = (window as typeof window & { __openchamberEnsureNerdFonts?: () => Promise<void> }).__openchamberEnsureNerdFonts;
|
||||||
|
if (typeof loader !== 'function') return Promise.resolve();
|
||||||
|
return Promise.race([
|
||||||
|
Promise.resolve(loader()).catch(() => undefined),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, NERD_FONT_WAIT_MS)),
|
||||||
|
]).then(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
type TerminalSize = { cols: number; rows: number };
|
type TerminalSize = { cols: number; rows: number };
|
||||||
|
|
||||||
@@ -211,13 +236,13 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
|
|||||||
window.addEventListener('focus', handleWindowFocus);
|
window.addEventListener('focus', handleWindowFocus);
|
||||||
window.addEventListener('blur', handleWindowBlur);
|
window.addEventListener('blur', handleWindowBlur);
|
||||||
|
|
||||||
loadGhostty().then((ghostty) => {
|
Promise.all([loadGhostty(), ensureNerdFonts()]).then(([{ module, ghostty }]) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
terminal = new GhosttyTerminal({
|
terminal = new module.Terminal({
|
||||||
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
|
...getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false),
|
||||||
...(provisionalSizeRef.current ?? {}),
|
...(provisionalSizeRef.current ?? {}),
|
||||||
});
|
});
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new module.FitAddon();
|
||||||
terminal.loadAddon(fitAddon);
|
terminal.loadAddon(fitAddon);
|
||||||
terminal.open(container);
|
terminal.open(container);
|
||||||
terminalRef.current = terminal;
|
terminalRef.current = terminal;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntim
|
|||||||
import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata';
|
import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata';
|
||||||
|
|
||||||
const EMPTY_PINNED_SESSION_IDS = new Set<string>();
|
const EMPTY_PINNED_SESSION_IDS = new Set<string>();
|
||||||
import { getSettingsNavIcon } from '@/components/views/SettingsView';
|
import { getSettingsNavIcon } from '@/lib/settings/metadata';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { McpIcon } from '@/components/icons/McpIcon';
|
import { McpIcon } from '@/components/icons/McpIcon';
|
||||||
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
|
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
|
||||||
|
|||||||
@@ -175,6 +175,11 @@ export const HelpDialog: React.FC = () => {
|
|||||||
icon: "time",
|
icon: "time",
|
||||||
keys: '',
|
keys: '',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
keys: [`${mod} + 1...0`],
|
||||||
|
descriptionKey: "helpDialog.item.switchContextSurface",
|
||||||
|
icon: "layout-right",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -186,11 +191,6 @@ export const HelpDialog: React.FC = () => {
|
|||||||
icon: "palette",
|
icon: "palette",
|
||||||
keys: '',
|
keys: '',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
keys: [`${mod} + 1...9`],
|
|
||||||
descriptionKey: "helpDialog.item.switchProject",
|
|
||||||
icon: "layout-left",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'toggle_services_menu',
|
id: 'toggle_services_menu',
|
||||||
descriptionKey: 'helpDialog.item.toggleServicesMenu',
|
descriptionKey: 'helpDialog.item.toggleServicesMenu',
|
||||||
@@ -218,7 +218,7 @@ export const HelpDialog: React.FC = () => {
|
|||||||
<DialogContent className="max-w-2xl w-[min(42rem,calc(100vw-1.5rem))] max-h-[calc(100dvh-2rem)] flex flex-col overflow-hidden">
|
<DialogContent className="max-w-2xl w-[min(42rem,calc(100vw-1.5rem))] max-h-[calc(100dvh-2rem)] flex flex-col overflow-hidden">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Icon name="settings-3" className="h-5 w-5" />
|
<Icon name="command" className="h-5 w-5" />
|
||||||
{t('helpDialog.title')}
|
{t('helpDialog.title')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ const isSameThumbMetrics = (a: ThumbMetrics, b: ThumbMetrics): boolean => {
|
|||||||
return Math.abs(a.length - b.length) < METRIC_EPSILON && Math.abs(a.offset - b.offset) < METRIC_EPSILON;
|
return Math.abs(a.length - b.length) < METRIC_EPSILON && Math.abs(a.offset - b.offset) < METRIC_EPSILON;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Desktop shells (Electron, VS Code webview) use persistent, not
|
||||||
|
// auto-hiding, scrollbars. Reads the same `desktop-runtime` root class that
|
||||||
|
// index.css keys off, so JS and CSS never disagree on what counts as desktop.
|
||||||
|
const isDesktopScrollbarRuntime = (): boolean =>
|
||||||
|
typeof document !== "undefined" && document.documentElement.classList.contains("desktop-runtime");
|
||||||
|
|
||||||
const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
||||||
containerRef,
|
containerRef,
|
||||||
minThumbSize = 32,
|
minThumbSize = 32,
|
||||||
@@ -128,8 +134,15 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
|||||||
if (isHoveringRef.current) {
|
if (isHoveringRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Desktop shells keep the thumb visible once shown instead of
|
||||||
|
// auto-hiding it after a delay. userIntentOnly callers (e.g. chat
|
||||||
|
// auto-follow scroll) opt out of persistence on purpose, so they keep
|
||||||
|
// the existing auto-hide behavior even on desktop.
|
||||||
|
if (isDesktopScrollbarRuntime() && !userIntentOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelayMs);
|
hideTimeoutRef.current = setTimeout(() => setVisible(false), hideDelayMs);
|
||||||
}, [hideDelayMs]);
|
}, [hideDelayMs, userIntentOnly]);
|
||||||
|
|
||||||
const markUserIntent = React.useCallback(() => {
|
const markUserIntent = React.useCallback(() => {
|
||||||
lastUserIntentAtRef.current = Date.now();
|
lastUserIntentAtRef.current = Date.now();
|
||||||
@@ -162,7 +175,10 @@ const OverlayScrollbarComponent: React.FC<OverlayScrollbarProps> = ({
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
updateMetrics();
|
updateMetrics();
|
||||||
setVisible(false);
|
// On desktop shells, show the thumb immediately if content overflows
|
||||||
|
// instead of waiting for the first scroll event (persistent affordance,
|
||||||
|
// matching native desktop scrollbar conventions).
|
||||||
|
setVisible(isDesktopScrollbarRuntime() && !userIntentOnly);
|
||||||
|
|
||||||
const onScroll = () => handleScroll();
|
const onScroll = () => handleScroll();
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -1417,12 +1417,32 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
|||||||
}));
|
}));
|
||||||
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
|
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
|
||||||
|
|
||||||
|
const currentBranch = status?.current ?? null;
|
||||||
|
|
||||||
|
// The repository's own default branch, so a repo whose default is neither
|
||||||
|
// main, master nor develop stops being compared against a branch that does
|
||||||
|
// not exist.
|
||||||
|
const defaultBranch = React.useMemo(() => {
|
||||||
|
const trackingRemote = status?.tracking?.trim().split('/')[0];
|
||||||
|
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
|
||||||
|
?? branches?.defaultBranches?.origin;
|
||||||
|
}, [branches, status?.tracking]);
|
||||||
|
|
||||||
const baseBranch = React.useMemo(() => deriveBaseBranch({
|
const baseBranch = React.useMemo(() => deriveBaseBranch({
|
||||||
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
|
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
|
||||||
localBranches,
|
localBranches,
|
||||||
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
|
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
|
||||||
rootBranchHint,
|
rootBranchHint,
|
||||||
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
|
defaultBranch,
|
||||||
|
headBranch: currentBranch,
|
||||||
|
}), [
|
||||||
|
currentBranch,
|
||||||
|
defaultBranch,
|
||||||
|
effectiveRemotes,
|
||||||
|
localBranches,
|
||||||
|
rootBranchHint,
|
||||||
|
worktreeMetadata?.createdFromBranch,
|
||||||
|
]);
|
||||||
|
|
||||||
const updateTargetBranch = React.useMemo(() => {
|
const updateTargetBranch = React.useMemo(() => {
|
||||||
const remoteNames = effectiveRemotes.map((remote) => remote.name);
|
const remoteNames = effectiveRemotes.map((remote) => remote.name);
|
||||||
@@ -1511,7 +1531,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
|||||||
|
|
||||||
const stagedCount = stagedChangeEntries.length;
|
const stagedCount = stagedChangeEntries.length;
|
||||||
const isBusy = isLoading || syncAction !== null || commitAction !== null;
|
const isBusy = isLoading || syncAction !== null || commitAction !== null;
|
||||||
const currentBranch = status?.current ?? null;
|
|
||||||
const canShowIntegrateCommitsSection = Boolean(
|
const canShowIntegrateCommitsSection = Boolean(
|
||||||
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
|
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -213,14 +213,32 @@ export const PullRequestView: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
|
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
|
||||||
|
|
||||||
|
const currentBranch = status?.current ?? null;
|
||||||
|
|
||||||
|
// A pull request opened against a branch that does not exist is worse than a
|
||||||
|
// broken walkthrough, so this surface reads the repository's default branch
|
||||||
|
// too rather than guessing at main/master/develop.
|
||||||
|
const defaultBranch = React.useMemo(() => {
|
||||||
|
const trackingRemote = status?.tracking?.trim().split('/')[0];
|
||||||
|
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
|
||||||
|
?? branches?.defaultBranches?.origin;
|
||||||
|
}, [branches, status?.tracking]);
|
||||||
|
|
||||||
const baseBranch = React.useMemo(() => deriveBaseBranch({
|
const baseBranch = React.useMemo(() => deriveBaseBranch({
|
||||||
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
|
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
|
||||||
localBranches,
|
localBranches,
|
||||||
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
|
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
|
||||||
rootBranchHint,
|
rootBranchHint,
|
||||||
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
|
defaultBranch,
|
||||||
|
headBranch: currentBranch,
|
||||||
const currentBranch = status?.current ?? null;
|
}), [
|
||||||
|
currentBranch,
|
||||||
|
defaultBranch,
|
||||||
|
effectiveRemotes,
|
||||||
|
localBranches,
|
||||||
|
rootBranchHint,
|
||||||
|
worktreeMetadata?.createdFromBranch,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!currentDirectory || !currentBranch) {
|
if (!currentDirectory || !currentBranch) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRunti
|
|||||||
import { isWindowsArm64 as isWindowsArm64Platform } from '@/lib/platform';
|
import { isWindowsArm64 as isWindowsArm64Platform } from '@/lib/platform';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import type { IconName } from "@/components/icon/icons";
|
|
||||||
import { McpIcon } from '@/components/icons/McpIcon';
|
import { McpIcon } from '@/components/icons/McpIcon';
|
||||||
import { OpenCodeReloadFooterAction } from '@/components/views/OpenCodeReloadFooterAction';
|
import { OpenCodeReloadFooterAction } from '@/components/views/OpenCodeReloadFooterAction';
|
||||||
import {
|
import {
|
||||||
@@ -55,6 +54,7 @@ import {
|
|||||||
} from '@/stores/usePendingOpenCodeRestartStore';
|
} from '@/stores/usePendingOpenCodeRestartStore';
|
||||||
import {
|
import {
|
||||||
SETTINGS_PAGE_METADATA,
|
SETTINGS_PAGE_METADATA,
|
||||||
|
getSettingsNavIcon,
|
||||||
getSettingsPageMeta,
|
getSettingsPageMeta,
|
||||||
resolveSettingsSlug,
|
resolveSettingsSlug,
|
||||||
type SettingsPageSlug,
|
type SettingsPageSlug,
|
||||||
@@ -117,7 +117,6 @@ const pageOrder: SettingsPageSlug[] = [
|
|||||||
|
|
||||||
const NAV_GROUP_ORDER = ['general', 'projects', 'opencode', 'content'] as const;
|
const NAV_GROUP_ORDER = ['general', 'projects', 'opencode', 'content'] as const;
|
||||||
|
|
||||||
const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
|
|
||||||
const ADD_PROVIDER_SETTINGS_ID = '__add_provider__';
|
const ADD_PROVIDER_SETTINGS_ID = '__add_provider__';
|
||||||
|
|
||||||
function buildRuntimeContext(isDesktop: boolean, isMobile: boolean): SettingsRuntimeContext {
|
function buildRuntimeContext(isDesktop: boolean, isMobile: boolean): SettingsRuntimeContext {
|
||||||
@@ -175,65 +174,6 @@ function getCurrentHistoryState(): Record<string, unknown> {
|
|||||||
return window.history.state;
|
return window.history.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
|
||||||
switch (slug) {
|
|
||||||
case 'general':
|
|
||||||
return 'settings-3';
|
|
||||||
case 'projects':
|
|
||||||
return 'folders';
|
|
||||||
case 'remote-instances':
|
|
||||||
return 'computer';
|
|
||||||
case 'appearance':
|
|
||||||
return 'palette';
|
|
||||||
case 'chat':
|
|
||||||
return 'chat-ai-3';
|
|
||||||
case 'magic-prompts':
|
|
||||||
return 'ai-generate-2';
|
|
||||||
case 'snippets':
|
|
||||||
return SNIPPETS_SETTINGS_ICON.icon;
|
|
||||||
case 'notifications':
|
|
||||||
return 'notification-3';
|
|
||||||
case 'shortcuts':
|
|
||||||
return 'command';
|
|
||||||
case 'sessions':
|
|
||||||
return 'chat-history';
|
|
||||||
|
|
||||||
case 'providers':
|
|
||||||
return 'cloud';
|
|
||||||
case 'agents':
|
|
||||||
return 'ai-agent';
|
|
||||||
case 'behavior':
|
|
||||||
return 'brain';
|
|
||||||
case 'commands':
|
|
||||||
return 'slash-commands-2';
|
|
||||||
case 'mcp':
|
|
||||||
return null;
|
|
||||||
case 'plugins':
|
|
||||||
return 'plug-2';
|
|
||||||
|
|
||||||
case 'skills.installed':
|
|
||||||
return 'book-open';
|
|
||||||
case 'skills.catalog':
|
|
||||||
return 'book';
|
|
||||||
|
|
||||||
case 'git':
|
|
||||||
return 'git-branch';
|
|
||||||
|
|
||||||
case 'usage':
|
|
||||||
return 'bar-chart-2';
|
|
||||||
case 'voice':
|
|
||||||
return 'mic';
|
|
||||||
case 'tunnel':
|
|
||||||
return 'home-office';
|
|
||||||
case 'about':
|
|
||||||
return 'information';
|
|
||||||
case 'home':
|
|
||||||
return null;
|
|
||||||
default:
|
|
||||||
return 'robot-2';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
|
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { deriveBaseBranch, hasResolvableBaseBranch } from './baseBranch';
|
||||||
|
|
||||||
|
describe('deriveBaseBranch', () => {
|
||||||
|
test('prefers the repository default branch over conventional fallbacks', () => {
|
||||||
|
expect(deriveBaseBranch({
|
||||||
|
remoteNames: new Set(['origin']),
|
||||||
|
localBranches: ['next'],
|
||||||
|
defaultBranch: 'react',
|
||||||
|
})).toBe('react');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a remote-qualified default branch', () => {
|
||||||
|
expect(deriveBaseBranch({
|
||||||
|
remoteNames: new Set(['origin']),
|
||||||
|
localBranches: ['next'],
|
||||||
|
defaultBranch: 'origin/react',
|
||||||
|
})).toBe('react');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the more specific worktree origin ahead of the default branch', () => {
|
||||||
|
expect(deriveBaseBranch({
|
||||||
|
remoteNames: new Set(['origin']),
|
||||||
|
localBranches: ['next', 'react', 'feature'],
|
||||||
|
worktreeCreatedFromBranch: 'feature',
|
||||||
|
defaultBranch: 'react',
|
||||||
|
})).toBe('feature');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips a hint that is the branch being compared', () => {
|
||||||
|
// In a plain checkout the project root is the current worktree, so the root
|
||||||
|
// branch hint is the current branch — a branch is never its own base.
|
||||||
|
expect(deriveBaseBranch({
|
||||||
|
remoteNames: new Set(['origin']),
|
||||||
|
localBranches: ['next', 'react'],
|
||||||
|
rootBranchHint: 'next',
|
||||||
|
defaultBranch: 'react',
|
||||||
|
headBranch: 'next',
|
||||||
|
})).toBe('react');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to conventional names when nothing is known', () => {
|
||||||
|
expect(deriveBaseBranch({
|
||||||
|
remoteNames: new Set(['origin']),
|
||||||
|
localBranches: ['master', 'next'],
|
||||||
|
})).toBe('master');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasResolvableBaseBranch', () => {
|
||||||
|
test('rejects the main fallback when it does not exist', () => {
|
||||||
|
expect(hasResolvableBaseBranch({
|
||||||
|
baseBranch: 'main',
|
||||||
|
localBranches: ['next', 'react'],
|
||||||
|
remoteBranches: ['origin/next', 'origin/react'],
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts a base branch available through a remote-tracking ref', () => {
|
||||||
|
// Safe because getRangeDiff resolves a base that exists only on a remote
|
||||||
|
// through that remote rather than passing the bare name to git.
|
||||||
|
expect(hasResolvableBaseBranch({
|
||||||
|
baseBranch: 'main',
|
||||||
|
localBranches: ['next'],
|
||||||
|
remoteBranches: ['origin/main', 'origin/next'],
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not accept a differently-scoped branch that merely ends the same way', () => {
|
||||||
|
expect(hasResolvableBaseBranch({
|
||||||
|
baseBranch: 'main',
|
||||||
|
localBranches: ['next'],
|
||||||
|
remoteBranches: ['origin/feature/main'],
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches a base branch whose own name contains a slash', () => {
|
||||||
|
expect(hasResolvableBaseBranch({
|
||||||
|
baseBranch: 'release/2.0',
|
||||||
|
localBranches: ['next'],
|
||||||
|
remoteBranches: ['origin/release/2.0'],
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,8 +8,30 @@ export const deriveBaseBranch = (options: {
|
|||||||
localBranches: readonly string[];
|
localBranches: readonly string[];
|
||||||
worktreeCreatedFromBranch?: string | null;
|
worktreeCreatedFromBranch?: string | null;
|
||||||
rootBranchHint?: string | null;
|
rootBranchHint?: string | null;
|
||||||
|
/**
|
||||||
|
* The repository's own default branch, read from a `remote/HEAD` symbolic
|
||||||
|
* ref. Its own option rather than another hint: `rootBranchHint` means "the
|
||||||
|
* branch the project root worktree is on", and a parameter that means two
|
||||||
|
* things is one the next caller gets wrong.
|
||||||
|
*/
|
||||||
|
defaultBranch?: string | null;
|
||||||
|
/**
|
||||||
|
* The branch being compared. A branch is never its own base, so a candidate
|
||||||
|
* equal to it is skipped — in a plain checkout `rootBranchHint` *is* the
|
||||||
|
* current branch, and taking it produced a comparison with itself.
|
||||||
|
*/
|
||||||
|
headBranch?: string | null;
|
||||||
}): string => {
|
}): string => {
|
||||||
const { remoteNames, localBranches, worktreeCreatedFromBranch, rootBranchHint } = options;
|
const {
|
||||||
|
remoteNames,
|
||||||
|
localBranches,
|
||||||
|
worktreeCreatedFromBranch,
|
||||||
|
rootBranchHint,
|
||||||
|
defaultBranch,
|
||||||
|
headBranch,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const head = typeof headBranch === 'string' ? headBranch.trim() : '';
|
||||||
|
|
||||||
const normalizeBaseCandidate = (value: string): string => {
|
const normalizeBaseCandidate = (value: string): string => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -49,16 +71,47 @@ export const deriveBaseBranch = (options: {
|
|||||||
return normalized;
|
return normalized;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fromMeta = normalizeBaseCandidate(
|
const candidate = (value: unknown): string => {
|
||||||
typeof worktreeCreatedFromBranch === 'string' ? worktreeCreatedFromBranch : ''
|
const normalized = normalizeBaseCandidate(typeof value === 'string' ? value : '');
|
||||||
);
|
return normalized && normalized !== head ? normalized : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromMeta = candidate(worktreeCreatedFromBranch);
|
||||||
if (fromMeta) return fromMeta;
|
if (fromMeta) return fromMeta;
|
||||||
|
|
||||||
const fromHint = normalizeBaseCandidate(typeof rootBranchHint === 'string' ? rootBranchHint : '');
|
const fromHint = candidate(rootBranchHint);
|
||||||
if (fromHint) return fromHint;
|
if (fromHint) return fromHint;
|
||||||
|
|
||||||
|
// Authoritative where the hints are guesses: this is what the repository says
|
||||||
|
// its default branch is, so it outranks the conventional names below.
|
||||||
|
const fromDefault = candidate(defaultBranch);
|
||||||
|
if (fromDefault) return fromDefault;
|
||||||
|
|
||||||
if (localBranches.includes('main')) return 'main';
|
if (localBranches.includes('main')) return 'main';
|
||||||
if (localBranches.includes('master')) return 'master';
|
if (localBranches.includes('master')) return 'master';
|
||||||
if (localBranches.includes('develop')) return 'develop';
|
if (localBranches.includes('develop')) return 'develop';
|
||||||
return 'main';
|
return 'main';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a base branch can be resolved locally or through one of the active
|
||||||
|
* remote-tracking refs. Callers must not offer comparisons against the `main`
|
||||||
|
* fallback when that ref does not actually exist in the repository.
|
||||||
|
*
|
||||||
|
* `remoteBranches` are remote-relative (`origin/main`, `origin/feature/x`), so
|
||||||
|
* the remote name is dropped and the rest compared whole. A suffix test matched
|
||||||
|
* `origin/feature/main` for a base of `main`, which passes the check and then
|
||||||
|
* fails the comparison it was meant to prevent.
|
||||||
|
*/
|
||||||
|
export const hasResolvableBaseBranch = (options: {
|
||||||
|
baseBranch: string;
|
||||||
|
localBranches: readonly string[];
|
||||||
|
remoteBranches: readonly string[];
|
||||||
|
}): boolean => {
|
||||||
|
const { baseBranch, localBranches, remoteBranches } = options;
|
||||||
|
if (localBranches.includes(baseBranch)) return true;
|
||||||
|
return remoteBranches.some((branch) => {
|
||||||
|
const slashIndex = branch.indexOf('/');
|
||||||
|
return slashIndex > 0 && branch.slice(slashIndex + 1) === baseBranch;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import type { WalkthroughBlockedReason, WalkthroughModel } from '@/lib/walkthrough/types';
|
import type { WalkthroughBlockedState, WalkthroughModel } from '@/lib/walkthrough/types';
|
||||||
|
|
||||||
interface WalkthroughBlockerProps {
|
interface WalkthroughBlockerProps {
|
||||||
reason: WalkthroughBlockedReason;
|
reason: WalkthroughBlockedState;
|
||||||
model?: WalkthroughModel;
|
model?: WalkthroughModel;
|
||||||
requiredChars?: number;
|
requiredChars?: number;
|
||||||
availableChars?: number;
|
availableChars?: number;
|
||||||
@@ -102,6 +102,7 @@ export const WalkthroughBlocker = ({
|
|||||||
if (reason === 'no-model') return t('walkthrough.blocked.noModel.description');
|
if (reason === 'no-model') return t('walkthrough.blocked.noModel.description');
|
||||||
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description');
|
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description');
|
||||||
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description');
|
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description');
|
||||||
|
if (reason === 'server-unsupported') return t('walkthrough.blocked.serverUnsupported.description');
|
||||||
if (reason === 'output-exhausted') {
|
if (reason === 'output-exhausted') {
|
||||||
return label
|
return label
|
||||||
? t('walkthrough.blocked.outputExhausted.description', { model: label })
|
? t('walkthrough.blocked.outputExhausted.description', { model: label })
|
||||||
@@ -125,6 +126,7 @@ export const WalkthroughBlocker = ({
|
|||||||
if (reason === 'no-model') return t('walkthrough.blocked.noModel.title');
|
if (reason === 'no-model') return t('walkthrough.blocked.noModel.title');
|
||||||
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title');
|
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title');
|
||||||
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title');
|
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title');
|
||||||
|
if (reason === 'server-unsupported') return t('walkthrough.blocked.serverUnsupported.title');
|
||||||
if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title');
|
if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title');
|
||||||
if (reason === 'structured-output-unsupported') return t('walkthrough.blocked.structuredOutput.title');
|
if (reason === 'structured-output-unsupported') return t('walkthrough.blocked.structuredOutput.title');
|
||||||
return t('walkthrough.blocked.contextTooSmall.title');
|
return t('walkthrough.blocked.contextTooSmall.title');
|
||||||
@@ -150,13 +152,15 @@ export const WalkthroughBlocker = ({
|
|||||||
onChange={(providerId, modelId) => {
|
onChange={(providerId, modelId) => {
|
||||||
void handleModelChange(providerId, modelId);
|
void handleModelChange(providerId, modelId);
|
||||||
}}
|
}}
|
||||||
allowedProviderIds={providers}
|
allowedProviderIds={providers ?? []}
|
||||||
isModelAllowed={isStructuredOutputCapable}
|
isModelAllowed={isStructuredOutputCapable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(reason === 'empty-diff' || reason === 'only-generated') && (
|
{/* Retry is the whole remedy once the server is updated, so it stays in
|
||||||
|
reach rather than sending the user back through the panel header. */}
|
||||||
|
{(reason === 'empty-diff' || reason === 'only-generated' || reason === 'server-unsupported') && (
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||||
{t('walkthrough.action.refresh')}
|
{t('walkthrough.action.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { groupHunksByFile } from '@/lib/walkthrough/model';
|
import { groupHunksByFile } from '@/lib/walkthrough/model';
|
||||||
import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model';
|
import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model';
|
||||||
@@ -20,8 +21,13 @@ interface WalkthroughStreamProps {
|
|||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Importance says where to spend attention, not what is wrong: a stop is marked
|
||||||
|
// because it drives the rest of the change, never because something was found in
|
||||||
|
// it. A red pill said the opposite — status colours are read as findings, and a
|
||||||
|
// walkthrough deliberately hands out no verdicts — so the emphasis is carried by
|
||||||
|
// weight and an outline instead, and the tooltip states the axis outright.
|
||||||
const IMPORTANCE_CLASS: Record<WalkthroughStopImportance, string> = {
|
const IMPORTANCE_CLASS: Record<WalkthroughStopImportance, string> = {
|
||||||
critical: 'bg-status-error/10 text-status-error',
|
critical: 'border border-[var(--interactive-border)] font-medium text-foreground',
|
||||||
normal: 'bg-surface-muted text-muted-foreground',
|
normal: 'bg-surface-muted text-muted-foreground',
|
||||||
context: 'bg-surface-muted text-muted-foreground',
|
context: 'bg-surface-muted text-muted-foreground',
|
||||||
};
|
};
|
||||||
@@ -41,11 +47,22 @@ const StopHeader = ({ stopView }: { stopView: WalkthroughStopView }) => {
|
|||||||
exactly as tall as one without: vertical padding on a smaller type
|
exactly as tall as one without: vertical padding on a smaller type
|
||||||
size was pushing past the tallest element in the row. */}
|
size was pushing past the tallest element in the row. */}
|
||||||
{stop.importance !== 'normal' && (
|
{stop.importance !== 'normal' && (
|
||||||
<span className={cn('typography-micro flex h-5 items-center rounded px-1.5 leading-none', IMPORTANCE_CLASS[stop.importance])}>
|
<Tooltip>
|
||||||
{stop.importance === 'critical'
|
<TooltipTrigger
|
||||||
? t('walkthrough.importance.critical')
|
className={cn('typography-micro flex h-5 items-center rounded px-1.5 leading-none', IMPORTANCE_CLASS[stop.importance])}
|
||||||
: t('walkthrough.importance.context')}
|
>
|
||||||
</span>
|
{stop.importance === 'critical'
|
||||||
|
? t('walkthrough.importance.critical')
|
||||||
|
: t('walkthrough.importance.context')}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-64">
|
||||||
|
<p className="typography-micro leading-tight">
|
||||||
|
{stop.importance === 'critical'
|
||||||
|
? t('walkthrough.importance.criticalHint')
|
||||||
|
: t('walkthrough.importance.contextHint')}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="typography-body text-muted-foreground">{stop.prose}</p>
|
<p className="typography-body text-muted-foreground">{stop.prose}</p>
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useI18n, type Locale } from '@/lib/i18n';
|
import { useI18n, type Locale } from '@/lib/i18n';
|
||||||
|
import { openExternalUrl } from '@/lib/url';
|
||||||
import { buildWalkthroughView } from '@/lib/walkthrough/model';
|
import { buildWalkthroughView } from '@/lib/walkthrough/model';
|
||||||
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
|
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
|
||||||
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||||
import { deriveBaseBranch } from '@/components/views/git/baseBranch';
|
import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useGitBranches, useGitStatus } from '@/stores/useGitStore';
|
import { useGitBranches, useGitStatus, useGitStore } from '@/stores/useGitStore';
|
||||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
import {
|
import {
|
||||||
getFreshestPrStatusForBranch,
|
getFreshestPrStatusForBranch,
|
||||||
@@ -41,6 +43,12 @@ interface WalkthroughViewProps {
|
|||||||
|
|
||||||
const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working'];
|
const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working'];
|
||||||
|
|
||||||
|
// What a walkthrough is — and what it deliberately is not — cannot be read off
|
||||||
|
// the panel: the first question users asked about it was whether its marks were
|
||||||
|
// review findings. The guide answers that, so it is reachable from the surface
|
||||||
|
// itself rather than only from the release announcement.
|
||||||
|
const WALKTHROUGH_GUIDE_URL = 'https://docs.openchamber.dev/walkthrough/';
|
||||||
|
|
||||||
// DropdownMenuLabel defaults to the same size and weight as its items, which
|
// DropdownMenuLabel defaults to the same size and weight as its items, which
|
||||||
// makes a heading read as another choice. This matches SelectLabel, the
|
// makes a heading read as another choice. This matches SelectLabel, the
|
||||||
// treatment used by the worktree picker.
|
// treatment used by the worktree picker.
|
||||||
@@ -152,6 +160,12 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
|
|
||||||
const status = useGitStatus(directory || null);
|
const status = useGitStatus(directory || null);
|
||||||
const branches = useGitBranches(directory || null);
|
const branches = useGitBranches(directory || null);
|
||||||
|
const ensureAll = useGitStore((state) => state.ensureAll);
|
||||||
|
const { github, git } = useRuntimeAPIs();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (directory) void ensureAll(directory, git);
|
||||||
|
}, [directory, ensureAll, git]);
|
||||||
|
|
||||||
// The branch source reviews everything on this branch that is not on its
|
// 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
|
// base. Three-dot semantics server-side mean merges from the base are
|
||||||
@@ -162,22 +176,33 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
if (!headRef) return null;
|
if (!headRef) return null;
|
||||||
const all = branches?.all ?? [];
|
const all = branches?.all ?? [];
|
||||||
const localBranches = all.filter((name) => !name.startsWith('remotes/'));
|
const localBranches = all.filter((name) => !name.startsWith('remotes/'));
|
||||||
|
const remoteBranches = all
|
||||||
|
.filter((name) => name.startsWith('remotes/'))
|
||||||
|
.map((name) => name.slice('remotes/'.length));
|
||||||
const remoteNames = new Set(
|
const remoteNames = new Set(
|
||||||
all
|
remoteBranches
|
||||||
.filter((name) => name.startsWith('remotes/'))
|
.map((name) => name.split('/')[0])
|
||||||
.map((name) => name.slice('remotes/'.length).split('/')[0])
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
);
|
);
|
||||||
const baseRef = deriveBaseBranch({ remoteNames, localBranches });
|
const trackingRemote = status?.tracking?.split('/')[0];
|
||||||
if (!baseRef || baseRef === headRef) return null;
|
const defaultBranch = (trackingRemote && branches?.defaultBranches?.[trackingRemote])
|
||||||
|
?? branches?.defaultBranches?.origin;
|
||||||
|
const baseRef = deriveBaseBranch({
|
||||||
|
remoteNames,
|
||||||
|
localBranches,
|
||||||
|
defaultBranch,
|
||||||
|
headBranch: headRef,
|
||||||
|
});
|
||||||
|
if (!baseRef || baseRef === headRef || !hasResolvableBaseBranch({ baseBranch: baseRef, localBranches, remoteBranches })) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return { kind: 'branch', baseRef, headRef };
|
return { kind: 'branch', baseRef, headRef };
|
||||||
}, [branches, currentBranch]);
|
}, [branches, currentBranch, status?.tracking]);
|
||||||
|
|
||||||
// The pull request for this branch used to appear only after visiting the PR
|
// 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
|
// 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
|
// store already dedupes by signature and throttles by TTL, so several panels
|
||||||
// wanting the same answer produce one request.
|
// wanting the same answer produce one request.
|
||||||
const { github } = useRuntimeAPIs();
|
|
||||||
const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false);
|
const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false);
|
||||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||||
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
||||||
@@ -323,15 +348,36 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
// Explicit pick first, then the model that actually produced what is on
|
// Explicit pick first, then the model that actually produced what is on
|
||||||
// screen, then whatever settings resolve to. The middle step is what makes
|
// screen, then whatever settings resolve to. The middle step is what makes
|
||||||
// reopening a review show the model behind it rather than the default.
|
// reopening a review show the model behind it rather than the default.
|
||||||
const activeModel = selectedModel
|
// Never present a provider without a usable login as the current selection —
|
||||||
?? (entry.result?.model ? `${entry.result.model.providerID}/${entry.result.model.modelID}` : undefined)
|
// the picker already hides them from the menu; showing one as selected was
|
||||||
?? (entry.readiness?.model ? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}` : undefined);
|
// the whole "why say so?" failure mode.
|
||||||
const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/');
|
|
||||||
const activeModelId = activeModelParts.join('/');
|
|
||||||
|
|
||||||
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
||||||
const [modelProviders, setModelProviders] = useState<string[] | undefined>(undefined);
|
const [modelProviders, setModelProviders] = useState<string[] | undefined>(undefined);
|
||||||
|
|
||||||
|
const providerIsAuthenticated = (providerId: string | undefined) => {
|
||||||
|
if (!providerId) return false;
|
||||||
|
// Until the auth list loads, do not present a candidate as selected —
|
||||||
|
// otherwise an unauthenticated config model flashes in the picker.
|
||||||
|
if (modelProviders === undefined) return false;
|
||||||
|
return modelProviders.includes(providerId);
|
||||||
|
};
|
||||||
|
const readinessModelRef = entry.readiness?.model
|
||||||
|
&& entry.readiness.model.hasLogin !== false
|
||||||
|
&& providerIsAuthenticated(entry.readiness.model.providerID)
|
||||||
|
? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}`
|
||||||
|
: undefined;
|
||||||
|
const resultModelRef = entry.result?.model
|
||||||
|
&& providerIsAuthenticated(entry.result.model.providerID)
|
||||||
|
? `${entry.result.model.providerID}/${entry.result.model.modelID}`
|
||||||
|
: undefined;
|
||||||
|
const selectedModelUsable = selectedModel
|
||||||
|
&& providerIsAuthenticated(selectedModel.split('/')[0])
|
||||||
|
? selectedModel
|
||||||
|
: undefined;
|
||||||
|
const activeModel = selectedModelUsable ?? resultModelRef ?? readinessModelRef;
|
||||||
|
const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/');
|
||||||
|
const activeModelId = activeModelParts.join('/');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modelProviders !== undefined) return;
|
if (modelProviders !== undefined) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -403,14 +449,20 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
|
|
||||||
const showStages = startedFromEmptyRef.current
|
const showStages = startedFromEmptyRef.current
|
||||||
&& (entry.status === 'generating' || stageProgress.holding);
|
&& (entry.status === 'generating' || stageProgress.holding);
|
||||||
|
// Auth/login gaps are not a full-panel blocker: hide the unusable model and
|
||||||
|
// disable Generate instead of explaining a raw provider error.
|
||||||
const blockedReason = entry.error?.code === 'context-too-small'
|
const blockedReason = entry.error?.code === 'context-too-small'
|
||||||
|| entry.error?.code === 'structured-output-unsupported'
|
|| entry.error?.code === 'structured-output-unsupported'
|
||||||
|| entry.error?.code === 'no-model'
|
|| entry.error?.code === 'no-model'
|
||||||
|| entry.error?.code === 'empty-diff'
|
|| entry.error?.code === 'empty-diff'
|
||||||
|| entry.error?.code === 'only-generated'
|
|| entry.error?.code === 'only-generated'
|
||||||
|| entry.error?.code === 'output-exhausted'
|
|| entry.error?.code === 'output-exhausted'
|
||||||
|
// Client-detected rather than reported: the server answered something that
|
||||||
|
// was not JSON, so it has no walkthrough routes at all.
|
||||||
|
|| entry.error?.code === 'server-unsupported'
|
||||||
? entry.error.code
|
? entry.error.code
|
||||||
: entry.readiness && !entry.readiness.ready && !view
|
: entry.readiness && !entry.readiness.ready && !view
|
||||||
|
&& entry.readiness.reason !== 'no-provider-login'
|
||||||
? entry.readiness.reason
|
? entry.readiness.reason
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
@@ -420,11 +472,16 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars;
|
const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars;
|
||||||
const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars;
|
const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars;
|
||||||
|
|
||||||
|
// Not ready, or no usable selected model, means Generate must not look
|
||||||
|
// actionable — including when the resolved model has no login.
|
||||||
|
const generateDisabled = !activeModel || Boolean(entry.readiness && !entry.readiness.ready);
|
||||||
|
|
||||||
const handleGenerate = useCallback(
|
const handleGenerate = useCallback(
|
||||||
(force: boolean) => {
|
(force: boolean) => {
|
||||||
|
if (generateDisabled) return;
|
||||||
void generate(directory, source, { force, language: activeLanguage });
|
void generate(directory, source, { force, language: activeLanguage });
|
||||||
},
|
},
|
||||||
[activeLanguage, directory, generate, source]
|
[activeLanguage, directory, generate, generateDisabled, source]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -495,6 +552,25 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<div className="ml-auto flex min-w-0 items-center gap-1">
|
<div className="ml-auto flex min-w-0 items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t('walkthrough.help.guide')}
|
||||||
|
onClick={() => {
|
||||||
|
void openExternalUrl(WALKTHROUGH_GUIDE_URL);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="question" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="typography-micro leading-tight">{t('walkthrough.help.guide')}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* A walkthrough nobody can read is worth nothing, so the prose
|
{/* A walkthrough nobody can read is worth nothing, so the prose
|
||||||
language is a per-review choice like the model — defaulting to the
|
language is a per-review choice like the model — defaulting to the
|
||||||
interface language, which is the best evidence of what the reader
|
interface language, which is the best evidence of what the reader
|
||||||
@@ -544,7 +620,8 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
onChange={(providerId, modelId) => {
|
onChange={(providerId, modelId) => {
|
||||||
selectModel(directory, source, providerId && modelId ? `${providerId}/${modelId}` : null);
|
selectModel(directory, source, providerId && modelId ? `${providerId}/${modelId}` : null);
|
||||||
}}
|
}}
|
||||||
allowedProviderIds={modelProviders}
|
// While the auth list is loading, allow none — not every provider.
|
||||||
|
allowedProviderIds={modelProviders ?? []}
|
||||||
isModelAllowed={isStructuredOutputCapable}
|
isModelAllowed={isStructuredOutputCapable}
|
||||||
tooltipsEnabled={false}
|
tooltipsEnabled={false}
|
||||||
dropdownPortalToBody
|
dropdownPortalToBody
|
||||||
@@ -592,7 +669,10 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={WALKTHROUGH_ACTION_CLASS}
|
className={generateDisabled
|
||||||
|
? 'border-border text-muted-foreground'
|
||||||
|
: WALKTHROUGH_ACTION_CLASS}
|
||||||
|
disabled={generateDisabled}
|
||||||
aria-label={compactHeader
|
aria-label={compactHeader
|
||||||
? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate'))
|
? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate'))
|
||||||
: undefined}
|
: undefined}
|
||||||
@@ -646,6 +726,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="ml-auto"
|
className="ml-auto"
|
||||||
|
disabled={generateDisabled}
|
||||||
// Not forced: if an entry for this exact request existed the banner
|
// Not forced: if an entry for this exact request existed the banner
|
||||||
// would not be here, and a forced run would refuse the cache it may
|
// would not be here, and a forced run would refuse the cache it may
|
||||||
// find on the way.
|
// find on the way.
|
||||||
@@ -677,7 +758,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entry.error && !blockedReason && (
|
{entry.error && !blockedReason && entry.error.code !== 'no-provider-login' && (
|
||||||
<div className="flex shrink-0 items-start gap-2 border-b border-border/60 bg-status-error/10 px-3 py-2">
|
<div className="flex shrink-0 items-start gap-2 border-b border-border/60 bg-status-error/10 px-3 py-2">
|
||||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-status-error" />
|
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-status-error" />
|
||||||
{/* Provider errors arrive as raw JSON bodies. Show a readable amount
|
{/* Provider errors arrive as raw JSON bodies. Show a readable amount
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import React, { useMemo, useEffect } from 'react';
|
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||||
import type { SupportedLanguages } from '@pierre/diffs';
|
import type { SupportedLanguages } from '@pierre/diffs';
|
||||||
import { WorkerPoolManager } from '@pierre/diffs/worker';
|
import type { WorkerPoolManager } from '@pierre/diffs/worker';
|
||||||
|
|
||||||
import { useOptionalThemeSystem } from './useThemeSystem';
|
import { useOptionalThemeSystem } from './useThemeSystem';
|
||||||
import { workerFactory } from '@/lib/diff/workerFactory';
|
|
||||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
|
||||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||||
|
import type { Theme } from '@/types/theme';
|
||||||
// NOTE: keep provider lightweight; avoid main-thread diff parsing here.
|
// NOTE: keep provider lightweight; avoid main-thread diff parsing here.
|
||||||
|
// This module must not statically import `@pierre/diffs` runtime code:
|
||||||
|
// `@pierre/diffs/worker` pulls the Shiki highlighter (core + oniguruma engine
|
||||||
|
// + grammar registry) into the eager startup graph and `initialize()` spawns
|
||||||
|
// workers plus a main-thread shared highlighter before any diff is visible.
|
||||||
|
// Everything heavy loads on demand and is only warmed after startup idle.
|
||||||
|
|
||||||
// Preload common languages for faster initial diff rendering
|
// Preload common languages for faster initial diff rendering
|
||||||
const PRELOAD_LANGS: SupportedLanguages[] = [
|
const PRELOAD_LANGS: SupportedLanguages[] = [
|
||||||
@@ -38,68 +42,90 @@ const WORKER_POOL_CONFIG: Record<WorkerPoolStyle, { poolSize: number; totalASTLR
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let unifiedWorkerPool: WorkerPoolManager | undefined;
|
type PoolModules = {
|
||||||
let splitWorkerPool: WorkerPoolManager | undefined;
|
WorkerPoolManager: typeof WorkerPoolManager;
|
||||||
|
workerFactory: () => Worker;
|
||||||
|
ensurePierreThemeRegistered: (theme: Theme) => void;
|
||||||
|
};
|
||||||
|
|
||||||
const createWorkerPool = (style: WorkerPoolStyle) => {
|
let poolModulesPromise: Promise<PoolModules> | null = null;
|
||||||
const config = WORKER_POOL_CONFIG[style];
|
|
||||||
const pool = new WorkerPoolManager(
|
const loadPoolModules = (): Promise<PoolModules> => {
|
||||||
{
|
poolModulesPromise ??= Promise.all([
|
||||||
workerFactory,
|
import('@pierre/diffs/worker'),
|
||||||
poolSize: config.poolSize,
|
import('@/lib/diff/workerFactory'),
|
||||||
totalASTLRUCacheSize: config.totalASTLRUCacheSize,
|
import('@/lib/shiki/appThemeRegistry'),
|
||||||
},
|
]).then(([workerModule, factoryModule, themeRegistryModule]) => ({
|
||||||
{
|
WorkerPoolManager: workerModule.WorkerPoolManager,
|
||||||
theme: {
|
workerFactory: factoryModule.workerFactory,
|
||||||
light: 'pierre-light',
|
ensurePierreThemeRegistered: themeRegistryModule.ensurePierreThemeRegistered,
|
||||||
dark: 'pierre-dark',
|
}));
|
||||||
|
return poolModulesPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pools: Partial<Record<WorkerPoolStyle, WorkerPoolManager>> = {};
|
||||||
|
const poolsRequested = new Set<WorkerPoolStyle>();
|
||||||
|
const poolListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
let currentRenderTheme: { light: string; dark: string } = {
|
||||||
|
light: 'pierre-light',
|
||||||
|
dark: 'pierre-dark',
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyPoolListeners = () => {
|
||||||
|
for (const listener of poolListeners) listener();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyRenderOptions = (style: WorkerPoolStyle, pool: WorkerPoolManager) => {
|
||||||
|
void pool.setRenderOptions({
|
||||||
|
theme: currentRenderTheme,
|
||||||
|
lineDiffType: WORKER_POOL_CONFIG[style].lineDiffType,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePool = (style: WorkerPoolStyle): void => {
|
||||||
|
if (typeof window === 'undefined' || poolsRequested.has(style)) return;
|
||||||
|
poolsRequested.add(style);
|
||||||
|
void loadPoolModules().then((modules) => {
|
||||||
|
if (pools[style]) return;
|
||||||
|
const config = WORKER_POOL_CONFIG[style];
|
||||||
|
const pool = new modules.WorkerPoolManager(
|
||||||
|
{
|
||||||
|
workerFactory: modules.workerFactory,
|
||||||
|
poolSize: config.poolSize,
|
||||||
|
totalASTLRUCacheSize: config.totalASTLRUCacheSize,
|
||||||
},
|
},
|
||||||
langs: PRELOAD_LANGS,
|
{
|
||||||
lineDiffType: config.lineDiffType,
|
theme: {
|
||||||
preferredHighlighter: 'shiki-wasm',
|
light: 'pierre-light',
|
||||||
}
|
dark: 'pierre-dark',
|
||||||
);
|
},
|
||||||
void pool.initialize();
|
langs: PRELOAD_LANGS,
|
||||||
return pool;
|
lineDiffType: config.lineDiffType,
|
||||||
|
preferredHighlighter: 'shiki-wasm',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
void pool.initialize();
|
||||||
|
pools[style] = pool;
|
||||||
|
applyRenderOptions(style, pool);
|
||||||
|
notifyPoolListeners();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getWorkerPool = (style: WorkerPoolStyle): WorkerPoolManager | undefined => {
|
const subscribeToPools = (listener: () => void): (() => void) => {
|
||||||
if (typeof window === 'undefined') {
|
poolListeners.add(listener);
|
||||||
return undefined;
|
return () => poolListeners.delete(listener);
|
||||||
}
|
|
||||||
|
|
||||||
if (style === 'split') {
|
|
||||||
splitWorkerPool ??= createWorkerPool('split');
|
|
||||||
return splitWorkerPool;
|
|
||||||
}
|
|
||||||
|
|
||||||
unifiedWorkerPool ??= createWorkerPool('unified');
|
|
||||||
return unifiedWorkerPool;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const WorkerPoolWarmup: React.FC<{
|
const setRenderTheme = (renderTheme: { light: string; dark: string }) => {
|
||||||
children: React.ReactNode;
|
if (currentRenderTheme.light === renderTheme.light && currentRenderTheme.dark === renderTheme.dark) {
|
||||||
renderTheme: { light: string; dark: string };
|
return;
|
||||||
}> = ({ children, renderTheme }) => {
|
}
|
||||||
const unifiedPool = useWorkerPool('unified');
|
currentRenderTheme = renderTheme;
|
||||||
const splitPool = useWorkerPool('split');
|
for (const style of Object.keys(pools) as WorkerPoolStyle[]) {
|
||||||
|
const pool = pools[style];
|
||||||
useEffect(() => {
|
if (pool) applyRenderOptions(style, pool);
|
||||||
if (unifiedPool) {
|
}
|
||||||
void unifiedPool.setRenderOptions({
|
|
||||||
theme: renderTheme,
|
|
||||||
lineDiffType: WORKER_POOL_CONFIG.unified.lineDiffType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (splitPool) {
|
|
||||||
void splitPool.setRenderOptions({
|
|
||||||
theme: renderTheme,
|
|
||||||
lineDiffType: WORKER_POOL_CONFIG.split.lineDiffType,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [renderTheme, splitPool, unifiedPool]);
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children }) => {
|
export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children }) => {
|
||||||
@@ -118,25 +144,54 @@ export const DiffWorkerProvider: React.FC<DiffWorkerProviderProps> = ({ children
|
|||||||
themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
|
themeSystem?.availableThemes.find((theme) => theme.metadata.id === darkThemeId) ??
|
||||||
fallbackDark;
|
fallbackDark;
|
||||||
|
|
||||||
ensurePierreThemeRegistered(lightTheme);
|
// Register the active app themes with @pierre/diffs and forward them to any
|
||||||
ensurePierreThemeRegistered(darkTheme);
|
// live pools. Registration goes through the deferred module load so the
|
||||||
|
// theme registry (and its @pierre/diffs import) stays out of the eager
|
||||||
|
// startup graph; each diff surface also registers the themes it renders
|
||||||
|
// with, so ordering is preserved even before this resolves.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void loadPoolModules().then((modules) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
modules.ensurePierreThemeRegistered(lightTheme);
|
||||||
|
modules.ensurePierreThemeRegistered(darkTheme);
|
||||||
|
setRenderTheme({ light: lightTheme.metadata.id, dark: darkTheme.metadata.id });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [darkTheme, lightTheme]);
|
||||||
|
|
||||||
const renderTheme = useMemo(
|
// Warm the worker pools once startup work has settled so the first diff a
|
||||||
() => ({
|
// user opens does not pay worker spawn + highlighter init. Idle-deferred:
|
||||||
light: lightTheme.metadata.id,
|
// warming competed with initial load (3 workers, shiki grammars, oniguruma
|
||||||
dark: darkTheme.metadata.id,
|
// wasm) when it ran during mount.
|
||||||
}),
|
useEffect(() => {
|
||||||
[darkTheme.metadata.id, lightTheme.metadata.id],
|
if (typeof window === 'undefined') return;
|
||||||
);
|
const warm = () => {
|
||||||
|
ensurePool('unified');
|
||||||
|
ensurePool('split');
|
||||||
|
};
|
||||||
|
if (typeof window.requestIdleCallback === 'function') {
|
||||||
|
const handle = window.requestIdleCallback(warm, { timeout: 5000 });
|
||||||
|
return () => window.cancelIdleCallback(handle);
|
||||||
|
}
|
||||||
|
const timeout = window.setTimeout(warm, 2000);
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return <>{children}</>;
|
||||||
<WorkerPoolWarmup renderTheme={renderTheme}>
|
|
||||||
{children}
|
|
||||||
</WorkerPoolWarmup>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export const useWorkerPool = (style: WorkerPoolStyle = 'unified'): WorkerPoolManager | undefined => {
|
export const useWorkerPool = (style: WorkerPoolStyle = 'unified'): WorkerPoolManager | undefined => {
|
||||||
return useMemo(() => getWorkerPool(style), [style]);
|
const pool = useSyncExternalStore(
|
||||||
|
subscribeToPools,
|
||||||
|
() => pools[style],
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
ensurePool(style);
|
||||||
|
}, [style]);
|
||||||
|
return pool;
|
||||||
};
|
};
|
||||||
|
|||||||
+4
@@ -1,3 +1,7 @@
|
|||||||
|
// Shared wall-clock ticker for live duration readouts (tool runtimes, session
|
||||||
|
// activity counters). Subscribers of the same interval share one timer and one
|
||||||
|
// `now`, so N live rows cost one interval rather than N.
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
type Subscriber = (now: number) => void;
|
type Subscriber = (now: number) => void;
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { isDesktopShell, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop';
|
import {
|
||||||
|
canRequestNativeDirectoryAccess,
|
||||||
|
isDesktopShell,
|
||||||
|
requestDirectoryAccess,
|
||||||
|
startAccessingDirectory,
|
||||||
|
stopAccessingDirectory,
|
||||||
|
} from '@/lib/desktop';
|
||||||
|
|
||||||
export const useFileSystemAccess = () => {
|
export const useFileSystemAccess = () => {
|
||||||
const [isDesktop, setIsDesktop] = useState(false);
|
const [isDesktop, setIsDesktop] = useState(false);
|
||||||
|
const [canRequestAccess, setCanRequestAccess] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsDesktop(isDesktopShell());
|
setIsDesktop(isDesktopShell());
|
||||||
|
setCanRequestAccess(canRequestNativeDirectoryAccess());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
||||||
@@ -34,6 +42,7 @@ export const useFileSystemAccess = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isDesktop,
|
isDesktop,
|
||||||
|
canRequestAccess,
|
||||||
requestAccess,
|
requestAccess,
|
||||||
startAccessing,
|
startAccessing,
|
||||||
stopAccessing
|
stopAccessing
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
|
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
|
||||||
import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts';
|
import {
|
||||||
|
eventMatchesShortcut,
|
||||||
|
eventMatchesShortcutPrefix,
|
||||||
|
getEffectiveShortcutCombo,
|
||||||
|
getEffectiveShortcutPrefix,
|
||||||
|
normalizeCombo,
|
||||||
|
} from '@/lib/shortcuts';
|
||||||
|
import { getVisibleContextRailSurfaces } from '@/lib/surfaces/registry';
|
||||||
import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap';
|
import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||||
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
|
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
|
||||||
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
import { focusChatInput } from '@/components/chat/composer/editor/dom';
|
||||||
import { addSelectionToChat } from '@/lib/addSelectionToChat';
|
import { addSelectionToChat } from '@/lib/addSelectionToChat';
|
||||||
@@ -29,6 +38,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
|
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
|
||||||
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
|
||||||
const currentShortcutDirectory = useDirectoryStore((s) => s.currentDirectory);
|
const currentShortcutDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
|
const effectiveDirectory = useEffectiveDirectory();
|
||||||
|
|
||||||
// The terminal lives in the context panel; these mirror the rail behavior.
|
// The terminal lives in the context panel; these mirror the rail behavior.
|
||||||
const toggleTerminalSurface = React.useCallback(() => {
|
const toggleTerminalSurface = React.useCallback(() => {
|
||||||
@@ -64,6 +74,9 @@ export const useKeyboardShortcuts = () => {
|
|||||||
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
const abortPrimedUntilRef = React.useRef<number | null>(null);
|
||||||
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const themeModeRef = React.useRef(themeMode);
|
const themeModeRef = React.useRef(themeMode);
|
||||||
|
// Currently held physical keys (lowercased), used to match chord prefixes
|
||||||
|
// whose primary key must be held while the activating key is pressed.
|
||||||
|
const heldKeysRef = React.useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
themeModeRef.current = themeMode;
|
themeModeRef.current = themeMode;
|
||||||
@@ -80,6 +93,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
|
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
|
||||||
|
const switchSurfacePrefix = getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides);
|
||||||
const dropdownTargetSelector = [
|
const dropdownTargetSelector = [
|
||||||
'[data-slot="dropdown-menu-content"]',
|
'[data-slot="dropdown-menu-content"]',
|
||||||
'[data-slot="select-content"]',
|
'[data-slot="select-content"]',
|
||||||
@@ -448,16 +462,6 @@ export const useKeyboardShortcuts = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventMatchesShortcut(e, combo('open_diff_panel'))) {
|
|
||||||
const state = useUIStore.getState();
|
|
||||||
if (state.isMobile || !currentDirectory) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'diff');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventMatchesShortcut(e, combo('toggle_terminal'))) {
|
if (eventMatchesShortcut(e, combo('toggle_terminal'))) {
|
||||||
const { isMobile } = useUIStore.getState();
|
const { isMobile } = useUIStore.getState();
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -478,6 +482,39 @@ export const useKeyboardShortcuts = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configured prefix + digit (default: Cmd/Ctrl + 1..9, with 0 for the
|
||||||
|
// 10th surface): open/close the matching context panel rail surface. The
|
||||||
|
// digit maps to the currently visible rail order, matching the number
|
||||||
|
// badges shown while holding the modifier. `e.repeat` guard keeps
|
||||||
|
// holding a digit from toggling.
|
||||||
|
const switchSurfaceDigit = e.key.length === 1 && e.key >= '0' && e.key <= '9'
|
||||||
|
? (e.key === '0' ? 10 : Number(e.key))
|
||||||
|
: null;
|
||||||
|
if (switchSurfaceDigit !== null
|
||||||
|
&& !e.repeat
|
||||||
|
&& eventMatchesShortcutPrefix(e, switchSurfacePrefix, heldKeysRef.current)) {
|
||||||
|
const state = useUIStore.getState();
|
||||||
|
if (state.isMobile || !effectiveDirectory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const directory = normalizeContextPanelDirectoryKey(effectiveDirectory);
|
||||||
|
const panelState = state.contextPanelByDirectory[directory];
|
||||||
|
const visibleSurfaces = getVisibleContextRailSurfaces({
|
||||||
|
railOrder: state.contextRailOrder,
|
||||||
|
planModeEnabled: useFeatureFlagsStore.getState().planModeEnabled,
|
||||||
|
isVSCode: isVSCodeRuntime(),
|
||||||
|
screenWidth: window.innerWidth,
|
||||||
|
tabs: panelState?.tabs ?? [],
|
||||||
|
});
|
||||||
|
const target = visibleSurfaces[switchSurfaceDigit - 1];
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
state.openContextSurface(directory, target.mode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Cmd/Ctrl+Shift+M: Open model selector (same conditions as double-ESC: chat tab, no overlays)
|
// Cmd/Ctrl+Shift+M: Open model selector (same conditions as double-ESC: chat tab, no overlays)
|
||||||
if (eventMatchesShortcut(e, combo('open_model_selector'))) {
|
if (eventMatchesShortcut(e, combo('open_model_selector'))) {
|
||||||
const {
|
const {
|
||||||
@@ -618,11 +655,30 @@ export const useKeyboardShortcuts = () => {
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Track held physical keys so chord prefixes (e.g. a configured
|
||||||
|
// `mod+p`) can require their primary key to stay held. Capture phase runs
|
||||||
|
// before handleKeyDown, so the set is current when chord matching runs.
|
||||||
|
const handleKeyHoldDown = (e: KeyboardEvent) => {
|
||||||
|
heldKeysRef.current.add(e.key.toLowerCase());
|
||||||
|
};
|
||||||
|
const handleKeyUp = (e: KeyboardEvent) => {
|
||||||
|
heldKeysRef.current.delete(e.key.toLowerCase());
|
||||||
|
};
|
||||||
|
const handleWindowBlur = () => {
|
||||||
|
heldKeysRef.current.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyHoldDown, true);
|
||||||
|
window.addEventListener('keyup', handleKeyUp, true);
|
||||||
|
window.addEventListener('blur', handleWindowBlur);
|
||||||
window.addEventListener('keydown', handleTerminalShortcutCapture, true);
|
window.addEventListener('keydown', handleTerminalShortcutCapture, true);
|
||||||
window.addEventListener('keydown', handleEscapeKeyDownCapture, true);
|
window.addEventListener('keydown', handleEscapeKeyDownCapture, true);
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyHoldDown, true);
|
||||||
|
window.removeEventListener('keyup', handleKeyUp, true);
|
||||||
|
window.removeEventListener('blur', handleWindowBlur);
|
||||||
window.removeEventListener('keydown', handleTerminalShortcutCapture, true);
|
window.removeEventListener('keydown', handleTerminalShortcutCapture, true);
|
||||||
window.removeEventListener('keydown', handleEscapeKeyDownCapture, true);
|
window.removeEventListener('keydown', handleEscapeKeyDownCapture, true);
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
@@ -650,6 +706,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
resetAbortPriming,
|
resetAbortPriming,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
currentDirectory,
|
currentDirectory,
|
||||||
|
effectiveDirectory,
|
||||||
activeProject?.id,
|
activeProject?.id,
|
||||||
activeProject?.path,
|
activeProject?.path,
|
||||||
shortcutOverrides,
|
shortcutOverrides,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||||
import type { Agent } from '@opencode-ai/sdk/v2';
|
import type { Agent, Message } from '@opencode-ai/sdk/v2';
|
||||||
import type { QueuedMessage } from '../stores/messageQueueStore';
|
import type { QueuedMessage } from '../stores/messageQueueStore';
|
||||||
|
import { ChildStoreManager } from '@/sync/child-store';
|
||||||
|
import { setSyncRefs } from '@/sync/sync-refs';
|
||||||
|
|
||||||
let visibleAgents: Agent[] = [];
|
let visibleAgents: Agent[] = [];
|
||||||
const sendMessageCalls: unknown[][] = [];
|
const sendMessageCalls: unknown[][] = [];
|
||||||
@@ -32,6 +34,7 @@ import {
|
|||||||
createQueuedAutoSendRetryScheduler,
|
createQueuedAutoSendRetryScheduler,
|
||||||
getQueuedAutoSendRetryDelayMs,
|
getQueuedAutoSendRetryDelayMs,
|
||||||
isQueuedAutoSendBackedOff,
|
isQueuedAutoSendBackedOff,
|
||||||
|
resolveQueuedSessionStatusType,
|
||||||
sendQueuedAutoSendPayload,
|
sendQueuedAutoSendPayload,
|
||||||
shouldDispatchQueuedAutoSend,
|
shouldDispatchQueuedAutoSend,
|
||||||
} from './useQueuedMessageAutoSend';
|
} from './useQueuedMessageAutoSend';
|
||||||
@@ -119,6 +122,59 @@ describe('queued auto-send retry backoff', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveQueuedSessionStatusType', () => {
|
||||||
|
const DIRECTORY = '/repo';
|
||||||
|
|
||||||
|
const assistantMessage = (id: string, completed?: number): Message => ({
|
||||||
|
id,
|
||||||
|
role: 'assistant',
|
||||||
|
sessionID: 'ses_1',
|
||||||
|
time: { created: 1, ...(completed !== undefined ? { completed } : {}) },
|
||||||
|
} as Message);
|
||||||
|
|
||||||
|
let childStores: ChildStoreManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
childStores = new ChildStoreManager();
|
||||||
|
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
|
||||||
|
store.setState({ status: 'complete', session_status: {}, message: {} });
|
||||||
|
setSyncRefs({} as never, childStores, DIRECTORY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats a session with an in-flight assistant turn as busy even when the status entry is missing', () => {
|
||||||
|
// The server status map only lists busy/retry sessions, so a missed busy
|
||||||
|
// event leaves NO status entry while the turn is still streaming. The
|
||||||
|
// queue gate must not read that absence as idle: queued prompts would be
|
||||||
|
// dispatched into the running turn and merged into one model response.
|
||||||
|
childStores.ensureChild(DIRECTORY, { bootstrap: false }).setState({
|
||||||
|
message: { ses_1: [assistantMessage('msg_streaming')] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('busy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves an explicit busy or retry status entry', () => {
|
||||||
|
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
|
||||||
|
store.setState({ session_status: { ses_1: { type: 'busy' } } });
|
||||||
|
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('busy');
|
||||||
|
store.setState({ session_status: { ses_1: { type: 'retry', attempt: 2, message: 'boom', next: 30 } } });
|
||||||
|
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('retry');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves idle when the trailing assistant message has completed', () => {
|
||||||
|
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
|
||||||
|
store.setState({ message: { ses_1: [assistantMessage('msg_done', 5)] } });
|
||||||
|
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('idle');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves an explicit idle entry and unknown sessions as idle', () => {
|
||||||
|
const store = childStores.ensureChild(DIRECTORY, { bootstrap: false });
|
||||||
|
store.setState({ session_status: { ses_1: { type: 'idle' } } });
|
||||||
|
expect(resolveQueuedSessionStatusType('ses_1', DIRECTORY)).toBe('idle');
|
||||||
|
expect(resolveQueuedSessionStatusType('ses_unknown', DIRECTORY)).toBe('idle');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('buildQueuedAutoSendPayload', () => {
|
describe('buildQueuedAutoSendPayload', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
visibleAgents = [];
|
visibleAgents = [];
|
||||||
@@ -216,7 +272,11 @@ describe('buildQueuedAutoSendPayload', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(payload).not.toBeNull();
|
expect(payload).not.toBeNull();
|
||||||
await sendQueuedAutoSendPayload('session-original', '/repo', payload!, {
|
await sendQueuedAutoSendPayload({
|
||||||
|
runtimeKey: 'runtime-original',
|
||||||
|
sessionId: 'session-original',
|
||||||
|
directory: '/repo',
|
||||||
|
}, payload!, {
|
||||||
providerID: 'provider-1',
|
providerID: 'provider-1',
|
||||||
modelID: 'model-1',
|
modelID: 'model-1',
|
||||||
agent: 'agent-1',
|
agent: 'agent-1',
|
||||||
@@ -234,7 +294,13 @@ describe('buildQueuedAutoSendPayload', () => {
|
|||||||
undefined,
|
undefined,
|
||||||
'variant-1',
|
'variant-1',
|
||||||
'normal',
|
'normal',
|
||||||
{ sessionId: 'session-original', directory: '/repo' },
|
{
|
||||||
|
target: {
|
||||||
|
runtimeKey: 'runtime-original',
|
||||||
|
sessionId: 'session-original',
|
||||||
|
directory: '/repo',
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,8 +103,7 @@ type ResolvedQueuedSendConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const sendQueuedAutoSendPayload = (
|
export const sendQueuedAutoSendPayload = (
|
||||||
sessionId: string,
|
target: MessageQueueTarget,
|
||||||
directory: string,
|
|
||||||
payload: QueuedAutoSendPayload,
|
payload: QueuedAutoSendPayload,
|
||||||
resolved: ResolvedQueuedSendConfig,
|
resolved: ResolvedQueuedSendConfig,
|
||||||
) => {
|
) => {
|
||||||
@@ -118,7 +117,7 @@ export const sendQueuedAutoSendPayload = (
|
|||||||
undefined,
|
undefined,
|
||||||
resolved.variant,
|
resolved.variant,
|
||||||
'normal',
|
'normal',
|
||||||
{ sessionId, directory },
|
{ target },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,11 +172,50 @@ export const shouldDispatchQueuedAutoSend = (
|
|||||||
&& currentStatusType === 'idle';
|
&& currentStatusType === 'idle';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the live status the queue gate should honor for a session.
|
||||||
|
*
|
||||||
|
* The server's `/session/status` map only lists busy/retry sessions — idle
|
||||||
|
* sessions are absent — so a missing entry means "idle per the snapshot", not
|
||||||
|
* "no information". A missed busy event therefore leaves no entry while a turn
|
||||||
|
* is still streaming. The trailing in-flight assistant message is the live
|
||||||
|
* evidence of that running turn: treat it as busy so the queue never dispatches
|
||||||
|
* into it (mirrors `useSessionActivity`'s fallback). The entry becomes idle the
|
||||||
|
* moment the message completes or an idle status event lands. This reads the
|
||||||
|
* directory child store directly so both the effect-loop gate and the
|
||||||
|
* dispatch-time re-check agree.
|
||||||
|
*/
|
||||||
|
export const resolveQueuedSessionStatusType = (
|
||||||
|
sessionId: string,
|
||||||
|
directory: string,
|
||||||
|
): SessionStatusType => {
|
||||||
|
const state = getDirectoryState(directory);
|
||||||
|
const statusType = state?.session_status?.[sessionId]?.type;
|
||||||
|
if (statusType === 'busy' || statusType === 'retry') {
|
||||||
|
return statusType;
|
||||||
|
}
|
||||||
|
const sessionMessages = state?.message?.[sessionId];
|
||||||
|
const lastMessage = sessionMessages && sessionMessages.length > 0
|
||||||
|
? sessionMessages[sessionMessages.length - 1]
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
lastMessage?.role === 'assistant'
|
||||||
|
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number'
|
||||||
|
) {
|
||||||
|
return 'busy';
|
||||||
|
}
|
||||||
|
return 'idle';
|
||||||
|
};
|
||||||
|
|
||||||
export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) {
|
export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?: boolean }) {
|
||||||
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true);
|
const enabled = typeof enabledOrOptions === 'boolean' ? enabledOrOptions : (enabledOrOptions?.enabled ?? true);
|
||||||
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
|
const queuedMessages = useMessageQueueStore((state) => state.queuedMessages);
|
||||||
const autoReviewRuns = useAutoReviewStore((state) => state.runsByOriginalSessionID);
|
const autoReviewRuns = useAutoReviewStore((state) => state.runsByOriginalSessionID);
|
||||||
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
|
const sessionStatusRecord = useDirectorySync((state) => state.session_status);
|
||||||
|
// Message completion clears the in-flight fallback in
|
||||||
|
// resolveQueuedSessionStatusType; subscribe so the queue drains the moment
|
||||||
|
// the trailing assistant message completes even if status events were missed.
|
||||||
|
const sessionMessages = useDirectorySync((state) => state.message);
|
||||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
|
|
||||||
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
|
const inFlightSessionsRef = React.useRef<Set<string>>(new Set());
|
||||||
@@ -216,7 +254,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStatus = getDirectoryState(target.directory)?.session_status?.[sessionId]?.type ?? 'idle';
|
const currentStatus = resolveQueuedSessionStatusType(sessionId, target.directory);
|
||||||
if (currentStatus !== 'idle') {
|
if (currentStatus !== 'idle') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -256,7 +294,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
|||||||
useMessageQueueStore.getState().markSending(target, payload.queuedMessageId);
|
useMessageQueueStore.getState().markSending(target, payload.queuedMessageId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendQueuedAutoSendPayload(sessionId, target.directory, payload, {
|
await sendQueuedAutoSendPayload(target, payload, {
|
||||||
providerID: resolved.providerID,
|
providerID: resolved.providerID,
|
||||||
modelID: resolved.modelID,
|
modelID: resolved.modelID,
|
||||||
agent: resolved.agent,
|
agent: resolved.agent,
|
||||||
@@ -294,7 +332,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
|||||||
const target = parseMessageQueueKey(key);
|
const target = parseMessageQueueKey(key);
|
||||||
if (!target || target.runtimeKey !== getRuntimeKey() || target.directory !== currentDirectory) return;
|
if (!target || target.runtimeKey !== getRuntimeKey() || target.directory !== currentDirectory) return;
|
||||||
const { sessionId } = target;
|
const { sessionId } = target;
|
||||||
const currentStatusType = (statusRecord[sessionId]?.type ?? 'idle') as SessionStatusType;
|
const currentStatusType = resolveQueuedSessionStatusType(sessionId, target.directory);
|
||||||
const previousStatusType = previousStatusRef.current.get(sessionId);
|
const previousStatusType = previousStatusRef.current.get(sessionId);
|
||||||
const wasAutoReviewBlocked = autoReviewBlockedSessionsRef.current.has(sessionId);
|
const wasAutoReviewBlocked = autoReviewBlockedSessionsRef.current.has(sessionId);
|
||||||
const isAutoReviewRunning = useAutoReviewStore.getState().isRunningForSession(sessionId);
|
const isAutoReviewRunning = useAutoReviewStore.getState().isRunningForSession(sessionId);
|
||||||
@@ -315,5 +353,5 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
|||||||
});
|
});
|
||||||
|
|
||||||
previousStatusRef.current = nextStatusMap;
|
previousStatusRef.current = nextStatusMap;
|
||||||
}, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory, retryTick, retryScheduler]);
|
}, [enabled, queuedMessages, sessionStatusRecord, sessionMessages, autoReviewRuns, currentDirectory, retryTick, retryScheduler]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1804,8 +1804,10 @@ input[aria-label="Terminal input"] {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings dialog: hide the overlay scrollbar; wheel/keyboard scroll still works. */
|
/* Settings dialog: hide the overlay scrollbar on mobile/web; wheel/keyboard
|
||||||
[data-settings-view="true"] .overlay-scrollbar {
|
scroll still works. Desktop shells keep the persistent scrollbar so the
|
||||||
|
Settings sub-panels aren't left with no scroll affordance at all. */
|
||||||
|
html:not(.desktop-runtime) [data-settings-view="true"] .overlay-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FilesystemError,
|
||||||
|
isFilesystemError,
|
||||||
|
parseFilesystemErrorReason,
|
||||||
|
} from './files-errors';
|
||||||
|
|
||||||
|
describe('FilesystemError', () => {
|
||||||
|
test('retains a stable reason and HTTP status', () => {
|
||||||
|
const error = new FilesystemError('Access denied', {
|
||||||
|
reason: 'os-permission',
|
||||||
|
status: 403,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(isFilesystemError(error)).toBe(true);
|
||||||
|
expect(error.name).toBe('FilesystemError');
|
||||||
|
expect(error.message).toBe('Access denied');
|
||||||
|
expect(error.reason).toBe('os-permission');
|
||||||
|
expect(error.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes unsupported response reasons to unknown', () => {
|
||||||
|
expect(parseFilesystemErrorReason('os-permission')).toBe('os-permission');
|
||||||
|
expect(parseFilesystemErrorReason('made-up')).toBe('unknown');
|
||||||
|
expect(parseFilesystemErrorReason(undefined)).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export type FilesystemErrorReason =
|
||||||
|
| 'os-permission'
|
||||||
|
| 'not-found'
|
||||||
|
| 'not-directory'
|
||||||
|
| 'invalid-response'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export class FilesystemError extends Error {
|
||||||
|
readonly reason: FilesystemErrorReason;
|
||||||
|
readonly status?: number;
|
||||||
|
|
||||||
|
constructor(message: string, options: { reason?: FilesystemErrorReason; status?: number } = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'FilesystemError';
|
||||||
|
this.reason = options.reason ?? 'unknown';
|
||||||
|
this.status = options.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isFilesystemError = (error: unknown): error is FilesystemError => (
|
||||||
|
error instanceof FilesystemError
|
||||||
|
|| Boolean(
|
||||||
|
error
|
||||||
|
&& typeof error === 'object'
|
||||||
|
&& 'reason' in error
|
||||||
|
&& typeof (error as { reason?: unknown }).reason === 'string'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const parseFilesystemErrorReason = (value: unknown): FilesystemErrorReason => {
|
||||||
|
switch (value) {
|
||||||
|
case 'os-permission':
|
||||||
|
case 'not-found':
|
||||||
|
case 'not-directory':
|
||||||
|
case 'invalid-response':
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -183,6 +183,7 @@ export interface GitBranch {
|
|||||||
all: string[];
|
all: string[];
|
||||||
current: string;
|
current: string;
|
||||||
branches: Record<string, GitBranchDetails>;
|
branches: Record<string, GitBranchDetails>;
|
||||||
|
defaultBranches?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GitCommitSummary {
|
interface GitCommitSummary {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildPairingConnectionPayload,
|
buildPairingConnectionPayload,
|
||||||
encodePairingConnectionPayload,
|
encodePairingConnectionPayload,
|
||||||
parsePairingConnectionPayload,
|
parsePairingConnectionPayload,
|
||||||
|
parsePairingConnectionPayloadString,
|
||||||
} from './connectionPayload';
|
} from './connectionPayload';
|
||||||
|
|
||||||
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
||||||
@@ -103,3 +104,45 @@ describe('connection payload helpers', () => {
|
|||||||
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${expired}`)).toBeNull();
|
expect(parsePairingConnectionPayload(`openchamber://connect?v=2&p=${expired}`)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parsePairingConnectionPayloadString (Android WebView fallback)', () => {
|
||||||
|
const payload = buildPairingConnectionPayload({
|
||||||
|
pairingId: 'pair_123',
|
||||||
|
secret: 'one-time-secret',
|
||||||
|
label: 'Desktop',
|
||||||
|
candidates: [
|
||||||
|
{ type: 'lan', url: 'http://192.168.1.20:4096', priority: 20 },
|
||||||
|
{ type: 'relay', relayUrl: 'wss://relay.example/ws', serverId: 'srv_abc', hostEncPubJwk, priority: 30 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const encoded = encodePairingConnectionPayload(payload);
|
||||||
|
|
||||||
|
test('parses the canonical link identically to the URL-based parser', () => {
|
||||||
|
// Old Android WebViews resolve the same string with hostname "" / pathname "//connect";
|
||||||
|
// the string parser must not depend on the URL API to succeed.
|
||||||
|
expect(parsePairingConnectionPayloadString(encoded)).toEqual(parsePairingConnectionPayload(encoded));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovers a link whose scheme/host case the URL parser would reject', () => {
|
||||||
|
const mixedCase = encoded.replace('openchamber://connect', 'OpenChamber://CONNECT');
|
||||||
|
expect(parsePairingConnectionPayload(mixedCase)).toBeNull();
|
||||||
|
expect(parsePairingConnectionPayloadString(mixedCase)).toEqual(parsePairingConnectionPayload(encoded));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tolerates a trailing slash and reordered query params', () => {
|
||||||
|
const trailingSlash = encoded.replace('openchamber://connect?', 'openchamber://connect/?');
|
||||||
|
expect(parsePairingConnectionPayloadString(trailingSlash)).toEqual(parsePairingConnectionPayload(encoded));
|
||||||
|
|
||||||
|
const p = encoded.slice(encoded.indexOf('p=') + 2);
|
||||||
|
expect(parsePairingConnectionPayloadString(`openchamber://connect?p=${p}&v=2`)).toEqual(parsePairingConnectionPayload(encoded));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still rejects non-pairing and malformed payloads', () => {
|
||||||
|
expect(parsePairingConnectionPayloadString('')).toBeNull();
|
||||||
|
expect(parsePairingConnectionPayloadString('hello world')).toBeNull();
|
||||||
|
expect(parsePairingConnectionPayloadString('openchamber://connect')).toBeNull();
|
||||||
|
expect(parsePairingConnectionPayloadString('openchamber:///connect?v=2&p=x')).toBeNull();
|
||||||
|
expect(parsePairingConnectionPayloadString('openchamber://connect?v=1&server=http%3A%2F%2F192.168.1.10%3A2606&token=t')).toBeNull();
|
||||||
|
expect(parsePairingConnectionPayloadString('openchamber://connect?v=2&p=not-json')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -212,3 +212,35 @@ export const parsePairingConnectionPayload = (value: string): PairingConnectionP
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// URL-string-only sibling of parsePairingConnectionPayload. Old Android WebViews
|
||||||
|
// (e.g. WebView 114) mis-parse non-special schemes: `new URL('openchamber://connect?...')`
|
||||||
|
// yields hostname "" and pathname "//connect", so the URL-based parser above rejects a
|
||||||
|
// perfectly valid pairing link. This parser never touches the URL/URLSearchParams APIs —
|
||||||
|
// it matches the head with a regex and reads `v`/`p` straight off the query string.
|
||||||
|
// Used by the Android QR-scan path after the standard parse fails; keeps every existing
|
||||||
|
// validation (version, payload length, base64url, candidate normalization).
|
||||||
|
export const parsePairingConnectionPayloadString = (value: string): PairingConnectionPayload | null => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
||||||
|
const question = trimmed.indexOf('?');
|
||||||
|
if (question === -1 || !/^openchamber:\/\/connect\/?$/i.test(trimmed.slice(0, question))) return null;
|
||||||
|
let version: string | null = null;
|
||||||
|
let encoded: string | null = null;
|
||||||
|
for (const part of trimmed.slice(question + 1).split('&')) {
|
||||||
|
const eq = part.indexOf('=');
|
||||||
|
if (eq === -1) continue;
|
||||||
|
const key = part.slice(0, eq);
|
||||||
|
const value_ = part.slice(eq + 1);
|
||||||
|
if (key === 'v') version = value_;
|
||||||
|
else if (key === 'p') encoded = value_;
|
||||||
|
}
|
||||||
|
if (version !== '2' || !encoded || encoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
||||||
|
const decoded = base64UrlDecode(encoded);
|
||||||
|
if (!decoded || decoded.length > MAX_PAIRING_PAYLOAD_LENGTH) return null;
|
||||||
|
try {
|
||||||
|
return normalizePairingPayload(JSON.parse(decoded) as unknown);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -531,6 +531,10 @@ export const isDesktopShell = (): boolean => {
|
|||||||
return isElectronShell();
|
return isElectronShell();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const canRequestNativeDirectoryAccess = (): boolean => (
|
||||||
|
isDesktopShell() && hasDesktopInvoke() && isDesktopLocalOriginActive()
|
||||||
|
);
|
||||||
|
|
||||||
export const startDesktopWindowDrag = async (): Promise<boolean> => {
|
export const startDesktopWindowDrag = async (): Promise<boolean> => {
|
||||||
if (!isDesktopShell()) {
|
if (!isDesktopShell()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -586,12 +590,13 @@ export const requestDirectoryAccess = async (
|
|||||||
directoryPath: string
|
directoryPath: string
|
||||||
): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
|
||||||
// Desktop shell on local instance: use native folder picker.
|
// Desktop shell on local instance: use native folder picker.
|
||||||
if (hasDesktopInvoke() && isDesktopLocalOriginActive()) {
|
if (canRequestNativeDirectoryAccess()) {
|
||||||
try {
|
try {
|
||||||
const selected = await getDesktopBridge()?.openDialog?.({
|
const selected = await getDesktopBridge()?.openDialog?.({
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
title: 'Select Working Directory',
|
title: 'Select Working Directory',
|
||||||
|
...(directoryPath ? { defaultPath: directoryPath } : {}),
|
||||||
});
|
});
|
||||||
if (!selected || typeof selected !== 'string') {
|
if (!selected || typeof selected !== 'string') {
|
||||||
return { success: false, error: 'Directory selection cancelled' };
|
return { success: false, error: 'Directory selection cancelled' };
|
||||||
@@ -603,7 +608,7 @@ export const requestDirectoryAccess = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, path: directoryPath };
|
return { success: false, error: 'Native directory picker not available' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDesktopFileGrantResult = (
|
const isDesktopFileGrantResult = (
|
||||||
|
|||||||
@@ -457,6 +457,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.about.title': 'Über OpenChamber',
|
'settings.openchamber.about.title': 'Über OpenChamber',
|
||||||
'settings.openchamber.about.field.version': 'Version',
|
'settings.openchamber.about.field.version': 'Version',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode-Version',
|
'settings.openchamber.about.field.openCodeVersion': 'OpenCode-Version',
|
||||||
|
'settings.openchamber.about.field.instanceUrls': 'Instanz-URLs',
|
||||||
|
'settings.openchamber.about.field.applicationUrl': 'Anwendung',
|
||||||
|
'settings.openchamber.about.field.tunnelUrl': 'Tunnel',
|
||||||
'settings.openchamber.about.state.checking': 'Wird geprüft...',
|
'settings.openchamber.about.state.checking': 'Wird geprüft...',
|
||||||
'settings.openchamber.about.state.upToDate': 'Aktuell',
|
'settings.openchamber.about.state.upToDate': 'Aktuell',
|
||||||
'settings.openchamber.about.state.unknown': 'unbekannt',
|
'settings.openchamber.about.state.unknown': 'unbekannt',
|
||||||
@@ -1055,6 +1058,8 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte 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_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.open_right_sidebar_files.label': 'Datei-Tab der rechten Seitenleiste öffnen',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Kontextpanel-Oberfläche wechseln',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung',
|
'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_chat_worktree.label': 'Neuer Worktree-Entwurf',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster',
|
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster',
|
||||||
@@ -1333,6 +1338,17 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
|
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
|
||||||
'settings.providers.page.auth.oauthMethodFallback': 'OAuth-Methode {index}',
|
'settings.providers.page.auth.oauthMethodFallback': 'OAuth-Methode {index}',
|
||||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Autorisierungscode einfügen',
|
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Autorisierungscode einfügen',
|
||||||
|
'settings.providers.page.auth.oauth.starting': 'Autorisierung wird gestartet …',
|
||||||
|
'settings.providers.page.auth.oauth.waiting': 'Warten auf Autorisierung …',
|
||||||
|
'settings.providers.page.auth.oauth.waitingHint': 'Schließen Sie die Anmeldung im Browser ab. Lassen Sie diese Seite geöffnet – die Verbindung wird von selbst hergestellt.',
|
||||||
|
'settings.providers.page.auth.oauth.codeHint': 'Kopieren Sie den Autorisierungscode aus dem Browser und fügen Sie ihn hier ein.',
|
||||||
|
'settings.providers.page.auth.oauth.deviceCodeLabel': 'Gerätecode',
|
||||||
|
'settings.providers.page.auth.oauth.linkLabel': 'Autorisierungslink',
|
||||||
|
'settings.providers.page.auth.oauth.promptRequired': 'Füllen Sie „{field}“ aus, um fortzufahren',
|
||||||
|
'settings.providers.page.auth.oauth.error.sessionExpired': 'Die Autorisierungsanfrage ist abgelaufen. Verbinden Sie erneut, um sie neu zu starten.',
|
||||||
|
'settings.providers.page.auth.oauth.error.codeRequired': 'Dieser Anbieter benötigt den Autorisierungscode aus Ihrem Browser.',
|
||||||
|
'settings.providers.page.auth.oauth.error.declined': 'Die Autorisierung wurde abgelehnt oder nicht abgeschlossen.',
|
||||||
|
'settings.providers.page.auth.oauth.error.invalidInput': 'Die eingegebenen Angaben wurden abgelehnt.',
|
||||||
'settings.providers.page.auth.connected': 'Verbunden',
|
'settings.providers.page.auth.connected': 'Verbunden',
|
||||||
'settings.providers.page.auth.incomplete': 'Anmeldedaten fehlen',
|
'settings.providers.page.auth.incomplete': 'Anmeldedaten fehlen',
|
||||||
'settings.providers.page.auth.incompleteHint': '· Fügen Sie einen API-Schlüssel oder {env:VAR} hinzu, bevor Sie diesen Anbieter im Chat verwenden',
|
'settings.providers.page.auth.incompleteHint': '· Fügen Sie einen API-Schlüssel oder {env:VAR} hinzu, bevor Sie diesen Anbieter im Chat verwenden',
|
||||||
@@ -1363,6 +1379,9 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.actions.open': 'Öffnen',
|
'settings.providers.page.actions.open': 'Öffnen',
|
||||||
'settings.providers.page.actions.copy': 'Kopieren',
|
'settings.providers.page.actions.copy': 'Kopieren',
|
||||||
'settings.providers.page.actions.complete': 'Vervollständigen',
|
'settings.providers.page.actions.complete': 'Vervollständigen',
|
||||||
|
'settings.providers.page.actions.continue': 'Weiter',
|
||||||
|
'settings.providers.page.actions.cancel': 'Abbrechen',
|
||||||
|
'settings.providers.page.actions.tryAgain': 'Wiederholen',
|
||||||
'settings.providers.page.actions.hide': 'Ausblenden',
|
'settings.providers.page.actions.hide': 'Ausblenden',
|
||||||
'settings.providers.page.actions.reconnect': 'Erneut verbinden',
|
'settings.providers.page.actions.reconnect': 'Erneut verbinden',
|
||||||
'settings.providers.page.actions.edit': 'Bearbeiten',
|
'settings.providers.page.actions.edit': 'Bearbeiten',
|
||||||
@@ -1377,7 +1396,6 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.toast.apiKeySaved': 'API-Schlüssel gespeichert',
|
'settings.providers.page.toast.apiKeySaved': 'API-Schlüssel gespeichert',
|
||||||
'settings.providers.page.toast.oauthStartFailed': 'Fehler beim Starten des OAuth-Flows',
|
'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.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.oauthCompleteFailed': 'Fehler beim Abschließen des OAuth-Flows',
|
||||||
'settings.providers.page.toast.oauthCompleted': 'OAuth-Verbindung abgeschlossen',
|
'settings.providers.page.toast.oauthCompleted': 'OAuth-Verbindung abgeschlossen',
|
||||||
'settings.providers.page.toast.oauthLinkCopied': 'OAuth-Link kopiert',
|
'settings.providers.page.toast.oauthLinkCopied': 'OAuth-Link kopiert',
|
||||||
|
|||||||
@@ -248,6 +248,9 @@ export const dict = {
|
|||||||
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} pausieren',
|
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} pausieren',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Aktiviert',
|
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Aktiviert',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.paused': 'Pausiert',
|
'sessions.scheduledTasks.dialog.taskToggle.paused': 'Pausiert',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.note': 'Von Loop-Datei verwaltet {file}',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Aktiviert wird durch die Loop-Datei gesteuert; setze enabled im Markdown-Frontmatter',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Loop-Aufgaben werden in ihrer .agents/loops-Markdown-Datei konfiguriert',
|
||||||
'sessions.scheduledTasks.editor.title.edit': 'Geplante Aufgabe bearbeiten',
|
'sessions.scheduledTasks.editor.title.edit': 'Geplante Aufgabe bearbeiten',
|
||||||
'sessions.scheduledTasks.editor.title.new': 'Neue geplante Aufgabe',
|
'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.description': 'Konfigurieren Sie eine serverseitige Aufgabe, die eine neue Sitzung erstellt und eine Eingabeaufforderung sendet.',
|
||||||
@@ -473,6 +476,10 @@ export const dict = {
|
|||||||
'sessions.sidebar.session.status.unread': 'Ungelesene Updates',
|
'sessions.sidebar.session.status.unread': 'Ungelesene Updates',
|
||||||
'sessions.sidebar.session.status.pinned': 'Angeheftete Sitzung',
|
'sessions.sidebar.session.status.pinned': 'Angeheftete Sitzung',
|
||||||
'sessions.sidebar.session.status.permissionRequired': 'Berechtigung erforderlich',
|
'sessions.sidebar.session.status.permissionRequired': 'Berechtigung erforderlich',
|
||||||
|
'sessions.sidebar.session.status.questionPendingSingle': '1 ausstehende Frage',
|
||||||
|
'sessions.sidebar.session.status.questionPendingMany': '{count} ausstehende Fragen',
|
||||||
|
'sessions.sidebar.session.status.activeFor': 'Seit {duration} aktiv',
|
||||||
|
'sessions.sidebar.session.status.lastTurnDuration': 'Letzter Durchlauf dauerte {duration}',
|
||||||
'sessions.sidebar.session.subsessions.collapse': 'Untersitzungen einklappen',
|
'sessions.sidebar.session.subsessions.collapse': 'Untersitzungen einklappen',
|
||||||
'sessions.sidebar.session.subsessions.expand': 'Untersitzungen ausklappen',
|
'sessions.sidebar.session.subsessions.expand': 'Untersitzungen ausklappen',
|
||||||
'sessions.sidebar.dialogs.deleteSession.title': 'Sitzung löschen?',
|
'sessions.sidebar.dialogs.deleteSession.title': 'Sitzung löschen?',
|
||||||
@@ -1492,6 +1499,10 @@ export const dict = {
|
|||||||
'directoryExplorerDialog.browse.directories': 'Verzeichnisse',
|
'directoryExplorerDialog.browse.directories': 'Verzeichnisse',
|
||||||
'directoryExplorerDialog.browse.loading': 'Lade Verzeichnisse...',
|
'directoryExplorerDialog.browse.loading': 'Lade Verzeichnisse...',
|
||||||
'directoryExplorerDialog.browse.empty': 'Keine passenden Verzeichnisse.',
|
'directoryExplorerDialog.browse.empty': 'Keine passenden Verzeichnisse.',
|
||||||
|
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber benötigt Zugriff auf diesen Ordner.',
|
||||||
|
'directoryExplorerDialog.browse.loadFailed': 'Dieser Ordner konnte nicht geladen werden.',
|
||||||
|
'directoryExplorerDialog.browse.grantAccess': 'Zugriff gewähren',
|
||||||
|
'directoryExplorerDialog.browse.retry': 'Erneut versuchen',
|
||||||
'directoryExplorerDialog.browse.parentDirectory': 'Übergeordnetes Verzeichnis',
|
'directoryExplorerDialog.browse.parentDirectory': 'Übergeordnetes Verzeichnis',
|
||||||
'directoryExplorerDialog.browse.addedBadge': 'Hinzugefügt',
|
'directoryExplorerDialog.browse.addedBadge': 'Hinzugefügt',
|
||||||
'directoryExplorerDialog.browse.quickAdd': 'Hinzufügen',
|
'directoryExplorerDialog.browse.quickAdd': 'Hinzufügen',
|
||||||
@@ -1558,7 +1569,7 @@ export const dict = {
|
|||||||
'helpDialog.item.toggleTerminalExpanded': 'Terminal erweitert umschalten',
|
'helpDialog.item.toggleTerminalExpanded': 'Terminal erweitert umschalten',
|
||||||
'helpDialog.item.togglePlanContextPanel': 'Plan-Kontext-Panel umschalten',
|
'helpDialog.item.togglePlanContextPanel': 'Plan-Kontext-Panel umschalten',
|
||||||
'helpDialog.item.cycleTheme': 'Thema wechseln (Hell → Dunkel → System)',
|
'helpDialog.item.cycleTheme': 'Thema wechseln (Hell → Dunkel → System)',
|
||||||
'helpDialog.item.switchProject': 'Projekt wechseln',
|
'helpDialog.item.switchContextSurface': 'Kontextpanel-Oberfläche wechseln (Zahlentaste)',
|
||||||
'helpDialog.item.toggleServicesMenu': 'Dienstemenü umschalten',
|
'helpDialog.item.toggleServicesMenu': 'Dienstemenü umschalten',
|
||||||
'helpDialog.item.cycleServicesTab': 'Dienste-Registerkarte durchgehen',
|
'helpDialog.item.cycleServicesTab': 'Dienste-Registerkarte durchgehen',
|
||||||
'helpDialog.item.openSettings': 'Einstellungen öffnen',
|
'helpDialog.item.openSettings': 'Einstellungen öffnen',
|
||||||
@@ -2728,6 +2739,9 @@ export const dict = {
|
|||||||
'common.relative.daysAgoCompact': '{count}d her',
|
'common.relative.daysAgoCompact': '{count}d her',
|
||||||
'common.relative.weeksAgoCompact': '{count}w her',
|
'common.relative.weeksAgoCompact': '{count}w her',
|
||||||
'common.relative.yearsAgoCompact': '{count}y her',
|
'common.relative.yearsAgoCompact': '{count}y her',
|
||||||
|
'common.duration.secondsCompact': '{seconds}s',
|
||||||
|
'common.duration.minutesSecondsCompact': '{minutes}m {seconds}s',
|
||||||
|
'common.duration.hoursMinutesCompact': '{hours}h {minutes}m',
|
||||||
'contextFileOpen.failure.tooLarge': 'Datei ist zu groß zum Öffnen (>{count} Zeilen)',
|
'contextFileOpen.failure.tooLarge': 'Datei ist zu groß zum Öffnen (>{count} Zeilen)',
|
||||||
'contextFileOpen.failure.missing': 'Datei nicht gefunden',
|
'contextFileOpen.failure.missing': 'Datei nicht gefunden',
|
||||||
'contextFileOpen.failure.unreadable': 'Fehler beim Öffnen der Datei',
|
'contextFileOpen.failure.unreadable': 'Fehler beim Öffnen der Datei',
|
||||||
@@ -2823,6 +2837,10 @@ export const dict = {
|
|||||||
'contextPanel.editorEmpty.description': 'Wählen Sie etwas aus der Seitenleiste aus, um Kontext anzuzeigen.',
|
'contextPanel.editorEmpty.description': 'Wählen Sie etwas aus der Seitenleiste aus, um Kontext anzuzeigen.',
|
||||||
'contextRail.surface.editor.description': 'Bearbeitungskontext',
|
'contextRail.surface.editor.description': 'Bearbeitungskontext',
|
||||||
'contextRail.surface.git.description': 'Git-Kontext',
|
'contextRail.surface.git.description': 'Git-Kontext',
|
||||||
|
'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} geänderte Datei',
|
||||||
|
'contextRail.surface.git.changesCountAriaPlural': '{label}, {count} geänderte Dateien',
|
||||||
|
'contextRail.surface.git.changesCountTooltipSingle': '{count} geänderte Datei',
|
||||||
|
'contextRail.surface.git.changesCountTooltipPlural': '{count} geänderte Dateien',
|
||||||
'contextRail.surface.terminal.description': 'Terminal-Kontext',
|
'contextRail.surface.terminal.description': 'Terminal-Kontext',
|
||||||
'contextRail.surface.diff.description': 'Diff-Kontext',
|
'contextRail.surface.diff.description': 'Diff-Kontext',
|
||||||
'contextPanel.mode.walkthrough': 'Walkthrough',
|
'contextPanel.mode.walkthrough': 'Walkthrough',
|
||||||
@@ -2853,17 +2871,20 @@ export const dict = {
|
|||||||
'walkthrough.empty.title': 'Noch nichts vorhanden',
|
'walkthrough.empty.title': 'Noch nichts vorhanden',
|
||||||
'walkthrough.empty.description': 'Wählen Sie Inhalte aus, um einen Walkthrough zu erstellen.',
|
'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.stale.banner': 'Der Code hat sich nach diesem Review geändert. Veraltete Schritte: {count}',
|
||||||
'walkthrough.stop.staleAll': 'Alle veralteten Inhalte stoppen',
|
'walkthrough.stop.staleAll': 'Der gesamte Code, den dieser Schritt beschrieben hat, hat sich geändert.',
|
||||||
'walkthrough.stop.stalePartial': 'Ein Teil des vom Schritt beschriebenen Codes hat sich geändert. Fehlende Teile: {count}',
|
'walkthrough.stop.stalePartial': 'Ein Teil des vom Schritt beschriebenen Codes hat sich geändert. Fehlende Teile: {count}',
|
||||||
'walkthrough.stop.staleShort': 'Veraltete stoppen',
|
'walkthrough.stop.staleShort': 'Veraltet',
|
||||||
'walkthrough.stop.noCode': 'Kein Code vorhanden',
|
'walkthrough.stop.noCode': 'Kein Code vorhanden',
|
||||||
'walkthrough.uncovered.title': 'Vom Review ausgelassene Änderungen: {count}',
|
'walkthrough.uncovered.title': 'Vom Review ausgelassene Änderungen: {count}',
|
||||||
'walkthrough.uncovered.description': 'Diese Bereiche wurden noch nicht in den Walkthrough aufgenommen.',
|
'walkthrough.uncovered.description': 'Diese Bereiche wurden noch nicht in den Walkthrough aufgenommen.',
|
||||||
'walkthrough.toc.moreFiles': 'Weitere Dateien: {count}',
|
'walkthrough.toc.moreFiles': 'Weitere Dateien: {count}',
|
||||||
'walkthrough.toc.uncovered': 'Nicht abgedeckt: {count}',
|
'walkthrough.toc.uncovered': 'Nicht abgedeckt: {count}',
|
||||||
'walkthrough.toc.resize': 'Größe ändern',
|
'walkthrough.toc.resize': 'Größe ändern',
|
||||||
'walkthrough.importance.critical': 'Kritisch',
|
'walkthrough.importance.critical': 'Kernänderung',
|
||||||
|
'walkthrough.importance.criticalHint': 'Dieser Schritt trägt die eigentliche Änderung, lesen Sie ihn genau. Es ist kein in Ihrem Code gefundenes Problem.',
|
||||||
'walkthrough.importance.context': 'Kontext',
|
'walkthrough.importance.context': 'Kontext',
|
||||||
|
'walkthrough.importance.contextHint': 'Eine unterstützende Änderung, damit der Rest verständlich bleibt.',
|
||||||
|
'walkthrough.help.guide': 'So funktionieren Walkthroughs',
|
||||||
'walkthrough.blocked.noModel.title': 'Kein Modell ausgewählt',
|
'walkthrough.blocked.noModel.title': 'Kein Modell ausgewählt',
|
||||||
'walkthrough.blocked.noModel.description': 'Wählen Sie zuerst ein Modell aus.',
|
'walkthrough.blocked.noModel.description': 'Wählen Sie zuerst ein Modell aus.',
|
||||||
'walkthrough.blocked.emptyDiff.title': 'Kein Diff vorhanden',
|
'walkthrough.blocked.emptyDiff.title': 'Kein Diff vorhanden',
|
||||||
@@ -2878,6 +2899,8 @@ export const dict = {
|
|||||||
'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.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.title': 'Nur generierter Inhalt',
|
||||||
'walkthrough.blocked.onlyGenerated.description': 'Es ist nur generierter Inhalt vorhanden.',
|
'walkthrough.blocked.onlyGenerated.description': 'Es ist nur generierter Inhalt vorhanden.',
|
||||||
|
'walkthrough.blocked.serverUnsupported.title': 'Dieser Server unterstützt keine Walkthroughs',
|
||||||
|
'walkthrough.blocked.serverUnsupported.description': 'Der OpenChamber-Server, mit dem diese App verbunden ist, hat die Walkthrough-API nicht beantwortet — er ist also älter als die App. Aktualisieren Sie den Server auf 1.18 oder neuer und aktualisieren Sie dann die Ansicht.',
|
||||||
'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.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.',
|
'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.plan.description': 'Plankontext',
|
||||||
@@ -2905,6 +2928,8 @@ export const dict = {
|
|||||||
'sessions.sidebar.group.empty.loadingSessions': 'Sitzungen werden geladen...',
|
'sessions.sidebar.group.empty.loadingSessions': 'Sitzungen werden geladen...',
|
||||||
'sessions.sidebar.group.empty.loadFailed': 'Sitzungen konnten nicht geladen werden',
|
'sessions.sidebar.group.empty.loadFailed': 'Sitzungen konnten nicht geladen werden',
|
||||||
'sessions.sidebar.group.empty.retry': 'Erneut versuchen',
|
'sessions.sidebar.group.empty.retry': 'Erneut versuchen',
|
||||||
|
'sessions.sidebar.group.empty.permissionDenied': 'Ordnerzugriff ist erforderlich.',
|
||||||
|
'sessions.sidebar.group.empty.grantAccess': 'Zugriff gewähren',
|
||||||
'chat.messageBody.actions.pinContext': 'Kontext anheften',
|
'chat.messageBody.actions.pinContext': 'Kontext anheften',
|
||||||
'chat.messageBody.actions.unpinContext': 'Kontext lösen',
|
'chat.messageBody.actions.unpinContext': 'Kontext lösen',
|
||||||
'chat.messageBody.actions.contextPinFailed': 'Kontext konnte nicht angeheftet werden',
|
'chat.messageBody.actions.contextPinFailed': 'Kontext konnte nicht angeheftet werden',
|
||||||
|
|||||||
@@ -476,6 +476,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.about.title': 'About OpenChamber',
|
'settings.openchamber.about.title': 'About OpenChamber',
|
||||||
'settings.openchamber.about.field.version': 'Version',
|
'settings.openchamber.about.field.version': 'Version',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode version',
|
'settings.openchamber.about.field.openCodeVersion': 'OpenCode version',
|
||||||
|
'settings.openchamber.about.field.instanceUrls': 'Instance URLs',
|
||||||
|
'settings.openchamber.about.field.applicationUrl': 'Application',
|
||||||
|
'settings.openchamber.about.field.tunnelUrl': 'Tunnel',
|
||||||
'settings.openchamber.about.state.checking': 'Checking...',
|
'settings.openchamber.about.state.checking': 'Checking...',
|
||||||
'settings.openchamber.about.state.upToDate': 'Up to date',
|
'settings.openchamber.about.state.upToDate': 'Up to date',
|
||||||
'settings.openchamber.about.state.unknown': 'unknown',
|
'settings.openchamber.about.state.unknown': 'unknown',
|
||||||
@@ -1120,6 +1123,8 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel',
|
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface',
|
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Open Files surface',
|
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Open Files surface',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Switch context panel surface',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session',
|
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft',
|
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window',
|
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window',
|
||||||
@@ -1398,6 +1403,17 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
|
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
|
||||||
'settings.providers.page.auth.oauthMethodFallback': 'OAuth method {index}',
|
'settings.providers.page.auth.oauthMethodFallback': 'OAuth method {index}',
|
||||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Paste authorization code',
|
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Paste authorization code',
|
||||||
|
'settings.providers.page.auth.oauth.starting': 'Starting authorization…',
|
||||||
|
'settings.providers.page.auth.oauth.waiting': 'Waiting for authorization…',
|
||||||
|
'settings.providers.page.auth.oauth.waitingHint': 'Finish signing in in your browser. Keep this page open — the connection completes on its own.',
|
||||||
|
'settings.providers.page.auth.oauth.codeHint': 'Copy the authorization code from your browser and paste it here.',
|
||||||
|
'settings.providers.page.auth.oauth.deviceCodeLabel': 'Device code',
|
||||||
|
'settings.providers.page.auth.oauth.linkLabel': 'Authorization link',
|
||||||
|
'settings.providers.page.auth.oauth.promptRequired': 'Fill in “{field}” to continue',
|
||||||
|
'settings.providers.page.auth.oauth.error.sessionExpired': 'The authorization request expired. Connect again to restart it.',
|
||||||
|
'settings.providers.page.auth.oauth.error.codeRequired': 'This provider needs the authorization code from your browser.',
|
||||||
|
'settings.providers.page.auth.oauth.error.declined': 'Authorization was declined or did not complete.',
|
||||||
|
'settings.providers.page.auth.oauth.error.invalidInput': 'The details you entered were rejected.',
|
||||||
'settings.providers.page.auth.connected': 'Connected',
|
'settings.providers.page.auth.connected': 'Connected',
|
||||||
'settings.providers.page.auth.incomplete': 'Credentials missing',
|
'settings.providers.page.auth.incomplete': 'Credentials missing',
|
||||||
'settings.providers.page.auth.incompleteHint': '· Add an API key or {env:VAR} before using this provider in chat',
|
'settings.providers.page.auth.incompleteHint': '· Add an API key or {env:VAR} before using this provider in chat',
|
||||||
@@ -1428,6 +1444,9 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.actions.open': 'Open',
|
'settings.providers.page.actions.open': 'Open',
|
||||||
'settings.providers.page.actions.copy': 'Copy',
|
'settings.providers.page.actions.copy': 'Copy',
|
||||||
'settings.providers.page.actions.complete': 'Complete',
|
'settings.providers.page.actions.complete': 'Complete',
|
||||||
|
'settings.providers.page.actions.continue': 'Continue',
|
||||||
|
'settings.providers.page.actions.cancel': 'Cancel',
|
||||||
|
'settings.providers.page.actions.tryAgain': 'Try again',
|
||||||
'settings.providers.page.actions.hide': 'Hide',
|
'settings.providers.page.actions.hide': 'Hide',
|
||||||
'settings.providers.page.actions.reconnect': 'Reconnect',
|
'settings.providers.page.actions.reconnect': 'Reconnect',
|
||||||
'settings.providers.page.actions.edit': 'Edit',
|
'settings.providers.page.actions.edit': 'Edit',
|
||||||
@@ -1442,7 +1461,6 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.toast.apiKeySaved': 'API key saved',
|
'settings.providers.page.toast.apiKeySaved': 'API key saved',
|
||||||
'settings.providers.page.toast.oauthStartFailed': 'Failed to start OAuth flow',
|
'settings.providers.page.toast.oauthStartFailed': 'Failed to start OAuth flow',
|
||||||
'settings.providers.page.toast.oauthDetailsMissing': 'No OAuth details returned',
|
'settings.providers.page.toast.oauthDetailsMissing': 'No OAuth details returned',
|
||||||
'settings.providers.page.toast.completeOAuthInBrowser': 'Complete the OAuth flow in your browser',
|
|
||||||
'settings.providers.page.toast.oauthCompleteFailed': 'Failed to complete OAuth flow',
|
'settings.providers.page.toast.oauthCompleteFailed': 'Failed to complete OAuth flow',
|
||||||
'settings.providers.page.toast.oauthCompleted': 'OAuth connection completed',
|
'settings.providers.page.toast.oauthCompleted': 'OAuth connection completed',
|
||||||
'settings.providers.page.toast.oauthLinkCopied': 'OAuth link copied',
|
'settings.providers.page.toast.oauthLinkCopied': 'OAuth link copied',
|
||||||
|
|||||||
@@ -268,6 +268,9 @@ export const dict = {
|
|||||||
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}',
|
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Enabled',
|
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Enabled',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.paused': 'Paused',
|
'sessions.scheduledTasks.dialog.taskToggle.paused': 'Paused',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.note': 'Managed by loop file {file}',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Enabled is controlled by the loop file; set enabled in the markdown frontmatter',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Loop tasks are configured in their .agents/loops markdown file',
|
||||||
'sessions.scheduledTasks.editor.title.edit': 'Edit scheduled task',
|
'sessions.scheduledTasks.editor.title.edit': 'Edit scheduled task',
|
||||||
'sessions.scheduledTasks.editor.title.new': 'New scheduled task',
|
'sessions.scheduledTasks.editor.title.new': 'New scheduled task',
|
||||||
'sessions.scheduledTasks.editor.description': 'Configure a server-side task that creates a new session and sends a prompt.',
|
'sessions.scheduledTasks.editor.description': 'Configure a server-side task that creates a new session and sends a prompt.',
|
||||||
@@ -530,6 +533,10 @@ export const dict = {
|
|||||||
'sessions.sidebar.session.status.pinned': 'Pinned session',
|
'sessions.sidebar.session.status.pinned': 'Pinned session',
|
||||||
'sessions.sidebar.session.status.movingToWorktree': 'Moving session to a new worktree',
|
'sessions.sidebar.session.status.movingToWorktree': 'Moving session to a new worktree',
|
||||||
'sessions.sidebar.session.status.permissionRequired': 'Permission required',
|
'sessions.sidebar.session.status.permissionRequired': 'Permission required',
|
||||||
|
'sessions.sidebar.session.status.questionPendingSingle': '1 pending question',
|
||||||
|
'sessions.sidebar.session.status.questionPendingMany': '{count} pending questions',
|
||||||
|
'sessions.sidebar.session.status.activeFor': 'Active for {duration}',
|
||||||
|
'sessions.sidebar.session.status.lastTurnDuration': 'Last turn took {duration}',
|
||||||
'sessions.sidebar.session.subsessions.collapse': 'Collapse subsessions',
|
'sessions.sidebar.session.subsessions.collapse': 'Collapse subsessions',
|
||||||
'sessions.sidebar.session.subsessions.expand': 'Expand subsessions',
|
'sessions.sidebar.session.subsessions.expand': 'Expand subsessions',
|
||||||
'sessions.sidebar.dialogs.deleteSession.title': 'Delete session?',
|
'sessions.sidebar.dialogs.deleteSession.title': 'Delete session?',
|
||||||
@@ -1104,6 +1111,10 @@ export const dict = {
|
|||||||
'contextPanel.editorEmpty.description': 'Pick a file from the tree to start editing.',
|
'contextPanel.editorEmpty.description': 'Pick a file from the tree to start editing.',
|
||||||
'contextRail.surface.editor.description': 'Edit project files',
|
'contextRail.surface.editor.description': 'Edit project files',
|
||||||
'contextRail.surface.git.description': 'Commits, branches, and pull requests',
|
'contextRail.surface.git.description': 'Commits, branches, and pull requests',
|
||||||
|
'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} changed file',
|
||||||
|
'contextRail.surface.git.changesCountAriaPlural': '{label}, {count} changed files',
|
||||||
|
'contextRail.surface.git.changesCountTooltipSingle': '{count} changed file',
|
||||||
|
'contextRail.surface.git.changesCountTooltipPlural': '{count} changed files',
|
||||||
'contextRail.surface.terminal.description': 'Built-in terminal',
|
'contextRail.surface.terminal.description': 'Built-in terminal',
|
||||||
'contextRail.surface.diff.description': 'Review working changes',
|
'contextRail.surface.diff.description': 'Review working changes',
|
||||||
'contextPanel.mode.walkthrough': 'Walkthrough',
|
'contextPanel.mode.walkthrough': 'Walkthrough',
|
||||||
@@ -1143,8 +1154,11 @@ export const dict = {
|
|||||||
'walkthrough.toc.moreFiles': 'More files: {count}',
|
'walkthrough.toc.moreFiles': 'More files: {count}',
|
||||||
'walkthrough.toc.uncovered': 'Not covered: {count}',
|
'walkthrough.toc.uncovered': 'Not covered: {count}',
|
||||||
'walkthrough.toc.resize': 'Resize the contents column',
|
'walkthrough.toc.resize': 'Resize the contents column',
|
||||||
'walkthrough.importance.critical': 'Critical',
|
'walkthrough.importance.critical': 'Key change',
|
||||||
|
'walkthrough.importance.criticalHint': 'This step drives the rest of the change, so read it closely. It is not a problem found in your code.',
|
||||||
'walkthrough.importance.context': 'Context',
|
'walkthrough.importance.context': 'Context',
|
||||||
|
'walkthrough.importance.contextHint': 'A supporting change, included so the rest makes sense.',
|
||||||
|
'walkthrough.help.guide': 'How walkthroughs work',
|
||||||
'walkthrough.blocked.noModel.title': 'No small model available',
|
'walkthrough.blocked.noModel.title': 'No small model available',
|
||||||
'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.',
|
'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.',
|
||||||
'walkthrough.blocked.emptyDiff.title': 'Nothing to review',
|
'walkthrough.blocked.emptyDiff.title': 'Nothing to review',
|
||||||
@@ -1159,6 +1173,8 @@ export const dict = {
|
|||||||
'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.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.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.onlyGenerated.description': 'Every change here is a lockfile or other tool-produced output, which the review deliberately skips.',
|
||||||
|
'walkthrough.blocked.serverUnsupported.title': 'This server has no walkthrough support',
|
||||||
|
'walkthrough.blocked.serverUnsupported.description': 'The OpenChamber server this app is connected to did not answer the walkthrough API, which means it is older than the app. Update the server to 1.18 or newer, then refresh.',
|
||||||
'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.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.',
|
'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.plan.description': 'View the current plan',
|
||||||
@@ -1640,6 +1656,10 @@ export const dict = {
|
|||||||
'directoryExplorerDialog.browse.directories': 'Directories',
|
'directoryExplorerDialog.browse.directories': 'Directories',
|
||||||
'directoryExplorerDialog.browse.loading': 'Loading directories...',
|
'directoryExplorerDialog.browse.loading': 'Loading directories...',
|
||||||
'directoryExplorerDialog.browse.empty': 'No matching directories.',
|
'directoryExplorerDialog.browse.empty': 'No matching directories.',
|
||||||
|
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber needs access to this folder.',
|
||||||
|
'directoryExplorerDialog.browse.loadFailed': 'Could not load this folder.',
|
||||||
|
'directoryExplorerDialog.browse.grantAccess': 'Grant access',
|
||||||
|
'directoryExplorerDialog.browse.retry': 'Try again',
|
||||||
'directoryExplorerDialog.browse.parentDirectory': 'Parent directory',
|
'directoryExplorerDialog.browse.parentDirectory': 'Parent directory',
|
||||||
'directoryExplorerDialog.browse.addedBadge': 'Added',
|
'directoryExplorerDialog.browse.addedBadge': 'Added',
|
||||||
'directoryExplorerDialog.browse.quickAdd': 'Add',
|
'directoryExplorerDialog.browse.quickAdd': 'Add',
|
||||||
@@ -1705,8 +1725,8 @@ export const dict = {
|
|||||||
'helpDialog.item.toggleTerminalDock': 'Toggle Terminal Dock',
|
'helpDialog.item.toggleTerminalDock': 'Toggle Terminal Dock',
|
||||||
'helpDialog.item.toggleTerminalExpanded': 'Toggle Terminal Expanded',
|
'helpDialog.item.toggleTerminalExpanded': 'Toggle Terminal Expanded',
|
||||||
'helpDialog.item.togglePlanContextPanel': 'Toggle Plan Context Panel',
|
'helpDialog.item.togglePlanContextPanel': 'Toggle Plan Context Panel',
|
||||||
|
'helpDialog.item.switchContextSurface': 'Switch Context Panel Surface (number key)',
|
||||||
'helpDialog.item.cycleTheme': 'Cycle Theme (Light → Dark → System)',
|
'helpDialog.item.cycleTheme': 'Cycle Theme (Light → Dark → System)',
|
||||||
'helpDialog.item.switchProject': 'Switch Project',
|
|
||||||
'helpDialog.item.toggleServicesMenu': 'Toggle Services Menu',
|
'helpDialog.item.toggleServicesMenu': 'Toggle Services Menu',
|
||||||
'helpDialog.item.cycleServicesTab': 'Cycle Services Tab',
|
'helpDialog.item.cycleServicesTab': 'Cycle Services Tab',
|
||||||
'helpDialog.item.openSettings': 'Open Settings',
|
'helpDialog.item.openSettings': 'Open Settings',
|
||||||
@@ -1991,6 +2011,8 @@ export const dict = {
|
|||||||
'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…',
|
'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…',
|
||||||
'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.',
|
'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.',
|
||||||
'sessions.sidebar.group.empty.retry': 'Try again',
|
'sessions.sidebar.group.empty.retry': 'Try again',
|
||||||
|
'sessions.sidebar.group.empty.permissionDenied': 'Folder access is required.',
|
||||||
|
'sessions.sidebar.group.empty.grantAccess': 'Grant access',
|
||||||
'chat.unifiedControls.title': 'Controls',
|
'chat.unifiedControls.title': 'Controls',
|
||||||
'chat.unifiedControls.model.title': 'Model',
|
'chat.unifiedControls.model.title': 'Model',
|
||||||
'chat.unifiedControls.model.noRecent': 'No recent models',
|
'chat.unifiedControls.model.noRecent': 'No recent models',
|
||||||
@@ -2895,6 +2917,9 @@ export const dict = {
|
|||||||
'common.relative.daysAgoCompact': '{count}d ago',
|
'common.relative.daysAgoCompact': '{count}d ago',
|
||||||
'common.relative.weeksAgoCompact': '{count}w ago',
|
'common.relative.weeksAgoCompact': '{count}w ago',
|
||||||
'common.relative.yearsAgoCompact': '{count}y ago',
|
'common.relative.yearsAgoCompact': '{count}y ago',
|
||||||
|
'common.duration.secondsCompact': '{seconds}s',
|
||||||
|
'common.duration.minutesSecondsCompact': '{minutes}m {seconds}s',
|
||||||
|
'common.duration.hoursMinutesCompact': '{hours}h {minutes}m',
|
||||||
'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)',
|
'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)',
|
||||||
'contextFileOpen.failure.missing': 'File not found',
|
'contextFileOpen.failure.missing': 'File not found',
|
||||||
'contextFileOpen.failure.unreadable': 'Failed to open file',
|
'contextFileOpen.failure.unreadable': 'Failed to open file',
|
||||||
|
|||||||
@@ -444,6 +444,9 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.about.title": "Acerca de OpenChamber",
|
"settings.openchamber.about.title": "Acerca de OpenChamber",
|
||||||
"settings.openchamber.about.field.version": "Versión",
|
"settings.openchamber.about.field.version": "Versión",
|
||||||
"settings.openchamber.about.field.openCodeVersion": "Versión de OpenCode",
|
"settings.openchamber.about.field.openCodeVersion": "Versión de OpenCode",
|
||||||
|
"settings.openchamber.about.field.instanceUrls": "URLs de la instancia",
|
||||||
|
"settings.openchamber.about.field.applicationUrl": "Aplicación",
|
||||||
|
"settings.openchamber.about.field.tunnelUrl": "Túnel",
|
||||||
"settings.openchamber.about.state.checking": "Comprobando...",
|
"settings.openchamber.about.state.checking": "Comprobando...",
|
||||||
"settings.openchamber.about.state.upToDate": "Actualizado",
|
"settings.openchamber.about.state.upToDate": "Actualizado",
|
||||||
"settings.openchamber.about.state.unknown": "desconocido",
|
"settings.openchamber.about.state.unknown": "desconocido",
|
||||||
@@ -1088,6 +1091,8 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto',
|
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto',
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git',
|
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git',
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superficie de archivos',
|
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superficie de archivos',
|
||||||
|
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Cambiar superficie del panel de contexto",
|
||||||
|
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión",
|
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión",
|
||||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree",
|
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree",
|
||||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat",
|
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat",
|
||||||
@@ -1372,6 +1377,17 @@ export const settingsDict = {
|
|||||||
"settings.providers.page.auth.apiKeyPlaceholder": "sk-...",
|
"settings.providers.page.auth.apiKeyPlaceholder": "sk-...",
|
||||||
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
|
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
|
||||||
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Pegar código de autorización",
|
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Pegar código de autorización",
|
||||||
|
"settings.providers.page.auth.oauth.starting": "Iniciando la autorización…",
|
||||||
|
"settings.providers.page.auth.oauth.waiting": "Esperando la autorización…",
|
||||||
|
"settings.providers.page.auth.oauth.waitingHint": "Termina de iniciar sesión en el navegador. Mantén esta página abierta: la conexión se completará sola.",
|
||||||
|
"settings.providers.page.auth.oauth.codeHint": "Copia el código de autorización del navegador y pégalo aquí.",
|
||||||
|
"settings.providers.page.auth.oauth.deviceCodeLabel": "Código del dispositivo",
|
||||||
|
"settings.providers.page.auth.oauth.linkLabel": "Enlace de autorización",
|
||||||
|
"settings.providers.page.auth.oauth.promptRequired": "Completa «{field}» para continuar",
|
||||||
|
"settings.providers.page.auth.oauth.error.sessionExpired": "La solicitud de autorización caducó. Vuelve a conectar para reiniciarla.",
|
||||||
|
"settings.providers.page.auth.oauth.error.codeRequired": "Este proveedor necesita el código de autorización de tu navegador.",
|
||||||
|
"settings.providers.page.auth.oauth.error.declined": "La autorización se rechazó o no se completó.",
|
||||||
|
"settings.providers.page.auth.oauth.error.invalidInput": "Se rechazaron los datos introducidos.",
|
||||||
"settings.providers.page.auth.connected": "Conectado",
|
"settings.providers.page.auth.connected": "Conectado",
|
||||||
"settings.providers.page.auth.incomplete": "Faltan credenciales",
|
"settings.providers.page.auth.incomplete": "Faltan credenciales",
|
||||||
"settings.providers.page.auth.incompleteHint": "· Añade una clave API o {env:VAR} antes de usar este proveedor en el chat",
|
"settings.providers.page.auth.incompleteHint": "· Añade una clave API o {env:VAR} antes de usar este proveedor en el chat",
|
||||||
@@ -1404,6 +1420,9 @@ export const settingsDict = {
|
|||||||
"settings.providers.page.actions.open": "Abrir",
|
"settings.providers.page.actions.open": "Abrir",
|
||||||
"settings.providers.page.actions.copy": "Copiar",
|
"settings.providers.page.actions.copy": "Copiar",
|
||||||
"settings.providers.page.actions.complete": "Completar",
|
"settings.providers.page.actions.complete": "Completar",
|
||||||
|
"settings.providers.page.actions.continue": "Continuar",
|
||||||
|
"settings.providers.page.actions.cancel": "Cancelar",
|
||||||
|
"settings.providers.page.actions.tryAgain": "Reintentar",
|
||||||
"settings.providers.page.actions.hide": "Ocultar",
|
"settings.providers.page.actions.hide": "Ocultar",
|
||||||
"settings.providers.page.actions.reconnect": "Reconectar",
|
"settings.providers.page.actions.reconnect": "Reconectar",
|
||||||
"settings.providers.page.actions.edit": "Editar",
|
"settings.providers.page.actions.edit": "Editar",
|
||||||
@@ -1419,7 +1438,6 @@ export const settingsDict = {
|
|||||||
"settings.providers.page.toast.apiKeySaved": "Clave API guardada",
|
"settings.providers.page.toast.apiKeySaved": "Clave API guardada",
|
||||||
"settings.providers.page.toast.oauthStartFailed": "No se pudo iniciar el flujo OAuth",
|
"settings.providers.page.toast.oauthStartFailed": "No se pudo iniciar el flujo OAuth",
|
||||||
"settings.providers.page.toast.oauthDetailsMissing": "No se devolvieron detalles de OAuth",
|
"settings.providers.page.toast.oauthDetailsMissing": "No se devolvieron detalles de OAuth",
|
||||||
"settings.providers.page.toast.completeOAuthInBrowser": "Completa el flujo OAuth en tu navegador",
|
|
||||||
"settings.providers.page.toast.oauthCompleteFailed": "No se pudo completar el flujo OAuth",
|
"settings.providers.page.toast.oauthCompleteFailed": "No se pudo completar el flujo OAuth",
|
||||||
"settings.providers.page.toast.oauthCompleted": "Conexión OAuth completada",
|
"settings.providers.page.toast.oauthCompleted": "Conexión OAuth completada",
|
||||||
"settings.providers.page.toast.oauthLinkCopied": "Enlace de OAuth copiado",
|
"settings.providers.page.toast.oauthLinkCopied": "Enlace de OAuth copiado",
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}",
|
"sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}",
|
||||||
"sessions.scheduledTasks.dialog.taskToggle.enabled": "Habilitado",
|
"sessions.scheduledTasks.dialog.taskToggle.enabled": "Habilitado",
|
||||||
"sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado",
|
"sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado",
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.note': 'Gestionada por el archivo de bucle {file}',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'La activación la controla el archivo de bucle; establece enabled en el frontmatter de Markdown',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Las tareas de bucle se configuran en su archivo Markdown .agents/loops',
|
||||||
"sessions.scheduledTasks.editor.title.edit": "Editar tarea programada",
|
"sessions.scheduledTasks.editor.title.edit": "Editar tarea programada",
|
||||||
"sessions.scheduledTasks.editor.title.new": "Nueva tarea programada",
|
"sessions.scheduledTasks.editor.title.new": "Nueva tarea programada",
|
||||||
"sessions.scheduledTasks.editor.description": "Configura una tarea del lado del servidor que crea una nueva sesión y envía un prompt.",
|
"sessions.scheduledTasks.editor.description": "Configura una tarea del lado del servidor que crea una nueva sesión y envía un prompt.",
|
||||||
@@ -531,6 +534,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.session.status.pinned": "Sesión anclada",
|
"sessions.sidebar.session.status.pinned": "Sesión anclada",
|
||||||
"sessions.sidebar.session.status.movingToWorktree": "Moviendo la sesión a un worktree nuevo",
|
"sessions.sidebar.session.status.movingToWorktree": "Moviendo la sesión a un worktree nuevo",
|
||||||
"sessions.sidebar.session.status.permissionRequired": "Permiso requerido",
|
"sessions.sidebar.session.status.permissionRequired": "Permiso requerido",
|
||||||
|
"sessions.sidebar.session.status.questionPendingSingle": "1 pregunta pendiente",
|
||||||
|
"sessions.sidebar.session.status.questionPendingMany": "{count} preguntas pendientes",
|
||||||
|
"sessions.sidebar.session.status.activeFor": "Activa desde hace {duration}",
|
||||||
|
"sessions.sidebar.session.status.lastTurnDuration": "El último turno duró {duration}",
|
||||||
"sessions.sidebar.session.subsessions.collapse": "Colapsar subsesiones",
|
"sessions.sidebar.session.subsessions.collapse": "Colapsar subsesiones",
|
||||||
"sessions.sidebar.session.subsessions.expand": "Expandir subsesiones",
|
"sessions.sidebar.session.subsessions.expand": "Expandir subsesiones",
|
||||||
"sessions.sidebar.dialogs.deleteSession.title": "¿Eliminar sesión?",
|
"sessions.sidebar.dialogs.deleteSession.title": "¿Eliminar sesión?",
|
||||||
@@ -1105,6 +1112,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"contextPanel.editorEmpty.description": "Elige un archivo del árbol para empezar a editar.",
|
"contextPanel.editorEmpty.description": "Elige un archivo del árbol para empezar a editar.",
|
||||||
"contextRail.surface.editor.description": "Editar archivos del proyecto",
|
"contextRail.surface.editor.description": "Editar archivos del proyecto",
|
||||||
"contextRail.surface.git.description": "Commits, ramas y pull requests",
|
"contextRail.surface.git.description": "Commits, ramas y pull requests",
|
||||||
|
"contextRail.surface.git.changesCountAriaSingle": "{label}, {count} archivo modificado",
|
||||||
|
"contextRail.surface.git.changesCountAriaPlural": "{label}, {count} archivos modificados",
|
||||||
|
"contextRail.surface.git.changesCountTooltipSingle": "{count} archivo modificado",
|
||||||
|
"contextRail.surface.git.changesCountTooltipPlural": "{count} archivos modificados",
|
||||||
"contextRail.surface.terminal.description": "Terminal integrada",
|
"contextRail.surface.terminal.description": "Terminal integrada",
|
||||||
"contextRail.surface.diff.description": "Revisar cambios en curso",
|
"contextRail.surface.diff.description": "Revisar cambios en curso",
|
||||||
"contextPanel.mode.walkthrough": "Recorrido",
|
"contextPanel.mode.walkthrough": "Recorrido",
|
||||||
@@ -1144,8 +1155,11 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"walkthrough.toc.moreFiles": "Más archivos: {count}",
|
"walkthrough.toc.moreFiles": "Más archivos: {count}",
|
||||||
"walkthrough.toc.uncovered": "Sin cubrir: {count}",
|
"walkthrough.toc.uncovered": "Sin cubrir: {count}",
|
||||||
"walkthrough.toc.resize": "Cambiar el ancho de la columna de contenidos",
|
"walkthrough.toc.resize": "Cambiar el ancho de la columna de contenidos",
|
||||||
"walkthrough.importance.critical": "Crítico",
|
"walkthrough.importance.critical": "Cambio clave",
|
||||||
|
"walkthrough.importance.criticalHint": "Este paso impulsa el resto del cambio, así que léelo con atención. No es un problema detectado en tu código.",
|
||||||
"walkthrough.importance.context": "Contexto",
|
"walkthrough.importance.context": "Contexto",
|
||||||
|
"walkthrough.importance.contextHint": "Un cambio de apoyo, incluido para que el resto tenga sentido.",
|
||||||
|
"walkthrough.help.guide": "Cómo funcionan los walkthroughs",
|
||||||
"walkthrough.blocked.noModel.title": "No hay ningún modelo pequeño disponible",
|
"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.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.title": "Nada que revisar",
|
||||||
@@ -1160,6 +1174,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"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.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.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.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.serverUnsupported.title": "Este servidor no admite walkthroughs",
|
||||||
|
"walkthrough.blocked.serverUnsupported.description": "El servidor de OpenChamber al que está conectada esta app no respondió a la API de walkthrough, así que es más antiguo que la app. Actualiza el servidor a 1.18 o posterior y vuelve a intentarlo.",
|
||||||
"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.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.",
|
"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.plan.description": "Ver el plan actual",
|
||||||
@@ -1618,6 +1634,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"directoryExplorerDialog.browse.directories": "Directorios",
|
"directoryExplorerDialog.browse.directories": "Directorios",
|
||||||
"directoryExplorerDialog.browse.loading": "Cargando directorios...",
|
"directoryExplorerDialog.browse.loading": "Cargando directorios...",
|
||||||
"directoryExplorerDialog.browse.empty": "No hay directorios coincidentes.",
|
"directoryExplorerDialog.browse.empty": "No hay directorios coincidentes.",
|
||||||
|
"directoryExplorerDialog.browse.permissionDenied": "OpenChamber necesita acceso a esta carpeta.",
|
||||||
|
"directoryExplorerDialog.browse.loadFailed": "No se pudo cargar esta carpeta.",
|
||||||
|
"directoryExplorerDialog.browse.grantAccess": "Permitir acceso",
|
||||||
|
"directoryExplorerDialog.browse.retry": "Reintentar",
|
||||||
"directoryExplorerDialog.browse.parentDirectory": "Directorio padre",
|
"directoryExplorerDialog.browse.parentDirectory": "Directorio padre",
|
||||||
"directoryExplorerDialog.browse.addedBadge": "Añadido",
|
"directoryExplorerDialog.browse.addedBadge": "Añadido",
|
||||||
"directoryExplorerDialog.browse.quickAdd": "Añadir",
|
"directoryExplorerDialog.browse.quickAdd": "Añadir",
|
||||||
@@ -1684,7 +1704,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"helpDialog.item.toggleTerminalExpanded": "Expandir o contraer terminal",
|
"helpDialog.item.toggleTerminalExpanded": "Expandir o contraer terminal",
|
||||||
"helpDialog.item.togglePlanContextPanel": "Alternar panel de contexto del plan",
|
"helpDialog.item.togglePlanContextPanel": "Alternar panel de contexto del plan",
|
||||||
"helpDialog.item.cycleTheme": "Cambiar tema (Claro → Oscuro → Sistema)",
|
"helpDialog.item.cycleTheme": "Cambiar tema (Claro → Oscuro → Sistema)",
|
||||||
"helpDialog.item.switchProject": "Cambiar proyecto",
|
"helpDialog.item.switchContextSurface": "Cambiar superficie del panel de contexto (tecla numérica)",
|
||||||
"helpDialog.item.toggleServicesMenu": "Mostrar u ocultar menú de servicios",
|
"helpDialog.item.toggleServicesMenu": "Mostrar u ocultar menú de servicios",
|
||||||
"helpDialog.item.cycleServicesTab": "Cambiar pestaña de servicios",
|
"helpDialog.item.cycleServicesTab": "Cambiar pestaña de servicios",
|
||||||
"helpDialog.item.openSettings": "Abrir configuración",
|
"helpDialog.item.openSettings": "Abrir configuración",
|
||||||
@@ -1969,6 +1989,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…",
|
"sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…",
|
||||||
"sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.",
|
"sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.",
|
||||||
"sessions.sidebar.group.empty.retry": "Reintentar",
|
"sessions.sidebar.group.empty.retry": "Reintentar",
|
||||||
|
"sessions.sidebar.group.empty.permissionDenied": "Se requiere acceso a la carpeta.",
|
||||||
|
"sessions.sidebar.group.empty.grantAccess": "Permitir acceso",
|
||||||
"chat.unifiedControls.title": "Controles",
|
"chat.unifiedControls.title": "Controles",
|
||||||
"chat.unifiedControls.model.title": "Modelo",
|
"chat.unifiedControls.model.title": "Modelo",
|
||||||
"chat.unifiedControls.model.noRecent": "No hay modelos recientes",
|
"chat.unifiedControls.model.noRecent": "No hay modelos recientes",
|
||||||
@@ -2896,6 +2918,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"common.relative.daysAgoCompact": "{count}d ago",
|
"common.relative.daysAgoCompact": "{count}d ago",
|
||||||
"common.relative.weeksAgoCompact": "{count}w ago",
|
"common.relative.weeksAgoCompact": "{count}w ago",
|
||||||
"common.relative.yearsAgoCompact": "{count}y ago",
|
"common.relative.yearsAgoCompact": "{count}y ago",
|
||||||
|
"common.duration.secondsCompact": "{seconds}s",
|
||||||
|
"common.duration.minutesSecondsCompact": "{minutes}m {seconds}s",
|
||||||
|
"common.duration.hoursMinutesCompact": "{hours}h {minutes}m",
|
||||||
"contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)",
|
"contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)",
|
||||||
"contextFileOpen.failure.missing": "File not found",
|
"contextFileOpen.failure.missing": "File not found",
|
||||||
"contextFileOpen.failure.unreadable": "Failed to open file",
|
"contextFileOpen.failure.unreadable": "Failed to open file",
|
||||||
|
|||||||
@@ -1009,6 +1009,8 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte',
|
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git',
|
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Ouvrir la surface Fichiers',
|
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Ouvrir la surface Fichiers',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Basculer la surface du panneau contextuel',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session',
|
'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree',
|
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat',
|
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat',
|
||||||
@@ -1293,6 +1295,17 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
|
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
|
||||||
'settings.providers.page.auth.oauthMethodFallback': 'Méthode OAuth {index}',
|
'settings.providers.page.auth.oauthMethodFallback': 'Méthode OAuth {index}',
|
||||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Coller le code d\'autorisation',
|
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Coller le code d\'autorisation',
|
||||||
|
'settings.providers.page.auth.oauth.starting': 'Démarrage de l’autorisation…',
|
||||||
|
'settings.providers.page.auth.oauth.waiting': 'En attente de l’autorisation…',
|
||||||
|
'settings.providers.page.auth.oauth.waitingHint': 'Terminez la connexion dans votre navigateur. Laissez cette page ouverte : la connexion se finalisera d’elle-même.',
|
||||||
|
'settings.providers.page.auth.oauth.codeHint': 'Copiez le code d’autorisation depuis votre navigateur et collez-le ici.',
|
||||||
|
'settings.providers.page.auth.oauth.deviceCodeLabel': 'Code de l’appareil',
|
||||||
|
'settings.providers.page.auth.oauth.linkLabel': 'Lien d’autorisation',
|
||||||
|
'settings.providers.page.auth.oauth.promptRequired': 'Renseignez « {field} » pour continuer',
|
||||||
|
'settings.providers.page.auth.oauth.error.sessionExpired': 'La demande d’autorisation a expiré. Reconnectez-vous pour la relancer.',
|
||||||
|
'settings.providers.page.auth.oauth.error.codeRequired': 'Ce fournisseur a besoin du code d’autorisation de votre navigateur.',
|
||||||
|
'settings.providers.page.auth.oauth.error.declined': 'L’autorisation a été refusée ou n’a pas abouti.',
|
||||||
|
'settings.providers.page.auth.oauth.error.invalidInput': 'Les informations saisies ont été refusées.',
|
||||||
'settings.providers.page.auth.connected': 'Connecté',
|
'settings.providers.page.auth.connected': 'Connecté',
|
||||||
'settings.providers.page.auth.incomplete': 'Identifiants manquants',
|
'settings.providers.page.auth.incomplete': 'Identifiants manquants',
|
||||||
'settings.providers.page.auth.incompleteHint': '· Ajoutez une clé API ou {env:VAR} avant d’utiliser ce fournisseur dans le chat',
|
'settings.providers.page.auth.incompleteHint': '· Ajoutez une clé API ou {env:VAR} avant d’utiliser ce fournisseur dans le chat',
|
||||||
@@ -1325,6 +1338,9 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.actions.open': 'Ouvrir',
|
'settings.providers.page.actions.open': 'Ouvrir',
|
||||||
'settings.providers.page.actions.copy': 'Copie',
|
'settings.providers.page.actions.copy': 'Copie',
|
||||||
'settings.providers.page.actions.complete': 'Complet',
|
'settings.providers.page.actions.complete': 'Complet',
|
||||||
|
'settings.providers.page.actions.continue': 'Continuer',
|
||||||
|
'settings.providers.page.actions.cancel': 'Annuler',
|
||||||
|
'settings.providers.page.actions.tryAgain': 'Réessayer',
|
||||||
'settings.providers.page.actions.hide': 'Cacher',
|
'settings.providers.page.actions.hide': 'Cacher',
|
||||||
'settings.providers.page.actions.reconnect': 'Reconnecter',
|
'settings.providers.page.actions.reconnect': 'Reconnecter',
|
||||||
'settings.providers.page.actions.edit': 'Modifier',
|
'settings.providers.page.actions.edit': 'Modifier',
|
||||||
@@ -1340,7 +1356,6 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.toast.apiKeySaved': 'Clé API enregistrée',
|
'settings.providers.page.toast.apiKeySaved': 'Clé API enregistrée',
|
||||||
'settings.providers.page.toast.oauthStartFailed': 'Échec du démarrage du flux OAuth',
|
'settings.providers.page.toast.oauthStartFailed': 'Échec du démarrage du flux OAuth',
|
||||||
'settings.providers.page.toast.oauthDetailsMissing': 'Aucun détail OAuth renvoyé',
|
'settings.providers.page.toast.oauthDetailsMissing': 'Aucun détail OAuth renvoyé',
|
||||||
'settings.providers.page.toast.completeOAuthInBrowser': 'Complétez le flux OAuth dans votre navigateur',
|
|
||||||
'settings.providers.page.toast.oauthCompleteFailed': 'Échec de la réalisation du flux OAuth',
|
'settings.providers.page.toast.oauthCompleteFailed': 'Échec de la réalisation du flux OAuth',
|
||||||
'settings.providers.page.toast.oauthCompleted': 'Connexion OAuth terminée',
|
'settings.providers.page.toast.oauthCompleted': 'Connexion OAuth terminée',
|
||||||
'settings.providers.page.toast.oauthLinkCopied': 'Lien OAuth copié',
|
'settings.providers.page.toast.oauthLinkCopied': 'Lien OAuth copié',
|
||||||
@@ -2057,6 +2072,9 @@ export const settingsDict = {
|
|||||||
'settings.remoteInstances.relay.toast.offerFailed': 'Échec de la création du lien d’association',
|
'settings.remoteInstances.relay.toast.offerFailed': 'Échec de la création du lien d’association',
|
||||||
'settings.remoteInstances.relay.toast.linkCopied': 'Lien d’association copié',
|
'settings.remoteInstances.relay.toast.linkCopied': 'Lien d’association copié',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'Version d’OpenCode',
|
'settings.openchamber.about.field.openCodeVersion': 'Version d’OpenCode',
|
||||||
|
'settings.openchamber.about.field.instanceUrls': 'URLs de l’instance',
|
||||||
|
'settings.openchamber.about.field.applicationUrl': 'Application',
|
||||||
|
'settings.openchamber.about.field.tunnelUrl': 'Tunnel',
|
||||||
'settings.openchamber.about.state.unknown': 'inconnue',
|
'settings.openchamber.about.state.unknown': 'inconnue',
|
||||||
'settings.voice.page.field.ttsInputMode': 'Mode d’entrée TTS',
|
'settings.voice.page.field.ttsInputMode': 'Mode d’entrée TTS',
|
||||||
'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé',
|
'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé',
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ export const dict = {
|
|||||||
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}',
|
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Pause {taskName}',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Activé',
|
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Activé',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.paused': 'En pause',
|
'sessions.scheduledTasks.dialog.taskToggle.paused': 'En pause',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.note': 'Gérée par le fichier de boucle {file}',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': "L'activation est contrôlée par le fichier de boucle ; définissez enabled dans le frontmatter Markdown",
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Les tâches de boucle sont configurées dans leur fichier Markdown .agents/loops',
|
||||||
'sessions.scheduledTasks.editor.title.edit': 'Modifier une tâche planifiée',
|
'sessions.scheduledTasks.editor.title.edit': 'Modifier une tâche planifiée',
|
||||||
'sessions.scheduledTasks.editor.title.new': 'Nouvelle tâche planifiée',
|
'sessions.scheduledTasks.editor.title.new': 'Nouvelle tâche planifiée',
|
||||||
'sessions.scheduledTasks.editor.description': 'Configurez une tâche côté serveur qui crée une nouvelle session et envoie un prompt.',
|
'sessions.scheduledTasks.editor.description': 'Configurez une tâche côté serveur qui crée une nouvelle session et envoie un prompt.',
|
||||||
@@ -366,6 +369,10 @@ export const dict = {
|
|||||||
'sessions.sidebar.session.status.pinned': 'Session épinglée',
|
'sessions.sidebar.session.status.pinned': 'Session épinglée',
|
||||||
'sessions.sidebar.session.status.movingToWorktree': 'Déplacement de la session vers un nouveau worktree',
|
'sessions.sidebar.session.status.movingToWorktree': 'Déplacement de la session vers un nouveau worktree',
|
||||||
'sessions.sidebar.session.status.permissionRequired': 'Autorisation requise',
|
'sessions.sidebar.session.status.permissionRequired': 'Autorisation requise',
|
||||||
|
'sessions.sidebar.session.status.questionPendingSingle': '1 question en attente',
|
||||||
|
'sessions.sidebar.session.status.questionPendingMany': '{count} questions en attente',
|
||||||
|
'sessions.sidebar.session.status.activeFor': 'Active depuis {duration}',
|
||||||
|
'sessions.sidebar.session.status.lastTurnDuration': 'Le dernier tour a duré {duration}',
|
||||||
'sessions.sidebar.session.subsessions.collapse': 'Réduire les sous-sessions',
|
'sessions.sidebar.session.subsessions.collapse': 'Réduire les sous-sessions',
|
||||||
'sessions.sidebar.session.subsessions.expand': 'Développer les sous-sessions',
|
'sessions.sidebar.session.subsessions.expand': 'Développer les sous-sessions',
|
||||||
'sessions.sidebar.dialogs.deleteSession.title': 'Supprimer la session ?',
|
'sessions.sidebar.dialogs.deleteSession.title': 'Supprimer la session ?',
|
||||||
@@ -929,6 +936,10 @@ export const dict = {
|
|||||||
'contextPanel.editorEmpty.description': 'Choisissez un fichier dans l’arborescence pour commencer.',
|
'contextPanel.editorEmpty.description': 'Choisissez un fichier dans l’arborescence pour commencer.',
|
||||||
'contextRail.surface.editor.description': 'Modifier les fichiers du projet',
|
'contextRail.surface.editor.description': 'Modifier les fichiers du projet',
|
||||||
'contextRail.surface.git.description': 'Commits, branches et pull requests',
|
'contextRail.surface.git.description': 'Commits, branches et pull requests',
|
||||||
|
'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} fichier modifié',
|
||||||
|
'contextRail.surface.git.changesCountAriaPlural': '{label}, {count} fichiers modifiés',
|
||||||
|
'contextRail.surface.git.changesCountTooltipSingle': '{count} fichier modifié',
|
||||||
|
'contextRail.surface.git.changesCountTooltipPlural': '{count} fichiers modifiés',
|
||||||
'contextRail.surface.terminal.description': 'Terminal intégré',
|
'contextRail.surface.terminal.description': 'Terminal intégré',
|
||||||
'contextRail.surface.diff.description': 'Passer en revue les modifications',
|
'contextRail.surface.diff.description': 'Passer en revue les modifications',
|
||||||
'contextPanel.mode.walkthrough': 'Parcours',
|
'contextPanel.mode.walkthrough': 'Parcours',
|
||||||
@@ -968,8 +979,11 @@ export const dict = {
|
|||||||
'walkthrough.toc.moreFiles': 'Autres fichiers : {count}',
|
'walkthrough.toc.moreFiles': 'Autres fichiers : {count}',
|
||||||
'walkthrough.toc.uncovered': 'Non traité : {count}',
|
'walkthrough.toc.uncovered': 'Non traité : {count}',
|
||||||
'walkthrough.toc.resize': 'Redimensionner la colonne du sommaire',
|
'walkthrough.toc.resize': 'Redimensionner la colonne du sommaire',
|
||||||
'walkthrough.importance.critical': 'Critique',
|
'walkthrough.importance.critical': 'Changement clé',
|
||||||
|
'walkthrough.importance.criticalHint': "Cette étape porte l'essentiel du changement, lisez-la attentivement. Ce n'est pas un problème détecté dans votre code.",
|
||||||
'walkthrough.importance.context': 'Contexte',
|
'walkthrough.importance.context': 'Contexte',
|
||||||
|
'walkthrough.importance.contextHint': 'Un changement de soutien, présent pour que le reste ait du sens.',
|
||||||
|
'walkthrough.help.guide': 'Comment fonctionnent les walkthroughs',
|
||||||
'walkthrough.blocked.noModel.title': 'Aucun petit modèle disponible',
|
'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.noModel.description': 'Connectez-vous à un fournisseur de modèles pour générer une revue.',
|
||||||
'walkthrough.blocked.emptyDiff.title': 'Rien à examiner',
|
'walkthrough.blocked.emptyDiff.title': 'Rien à examiner',
|
||||||
@@ -984,6 +998,8 @@ export const dict = {
|
|||||||
'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.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.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.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.serverUnsupported.title': 'Ce serveur ne prend pas en charge les walkthroughs',
|
||||||
|
'walkthrough.blocked.serverUnsupported.description': "Le serveur OpenChamber auquel cette application est connectée n'a pas répondu à l'API walkthrough : il est donc plus ancien que l'application. Mettez le serveur à jour en 1.18 ou plus récent, puis actualisez.",
|
||||||
'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.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.',
|
'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.plan.description': 'Voir le plan actuel',
|
||||||
@@ -1453,6 +1469,10 @@ export const dict = {
|
|||||||
'directoryExplorerDialog.browse.directories': 'Annuaires',
|
'directoryExplorerDialog.browse.directories': 'Annuaires',
|
||||||
'directoryExplorerDialog.browse.loading': 'Chargement des répertoires...',
|
'directoryExplorerDialog.browse.loading': 'Chargement des répertoires...',
|
||||||
'directoryExplorerDialog.browse.empty': 'Aucun répertoire correspondant.',
|
'directoryExplorerDialog.browse.empty': 'Aucun répertoire correspondant.',
|
||||||
|
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber doit accéder à ce dossier.',
|
||||||
|
'directoryExplorerDialog.browse.loadFailed': 'Impossible de charger ce dossier.',
|
||||||
|
'directoryExplorerDialog.browse.grantAccess': 'Autoriser l’accès',
|
||||||
|
'directoryExplorerDialog.browse.retry': 'Réessayer',
|
||||||
'directoryExplorerDialog.browse.parentDirectory': 'Annuaire parent',
|
'directoryExplorerDialog.browse.parentDirectory': 'Annuaire parent',
|
||||||
'directoryExplorerDialog.browse.addedBadge': 'Ajouté',
|
'directoryExplorerDialog.browse.addedBadge': 'Ajouté',
|
||||||
'directoryExplorerDialog.browse.quickAdd': 'Ajouter',
|
'directoryExplorerDialog.browse.quickAdd': 'Ajouter',
|
||||||
@@ -1519,7 +1539,7 @@ export const dict = {
|
|||||||
'helpDialog.item.toggleTerminalExpanded': 'Terminal à bascule étendu',
|
'helpDialog.item.toggleTerminalExpanded': 'Terminal à bascule étendu',
|
||||||
'helpDialog.item.togglePlanContextPanel': 'Toggle Panneau contextuel du plan',
|
'helpDialog.item.togglePlanContextPanel': 'Toggle Panneau contextuel du plan',
|
||||||
'helpDialog.item.cycleTheme': 'Basculer le thème (clair → sombre → système)',
|
'helpDialog.item.cycleTheme': 'Basculer le thème (clair → sombre → système)',
|
||||||
'helpDialog.item.switchProject': 'Changer de projet',
|
'helpDialog.item.switchContextSurface': 'Basculer la surface du panneau contextuel (touche numérique)',
|
||||||
'helpDialog.item.toggleServicesMenu': 'Basculer le menu des services',
|
'helpDialog.item.toggleServicesMenu': 'Basculer le menu des services',
|
||||||
'helpDialog.item.cycleServicesTab': 'Onglet Services de vélo',
|
'helpDialog.item.cycleServicesTab': 'Onglet Services de vélo',
|
||||||
'helpDialog.item.openSettings': 'Ouvrir les paramètres',
|
'helpDialog.item.openSettings': 'Ouvrir les paramètres',
|
||||||
@@ -1778,6 +1798,8 @@ export const dict = {
|
|||||||
'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…',
|
'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…',
|
||||||
'sessions.sidebar.group.empty.loadFailed': 'Impossible d’actualiser les sessions.',
|
'sessions.sidebar.group.empty.loadFailed': 'Impossible d’actualiser les sessions.',
|
||||||
'sessions.sidebar.group.empty.retry': 'Réessayer',
|
'sessions.sidebar.group.empty.retry': 'Réessayer',
|
||||||
|
'sessions.sidebar.group.empty.permissionDenied': 'L’accès au dossier est requis.',
|
||||||
|
'sessions.sidebar.group.empty.grantAccess': 'Autoriser l’accès',
|
||||||
'chat.unifiedControls.title': 'Contrôles',
|
'chat.unifiedControls.title': 'Contrôles',
|
||||||
'chat.unifiedControls.model.title': 'Modèle',
|
'chat.unifiedControls.model.title': 'Modèle',
|
||||||
'chat.unifiedControls.model.noRecent': 'Aucun modèle récent',
|
'chat.unifiedControls.model.noRecent': 'Aucun modèle récent',
|
||||||
@@ -2643,6 +2665,9 @@ export const dict = {
|
|||||||
'common.relative.daysAgoCompact': '{count} j',
|
'common.relative.daysAgoCompact': '{count} j',
|
||||||
'common.relative.weeksAgoCompact': '{count} sem',
|
'common.relative.weeksAgoCompact': '{count} sem',
|
||||||
'common.relative.yearsAgoCompact': '{count} a',
|
'common.relative.yearsAgoCompact': '{count} a',
|
||||||
|
'common.duration.secondsCompact': '{seconds}s',
|
||||||
|
'common.duration.minutesSecondsCompact': '{minutes}m {seconds}s',
|
||||||
|
'common.duration.hoursMinutesCompact': '{hours}h {minutes}m',
|
||||||
'contextFileOpen.failure.tooLarge': 'Le fichier est trop volumineux pour être ouvert (> {count} lignes)',
|
'contextFileOpen.failure.tooLarge': 'Le fichier est trop volumineux pour être ouvert (> {count} lignes)',
|
||||||
'contextFileOpen.failure.missing': 'Fichier introuvable',
|
'contextFileOpen.failure.missing': 'Fichier introuvable',
|
||||||
'contextFileOpen.failure.unreadable': 'Impossible d’ouvrir le fichier',
|
'contextFileOpen.failure.unreadable': 'Impossible d’ouvrir le fichier',
|
||||||
|
|||||||
@@ -477,6 +477,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.about.title': 'OpenChamber について',
|
'settings.openchamber.about.title': 'OpenChamber について',
|
||||||
'settings.openchamber.about.field.version': 'バージョン',
|
'settings.openchamber.about.field.version': 'バージョン',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode バージョン',
|
'settings.openchamber.about.field.openCodeVersion': 'OpenCode バージョン',
|
||||||
|
'settings.openchamber.about.field.instanceUrls': 'インスタンスのURL',
|
||||||
|
'settings.openchamber.about.field.applicationUrl': 'アプリケーション',
|
||||||
|
'settings.openchamber.about.field.tunnelUrl': 'トンネル',
|
||||||
'settings.openchamber.about.state.checking': '確認中...',
|
'settings.openchamber.about.state.checking': '確認中...',
|
||||||
'settings.openchamber.about.state.upToDate': '最新です',
|
'settings.openchamber.about.state.upToDate': '最新です',
|
||||||
'settings.openchamber.about.state.unknown': '不明',
|
'settings.openchamber.about.state.unknown': '不明',
|
||||||
@@ -1121,6 +1124,8 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替',
|
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く',
|
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'ファイルサーフェスを開く',
|
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'ファイルサーフェスを開く',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'コンテキストパネルのサーフェスを切り替え',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session',
|
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き',
|
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ',
|
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ',
|
||||||
@@ -1405,6 +1410,17 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
|
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
|
||||||
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方法 {index}',
|
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方法 {index}',
|
||||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '認証コードを貼り付け',
|
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '認証コードを貼り付け',
|
||||||
|
'settings.providers.page.auth.oauth.starting': '認証を開始しています…',
|
||||||
|
'settings.providers.page.auth.oauth.waiting': '認証を待っています…',
|
||||||
|
'settings.providers.page.auth.oauth.waitingHint': 'ブラウザーでサインインを完了してください。このページは開いたままにしてください。接続は自動的に完了します。',
|
||||||
|
'settings.providers.page.auth.oauth.codeHint': 'ブラウザーから認証コードをコピーして、ここに貼り付けてください。',
|
||||||
|
'settings.providers.page.auth.oauth.deviceCodeLabel': 'デバイスコード',
|
||||||
|
'settings.providers.page.auth.oauth.linkLabel': '認証リンク',
|
||||||
|
'settings.providers.page.auth.oauth.promptRequired': '続行するには「{field}」を入力してください',
|
||||||
|
'settings.providers.page.auth.oauth.error.sessionExpired': '認証リクエストの有効期限が切れました。もう一度接続してやり直してください。',
|
||||||
|
'settings.providers.page.auth.oauth.error.codeRequired': 'このプロバイダーにはブラウザーの認証コードが必要です。',
|
||||||
|
'settings.providers.page.auth.oauth.error.declined': '認証が拒否されたか、完了しませんでした。',
|
||||||
|
'settings.providers.page.auth.oauth.error.invalidInput': '入力された内容は拒否されました。',
|
||||||
'settings.providers.page.auth.connected': '接続済み',
|
'settings.providers.page.auth.connected': '接続済み',
|
||||||
'settings.providers.page.auth.incomplete': '認証情報が不足しています',
|
'settings.providers.page.auth.incomplete': '認証情報が不足しています',
|
||||||
'settings.providers.page.auth.incompleteHint': '· チャットでこのプロバイダーを使う前に API キーまたは {env:VAR} を追加してください',
|
'settings.providers.page.auth.incompleteHint': '· チャットでこのプロバイダーを使う前に API キーまたは {env:VAR} を追加してください',
|
||||||
@@ -1437,6 +1453,9 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.actions.open': '開く',
|
'settings.providers.page.actions.open': '開く',
|
||||||
'settings.providers.page.actions.copy': 'コピー',
|
'settings.providers.page.actions.copy': 'コピー',
|
||||||
'settings.providers.page.actions.complete': '完了',
|
'settings.providers.page.actions.complete': '完了',
|
||||||
|
'settings.providers.page.actions.continue': '続行',
|
||||||
|
'settings.providers.page.actions.cancel': 'キャンセル',
|
||||||
|
'settings.providers.page.actions.tryAgain': '再試行',
|
||||||
'settings.providers.page.actions.hide': '非表示',
|
'settings.providers.page.actions.hide': '非表示',
|
||||||
'settings.providers.page.actions.reconnect': '再接続',
|
'settings.providers.page.actions.reconnect': '再接続',
|
||||||
'settings.providers.page.actions.edit': '編集',
|
'settings.providers.page.actions.edit': '編集',
|
||||||
@@ -1452,7 +1471,6 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.toast.apiKeySaved': 'API キーを保存しました',
|
'settings.providers.page.toast.apiKeySaved': 'API キーを保存しました',
|
||||||
'settings.providers.page.toast.oauthStartFailed': 'OAuth フローの開始に失敗しました',
|
'settings.providers.page.toast.oauthStartFailed': 'OAuth フローの開始に失敗しました',
|
||||||
'settings.providers.page.toast.oauthDetailsMissing': 'OAuth の詳細が返されませんでした',
|
'settings.providers.page.toast.oauthDetailsMissing': 'OAuth の詳細が返されませんでした',
|
||||||
'settings.providers.page.toast.completeOAuthInBrowser': 'ブラウザで OAuth フローを完了してください',
|
|
||||||
'settings.providers.page.toast.oauthCompleteFailed': 'OAuth フローの完了に失敗しました',
|
'settings.providers.page.toast.oauthCompleteFailed': 'OAuth フローの完了に失敗しました',
|
||||||
'settings.providers.page.toast.oauthCompleted': 'OAuth 接続が完了しました',
|
'settings.providers.page.toast.oauthCompleted': 'OAuth 接続が完了しました',
|
||||||
'settings.providers.page.toast.oauthLinkCopied': 'OAuth リンクをコピーしました',
|
'settings.providers.page.toast.oauthLinkCopied': 'OAuth リンクをコピーしました',
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName}を一時停止',
|
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName}を一時停止',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.enabled': '有効',
|
'sessions.scheduledTasks.dialog.taskToggle.enabled': '有効',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.paused': '一時停止中',
|
'sessions.scheduledTasks.dialog.taskToggle.paused': '一時停止中',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.note': 'ループファイル {file} によって管理',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '有効状態はループファイルが制御します。Markdown フロントマターで enabled を設定してください',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'ループタスクは .agents/loops の Markdown ファイルで設定します',
|
||||||
'sessions.scheduledTasks.editor.title.edit': 'スケジュールタスクを編集',
|
'sessions.scheduledTasks.editor.title.edit': 'スケジュールタスクを編集',
|
||||||
'sessions.scheduledTasks.editor.title.new': '新しいスケジュールタスク',
|
'sessions.scheduledTasks.editor.title.new': '新しいスケジュールタスク',
|
||||||
'sessions.scheduledTasks.editor.description': '新しいセッションを作成しプロンプトを送信するサーバーサイドタスクを設定します。',
|
'sessions.scheduledTasks.editor.description': '新しいセッションを作成しプロンプトを送信するサーバーサイドタスクを設定します。',
|
||||||
@@ -531,6 +534,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.session.status.pinned': 'ピン留めされたセッション',
|
'sessions.sidebar.session.status.pinned': 'ピン留めされたセッション',
|
||||||
'sessions.sidebar.session.status.movingToWorktree': 'セッションを新しいworktreeへ移動中',
|
'sessions.sidebar.session.status.movingToWorktree': 'セッションを新しいworktreeへ移動中',
|
||||||
'sessions.sidebar.session.status.permissionRequired': '権限が必要です',
|
'sessions.sidebar.session.status.permissionRequired': '権限が必要です',
|
||||||
|
'sessions.sidebar.session.status.questionPendingSingle': '保留中の質問が1件あります',
|
||||||
|
'sessions.sidebar.session.status.questionPendingMany': '保留中の質問が{count}件あります',
|
||||||
|
'sessions.sidebar.session.status.activeFor': 'アクティブ時間 {duration}',
|
||||||
|
'sessions.sidebar.session.status.lastTurnDuration': '前回のターンの所要時間 {duration}',
|
||||||
'sessions.sidebar.session.subsessions.collapse': 'サブセッションを折りたたむ',
|
'sessions.sidebar.session.subsessions.collapse': 'サブセッションを折りたたむ',
|
||||||
'sessions.sidebar.session.subsessions.expand': 'サブセッションを展開',
|
'sessions.sidebar.session.subsessions.expand': 'サブセッションを展開',
|
||||||
'sessions.sidebar.dialogs.deleteSession.title': 'セッションを削除しますか?',
|
'sessions.sidebar.dialogs.deleteSession.title': 'セッションを削除しますか?',
|
||||||
@@ -1101,6 +1108,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextPanel.editorEmpty.description': 'ツリーからファイルを選んで編集を始めましょう。',
|
'contextPanel.editorEmpty.description': 'ツリーからファイルを選んで編集を始めましょう。',
|
||||||
'contextRail.surface.editor.description': 'プロジェクトのファイルを編集',
|
'contextRail.surface.editor.description': 'プロジェクトのファイルを編集',
|
||||||
'contextRail.surface.git.description': 'コミット・ブランチ・プルリクエスト',
|
'contextRail.surface.git.description': 'コミット・ブランチ・プルリクエスト',
|
||||||
|
'contextRail.surface.git.changesCountAriaSingle': '{label}、変更ファイル{count}件',
|
||||||
|
'contextRail.surface.git.changesCountAriaPlural': '{label}、変更ファイル{count}件',
|
||||||
|
'contextRail.surface.git.changesCountTooltipSingle': '変更ファイル{count}件',
|
||||||
|
'contextRail.surface.git.changesCountTooltipPlural': '変更ファイル{count}件',
|
||||||
'contextRail.surface.terminal.description': '内蔵ターミナル',
|
'contextRail.surface.terminal.description': '内蔵ターミナル',
|
||||||
'contextRail.surface.diff.description': '作業中の変更をレビュー',
|
'contextRail.surface.diff.description': '作業中の変更をレビュー',
|
||||||
'contextPanel.mode.walkthrough': 'ウォークスルー',
|
'contextPanel.mode.walkthrough': 'ウォークスルー',
|
||||||
@@ -1140,8 +1151,11 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'walkthrough.toc.moreFiles': 'その他のファイル: {count}',
|
'walkthrough.toc.moreFiles': 'その他のファイル: {count}',
|
||||||
'walkthrough.toc.uncovered': '未対応: {count}',
|
'walkthrough.toc.uncovered': '未対応: {count}',
|
||||||
'walkthrough.toc.resize': '目次の列幅を変更',
|
'walkthrough.toc.resize': '目次の列幅を変更',
|
||||||
'walkthrough.importance.critical': '重要',
|
'walkthrough.importance.critical': '主要な変更',
|
||||||
|
'walkthrough.importance.criticalHint': 'このステップが変更全体を動かしているため、じっくり読んでください。コードで見つかった問題ではありません。',
|
||||||
'walkthrough.importance.context': '補足',
|
'walkthrough.importance.context': '補足',
|
||||||
|
'walkthrough.importance.contextHint': '全体を理解するために添えられた補助的な変更です。',
|
||||||
|
'walkthrough.help.guide': 'ウォークスルーの仕組み',
|
||||||
'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません',
|
'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません',
|
||||||
'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。',
|
'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。',
|
||||||
'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません',
|
'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません',
|
||||||
@@ -1156,6 +1170,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'スモールモデルは出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。',
|
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'スモールモデルは出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。',
|
||||||
'walkthrough.blocked.onlyGenerated.title': '生成ファイルのみが変更されています',
|
'walkthrough.blocked.onlyGenerated.title': '生成ファイルのみが変更されています',
|
||||||
'walkthrough.blocked.onlyGenerated.description': 'ここでの変更はロックファイルなどツールが生成した出力だけで、レビューは意図的にこれらを対象外にしています。',
|
'walkthrough.blocked.onlyGenerated.description': 'ここでの変更はロックファイルなどツールが生成した出力だけで、レビューは意図的にこれらを対象外にしています。',
|
||||||
|
'walkthrough.blocked.serverUnsupported.title': 'このサーバーはウォークスルーに対応していません',
|
||||||
|
'walkthrough.blocked.serverUnsupported.description': 'このアプリが接続している OpenChamber サーバーはウォークスルー API に応答しませんでした。つまりアプリより古いバージョンです。サーバーを 1.18 以降に更新してから再読み込みしてください。',
|
||||||
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'スモールモデルが扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。',
|
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'スモールモデルが扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。',
|
||||||
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'スモールモデルはウォークスルーに必要な構造化応答をサポートしていません。',
|
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'スモールモデルはウォークスルーに必要な構造化応答をサポートしていません。',
|
||||||
'contextRail.surface.plan.description': '現在のプランを表示',
|
'contextRail.surface.plan.description': '現在のプランを表示',
|
||||||
@@ -1636,6 +1652,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'directoryExplorerDialog.browse.directories': 'ディレクトリ',
|
'directoryExplorerDialog.browse.directories': 'ディレクトリ',
|
||||||
'directoryExplorerDialog.browse.loading': 'ディレクトリを読み込み中...',
|
'directoryExplorerDialog.browse.loading': 'ディレクトリを読み込み中...',
|
||||||
'directoryExplorerDialog.browse.empty': '一致するディレクトリがありません。',
|
'directoryExplorerDialog.browse.empty': '一致するディレクトリがありません。',
|
||||||
|
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber がこのフォルダにアクセスする必要があります。',
|
||||||
|
'directoryExplorerDialog.browse.loadFailed': 'このフォルダを読み込めませんでした。',
|
||||||
|
'directoryExplorerDialog.browse.grantAccess': 'アクセスを許可',
|
||||||
|
'directoryExplorerDialog.browse.retry': '再試行',
|
||||||
'directoryExplorerDialog.browse.parentDirectory': '親ディレクトリ',
|
'directoryExplorerDialog.browse.parentDirectory': '親ディレクトリ',
|
||||||
'directoryExplorerDialog.browse.addedBadge': '追加済み',
|
'directoryExplorerDialog.browse.addedBadge': '追加済み',
|
||||||
'directoryExplorerDialog.browse.quickAdd': '追加',
|
'directoryExplorerDialog.browse.quickAdd': '追加',
|
||||||
@@ -1702,7 +1722,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'helpDialog.item.toggleTerminalExpanded': 'ターミナル展開の切り替え',
|
'helpDialog.item.toggleTerminalExpanded': 'ターミナル展開の切り替え',
|
||||||
'helpDialog.item.togglePlanContextPanel': '計画コンテキストパネルの切り替え',
|
'helpDialog.item.togglePlanContextPanel': '計画コンテキストパネルの切り替え',
|
||||||
'helpDialog.item.cycleTheme': 'テーマ切り替え(ライト→ダーク→システム)',
|
'helpDialog.item.cycleTheme': 'テーマ切り替え(ライト→ダーク→システム)',
|
||||||
'helpDialog.item.switchProject': 'プロジェクトを切り替え',
|
'helpDialog.item.switchContextSurface': 'コンテキストパネルのサーフェスを切り替え(数字キー)',
|
||||||
'helpDialog.item.toggleServicesMenu': 'サービスの切り替え',
|
'helpDialog.item.toggleServicesMenu': 'サービスの切り替え',
|
||||||
'helpDialog.item.cycleServicesTab': 'サービス変数の切り替え',
|
'helpDialog.item.cycleServicesTab': 'サービス変数の切り替え',
|
||||||
'helpDialog.item.openSettings': '設定を開く',
|
'helpDialog.item.openSettings': '設定を開く',
|
||||||
@@ -1987,6 +2007,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…',
|
'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…',
|
||||||
'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。',
|
'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。',
|
||||||
'sessions.sidebar.group.empty.retry': '再試行',
|
'sessions.sidebar.group.empty.retry': '再試行',
|
||||||
|
'sessions.sidebar.group.empty.permissionDenied': 'フォルダへのアクセスが必要です。',
|
||||||
|
'sessions.sidebar.group.empty.grantAccess': 'アクセスを許可',
|
||||||
'chat.unifiedControls.title': 'コントロール',
|
'chat.unifiedControls.title': 'コントロール',
|
||||||
'chat.unifiedControls.model.title': 'モデル',
|
'chat.unifiedControls.model.title': 'モデル',
|
||||||
'chat.unifiedControls.model.noRecent': '最近のモデルはありません',
|
'chat.unifiedControls.model.noRecent': '最近のモデルはありません',
|
||||||
@@ -2891,6 +2913,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'common.relative.daysAgoCompact': '{count}日前',
|
'common.relative.daysAgoCompact': '{count}日前',
|
||||||
'common.relative.weeksAgoCompact': '{count}週前',
|
'common.relative.weeksAgoCompact': '{count}週前',
|
||||||
'common.relative.yearsAgoCompact': '{count}年前',
|
'common.relative.yearsAgoCompact': '{count}年前',
|
||||||
|
'common.duration.secondsCompact': '{seconds}秒',
|
||||||
|
'common.duration.minutesSecondsCompact': '{minutes}分{seconds}秒',
|
||||||
|
'common.duration.hoursMinutesCompact': '{hours}時間{minutes}分',
|
||||||
'contextFileOpen.failure.tooLarge': 'ファイルが大きすぎて開けません(>{count}行)',
|
'contextFileOpen.failure.tooLarge': 'ファイルが大きすぎて開けません(>{count}行)',
|
||||||
'contextFileOpen.failure.missing': 'ファイルが見つかりません',
|
'contextFileOpen.failure.missing': 'ファイルが見つかりません',
|
||||||
'contextFileOpen.failure.unreadable': 'ファイルを開けませんでした',
|
'contextFileOpen.failure.unreadable': 'ファイルを開けませんでした',
|
||||||
|
|||||||
@@ -444,6 +444,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.about.title': 'OpenChamber 정보',
|
'settings.openchamber.about.title': 'OpenChamber 정보',
|
||||||
'settings.openchamber.about.field.version': '버전',
|
'settings.openchamber.about.field.version': '버전',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 버전',
|
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 버전',
|
||||||
|
'settings.openchamber.about.field.instanceUrls': '인스턴스 URL',
|
||||||
|
'settings.openchamber.about.field.applicationUrl': '애플리케이션',
|
||||||
|
'settings.openchamber.about.field.tunnelUrl': '터널',
|
||||||
'settings.openchamber.about.state.checking': '확인 중...',
|
'settings.openchamber.about.state.checking': '확인 중...',
|
||||||
'settings.openchamber.about.state.upToDate': '최신 상태',
|
'settings.openchamber.about.state.upToDate': '최신 상태',
|
||||||
'settings.openchamber.about.state.unknown': '알 수 없음',
|
'settings.openchamber.about.state.unknown': '알 수 없음',
|
||||||
@@ -1088,6 +1091,8 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환',
|
'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기',
|
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '파일 서피스 열기',
|
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '파일 서피스 열기',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '컨텍스트 패널 서피스 전환',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션',
|
'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안',
|
'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안',
|
||||||
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창',
|
'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창',
|
||||||
@@ -1372,6 +1377,17 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
|
'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...',
|
||||||
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 방식 {index}',
|
'settings.providers.page.auth.oauthMethodFallback': 'OAuth 방식 {index}',
|
||||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'authorization code 붙여넣기',
|
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'authorization code 붙여넣기',
|
||||||
|
'settings.providers.page.auth.oauth.starting': '인증을 시작하는 중…',
|
||||||
|
'settings.providers.page.auth.oauth.waiting': '인증을 기다리는 중…',
|
||||||
|
'settings.providers.page.auth.oauth.waitingHint': '브라우저에서 로그인을 완료하세요. 이 페이지를 열어 두면 연결이 자동으로 완료됩니다.',
|
||||||
|
'settings.providers.page.auth.oauth.codeHint': '브라우저에서 인증 코드를 복사해 여기에 붙여넣으세요.',
|
||||||
|
'settings.providers.page.auth.oauth.deviceCodeLabel': '기기 코드',
|
||||||
|
'settings.providers.page.auth.oauth.linkLabel': '인증 링크',
|
||||||
|
'settings.providers.page.auth.oauth.promptRequired': '계속하려면 “{field}”을(를) 입력하세요',
|
||||||
|
'settings.providers.page.auth.oauth.error.sessionExpired': '인증 요청이 만료되었습니다. 다시 연결해 처음부터 시작하세요.',
|
||||||
|
'settings.providers.page.auth.oauth.error.codeRequired': '이 제공자에는 브라우저의 인증 코드가 필요합니다.',
|
||||||
|
'settings.providers.page.auth.oauth.error.declined': '인증이 거부되었거나 완료되지 않았습니다.',
|
||||||
|
'settings.providers.page.auth.oauth.error.invalidInput': '입력한 정보가 거부되었습니다.',
|
||||||
'settings.providers.page.auth.connected': '연결됨',
|
'settings.providers.page.auth.connected': '연결됨',
|
||||||
'settings.providers.page.auth.incomplete': '자격 증명 없음',
|
'settings.providers.page.auth.incomplete': '자격 증명 없음',
|
||||||
'settings.providers.page.auth.incompleteHint': '· 채팅에서 이 공급자를 사용하기 전에 API 키 또는 {env:VAR}을(를) 추가하세요',
|
'settings.providers.page.auth.incompleteHint': '· 채팅에서 이 공급자를 사용하기 전에 API 키 또는 {env:VAR}을(를) 추가하세요',
|
||||||
@@ -1404,6 +1420,9 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.actions.open': '열기',
|
'settings.providers.page.actions.open': '열기',
|
||||||
'settings.providers.page.actions.copy': '복사',
|
'settings.providers.page.actions.copy': '복사',
|
||||||
'settings.providers.page.actions.complete': '완료',
|
'settings.providers.page.actions.complete': '완료',
|
||||||
|
'settings.providers.page.actions.continue': '계속',
|
||||||
|
'settings.providers.page.actions.cancel': '취소',
|
||||||
|
'settings.providers.page.actions.tryAgain': '다시 시도',
|
||||||
'settings.providers.page.actions.hide': '숨기기',
|
'settings.providers.page.actions.hide': '숨기기',
|
||||||
'settings.providers.page.actions.reconnect': '재연결',
|
'settings.providers.page.actions.reconnect': '재연결',
|
||||||
'settings.providers.page.actions.edit': '편집',
|
'settings.providers.page.actions.edit': '편집',
|
||||||
@@ -1419,7 +1438,6 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.toast.apiKeySaved': 'API key가 저장되었습니다',
|
'settings.providers.page.toast.apiKeySaved': 'API key가 저장되었습니다',
|
||||||
'settings.providers.page.toast.oauthStartFailed': 'OAuth flow를 시작하지 못했습니다',
|
'settings.providers.page.toast.oauthStartFailed': 'OAuth flow를 시작하지 못했습니다',
|
||||||
'settings.providers.page.toast.oauthDetailsMissing': '반환된 OAuth 세부 정보가 없습니다',
|
'settings.providers.page.toast.oauthDetailsMissing': '반환된 OAuth 세부 정보가 없습니다',
|
||||||
'settings.providers.page.toast.completeOAuthInBrowser': '브라우저에서 OAuth flow를 완료하세요',
|
|
||||||
'settings.providers.page.toast.oauthCompleteFailed': 'OAuth flow를 완료하지 못했습니다',
|
'settings.providers.page.toast.oauthCompleteFailed': 'OAuth flow를 완료하지 못했습니다',
|
||||||
'settings.providers.page.toast.oauthCompleted': 'OAuth 연결이 완료되었습니다',
|
'settings.providers.page.toast.oauthCompleted': 'OAuth 연결이 완료되었습니다',
|
||||||
'settings.providers.page.toast.oauthLinkCopied': 'OAuth 링크가 복사되었습니다',
|
'settings.providers.page.toast.oauthLinkCopied': 'OAuth 링크가 복사되었습니다',
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} 일시 중지',
|
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': '{taskName} 일시 중지',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.enabled': '활성화됨',
|
'sessions.scheduledTasks.dialog.taskToggle.enabled': '활성화됨',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.paused': '일시 중지됨',
|
'sessions.scheduledTasks.dialog.taskToggle.paused': '일시 중지됨',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.note': '루프 파일에서 관리됨: {file}',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': '활성화 여부는 루프 파일이 제어합니다. Markdown frontmatter에서 enabled를 설정하세요',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': '루프 작업은 .agents/loops Markdown 파일에서 구성합니다',
|
||||||
'sessions.scheduledTasks.editor.title.edit': '예약 작업 편집',
|
'sessions.scheduledTasks.editor.title.edit': '예약 작업 편집',
|
||||||
'sessions.scheduledTasks.editor.title.new': '새 예약 작업',
|
'sessions.scheduledTasks.editor.title.new': '새 예약 작업',
|
||||||
'sessions.scheduledTasks.editor.description': '새 세션을 만들고 프롬프트를 보내는 서버 작업을 설정합니다.',
|
'sessions.scheduledTasks.editor.description': '새 세션을 만들고 프롬프트를 보내는 서버 작업을 설정합니다.',
|
||||||
@@ -531,6 +534,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.session.status.pinned': '고정된 세션',
|
'sessions.sidebar.session.status.pinned': '고정된 세션',
|
||||||
'sessions.sidebar.session.status.movingToWorktree': '세션을 새 worktree로 이동하는 중',
|
'sessions.sidebar.session.status.movingToWorktree': '세션을 새 worktree로 이동하는 중',
|
||||||
'sessions.sidebar.session.status.permissionRequired': '권한 필요',
|
'sessions.sidebar.session.status.permissionRequired': '권한 필요',
|
||||||
|
'sessions.sidebar.session.status.questionPendingSingle': '대기 중인 질문 1개',
|
||||||
|
'sessions.sidebar.session.status.questionPendingMany': '대기 중인 질문 {count}개',
|
||||||
|
'sessions.sidebar.session.status.activeFor': '{duration} 동안 활성 상태',
|
||||||
|
'sessions.sidebar.session.status.lastTurnDuration': '마지막 턴 소요 시간 {duration}',
|
||||||
'sessions.sidebar.session.subsessions.collapse': '하위 세션 접기',
|
'sessions.sidebar.session.subsessions.collapse': '하위 세션 접기',
|
||||||
'sessions.sidebar.session.subsessions.expand': '하위 세션 펼치기',
|
'sessions.sidebar.session.subsessions.expand': '하위 세션 펼치기',
|
||||||
'sessions.sidebar.dialogs.deleteSession.title': '세션 삭제?',
|
'sessions.sidebar.dialogs.deleteSession.title': '세션 삭제?',
|
||||||
@@ -1105,6 +1112,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextPanel.editorEmpty.description': '트리에서 파일을 선택해 편집을 시작하세요.',
|
'contextPanel.editorEmpty.description': '트리에서 파일을 선택해 편집을 시작하세요.',
|
||||||
'contextRail.surface.editor.description': '프로젝트 파일 편집',
|
'contextRail.surface.editor.description': '프로젝트 파일 편집',
|
||||||
'contextRail.surface.git.description': '커밋, 브랜치, 풀 리퀘스트',
|
'contextRail.surface.git.description': '커밋, 브랜치, 풀 리퀘스트',
|
||||||
|
'contextRail.surface.git.changesCountAriaSingle': '{label}, 변경된 파일 {count}개',
|
||||||
|
'contextRail.surface.git.changesCountAriaPlural': '{label}, 변경된 파일 {count}개',
|
||||||
|
'contextRail.surface.git.changesCountTooltipSingle': '변경된 파일 {count}개',
|
||||||
|
'contextRail.surface.git.changesCountTooltipPlural': '변경된 파일 {count}개',
|
||||||
'contextRail.surface.terminal.description': '내장 터미널',
|
'contextRail.surface.terminal.description': '내장 터미널',
|
||||||
'contextRail.surface.diff.description': '작업 중인 변경 사항 검토',
|
'contextRail.surface.diff.description': '작업 중인 변경 사항 검토',
|
||||||
'contextPanel.mode.walkthrough': '워크스루',
|
'contextPanel.mode.walkthrough': '워크스루',
|
||||||
@@ -1144,8 +1155,11 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'walkthrough.toc.moreFiles': '다른 파일: {count}',
|
'walkthrough.toc.moreFiles': '다른 파일: {count}',
|
||||||
'walkthrough.toc.uncovered': '미포함: {count}',
|
'walkthrough.toc.uncovered': '미포함: {count}',
|
||||||
'walkthrough.toc.resize': '목차 열 너비 조절',
|
'walkthrough.toc.resize': '목차 열 너비 조절',
|
||||||
'walkthrough.importance.critical': '중요',
|
'walkthrough.importance.critical': '핵심 변경',
|
||||||
|
'walkthrough.importance.criticalHint': '이 단계가 변경 전체를 이끌고 있으니 꼼꼼히 읽어 보세요. 코드에서 발견된 문제가 아닙니다.',
|
||||||
'walkthrough.importance.context': '참고',
|
'walkthrough.importance.context': '참고',
|
||||||
|
'walkthrough.importance.contextHint': '나머지를 이해하는 데 도움이 되도록 함께 실은 보조 변경입니다.',
|
||||||
|
'walkthrough.help.guide': '워크스루 작동 방식',
|
||||||
'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다',
|
'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다',
|
||||||
'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.',
|
'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.',
|
||||||
'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다',
|
'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다',
|
||||||
@@ -1160,6 +1174,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '스몰 모델이 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.',
|
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '스몰 모델이 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.',
|
||||||
'walkthrough.blocked.onlyGenerated.title': '생성된 파일만 변경되었습니다',
|
'walkthrough.blocked.onlyGenerated.title': '생성된 파일만 변경되었습니다',
|
||||||
'walkthrough.blocked.onlyGenerated.description': '여기의 변경은 모두 잠금 파일이거나 도구가 만든 산출물이며, 리뷰는 이런 파일을 의도적으로 건너뜁니다.',
|
'walkthrough.blocked.onlyGenerated.description': '여기의 변경은 모두 잠금 파일이거나 도구가 만든 산출물이며, 리뷰는 이런 파일을 의도적으로 건너뜁니다.',
|
||||||
|
'walkthrough.blocked.serverUnsupported.title': '이 서버는 워크스루를 지원하지 않습니다',
|
||||||
|
'walkthrough.blocked.serverUnsupported.description': '이 앱이 연결된 OpenChamber 서버가 워크스루 API에 응답하지 않았습니다. 즉 앱보다 오래된 버전입니다. 서버를 1.18 이상으로 업데이트한 뒤 새로 고치세요.',
|
||||||
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '스몰 모델은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.',
|
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '스몰 모델은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.',
|
||||||
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '스몰 모델은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.',
|
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '스몰 모델은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.',
|
||||||
'contextRail.surface.plan.description': '현재 계획 보기',
|
'contextRail.surface.plan.description': '현재 계획 보기',
|
||||||
@@ -1642,6 +1658,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'directoryExplorerDialog.browse.directories': '디렉터리',
|
'directoryExplorerDialog.browse.directories': '디렉터리',
|
||||||
'directoryExplorerDialog.browse.loading': '디렉터리 로드 중...',
|
'directoryExplorerDialog.browse.loading': '디렉터리 로드 중...',
|
||||||
'directoryExplorerDialog.browse.empty': '일치하는 디렉터리가 없습니다.',
|
'directoryExplorerDialog.browse.empty': '일치하는 디렉터리가 없습니다.',
|
||||||
|
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber에서 이 폴더에 접근해야 합니다.',
|
||||||
|
'directoryExplorerDialog.browse.loadFailed': '이 폴더를 불러올 수 없습니다.',
|
||||||
|
'directoryExplorerDialog.browse.grantAccess': '접근 허용',
|
||||||
|
'directoryExplorerDialog.browse.retry': '다시 시도',
|
||||||
'directoryExplorerDialog.browse.parentDirectory': '상위 디렉터리',
|
'directoryExplorerDialog.browse.parentDirectory': '상위 디렉터리',
|
||||||
'directoryExplorerDialog.browse.addedBadge': '추가됨',
|
'directoryExplorerDialog.browse.addedBadge': '추가됨',
|
||||||
'directoryExplorerDialog.browse.quickAdd': '추가',
|
'directoryExplorerDialog.browse.quickAdd': '추가',
|
||||||
@@ -1708,7 +1728,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'helpDialog.item.toggleTerminalExpanded': '터미널 펼치기/접기',
|
'helpDialog.item.toggleTerminalExpanded': '터미널 펼치기/접기',
|
||||||
'helpDialog.item.togglePlanContextPanel': '플랜 컨텍스트 패널 전환',
|
'helpDialog.item.togglePlanContextPanel': '플랜 컨텍스트 패널 전환',
|
||||||
'helpDialog.item.cycleTheme': '테마 순환(라이트 → 다크 → 시스템)',
|
'helpDialog.item.cycleTheme': '테마 순환(라이트 → 다크 → 시스템)',
|
||||||
'helpDialog.item.switchProject': '프로젝트 전환',
|
'helpDialog.item.switchContextSurface': '컨텍스트 패널 서피스 전환(숫자 키)',
|
||||||
'helpDialog.item.toggleServicesMenu': '서비스 메뉴 전환',
|
'helpDialog.item.toggleServicesMenu': '서비스 메뉴 전환',
|
||||||
'helpDialog.item.cycleServicesTab': '서비스 탭 순환',
|
'helpDialog.item.cycleServicesTab': '서비스 탭 순환',
|
||||||
'helpDialog.item.openSettings': '설정 열기',
|
'helpDialog.item.openSettings': '설정 열기',
|
||||||
@@ -1993,6 +2013,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…',
|
'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…',
|
||||||
'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.',
|
'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.',
|
||||||
'sessions.sidebar.group.empty.retry': '다시 시도',
|
'sessions.sidebar.group.empty.retry': '다시 시도',
|
||||||
|
'sessions.sidebar.group.empty.permissionDenied': '폴더 접근이 필요합니다.',
|
||||||
|
'sessions.sidebar.group.empty.grantAccess': '접근 허용',
|
||||||
'chat.unifiedControls.title': '컨트롤',
|
'chat.unifiedControls.title': '컨트롤',
|
||||||
'chat.unifiedControls.model.title': '모델',
|
'chat.unifiedControls.model.title': '모델',
|
||||||
'chat.unifiedControls.model.noRecent': '최근 모델 없음',
|
'chat.unifiedControls.model.noRecent': '최근 모델 없음',
|
||||||
@@ -2895,6 +2917,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'common.relative.daysAgoCompact': '{count}d ago',
|
'common.relative.daysAgoCompact': '{count}d ago',
|
||||||
'common.relative.weeksAgoCompact': '{count}w ago',
|
'common.relative.weeksAgoCompact': '{count}w ago',
|
||||||
'common.relative.yearsAgoCompact': '{count}y ago',
|
'common.relative.yearsAgoCompact': '{count}y ago',
|
||||||
|
'common.duration.secondsCompact': '{seconds}초',
|
||||||
|
'common.duration.minutesSecondsCompact': '{minutes}분 {seconds}초',
|
||||||
|
'common.duration.hoursMinutesCompact': '{hours}시간 {minutes}분',
|
||||||
'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)',
|
'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)',
|
||||||
'contextFileOpen.failure.missing': 'File not found',
|
'contextFileOpen.failure.missing': 'File not found',
|
||||||
'contextFileOpen.failure.unreadable': 'Failed to open file',
|
'contextFileOpen.failure.unreadable': 'Failed to open file',
|
||||||
|
|||||||
@@ -722,6 +722,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.about.actions.updateToVersion': 'Aktualizuj do wersji {version}',
|
'settings.openchamber.about.actions.updateToVersion': 'Aktualizuj do wersji {version}',
|
||||||
'settings.openchamber.about.field.version': 'Wersja',
|
'settings.openchamber.about.field.version': 'Wersja',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'Wersja OpenCode',
|
'settings.openchamber.about.field.openCodeVersion': 'Wersja OpenCode',
|
||||||
|
'settings.openchamber.about.field.instanceUrls': 'Adresy URL instancji',
|
||||||
|
'settings.openchamber.about.field.applicationUrl': 'Aplikacja',
|
||||||
|
'settings.openchamber.about.field.tunnelUrl': 'Tunel',
|
||||||
'settings.openchamber.about.state.checking': 'Sprawdzanie...',
|
'settings.openchamber.about.state.checking': 'Sprawdzanie...',
|
||||||
'settings.openchamber.about.state.upToDate': 'Aktualna wersja',
|
'settings.openchamber.about.state.upToDate': 'Aktualna wersja',
|
||||||
'settings.openchamber.about.state.unknown': 'nieznane',
|
'settings.openchamber.about.state.unknown': 'nieznane',
|
||||||
@@ -820,6 +823,8 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Przejdź do linii (edytor plików)',
|
'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Przejdź do linii (edytor plików)',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Otwórz skróty klawiszowe',
|
'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Otwórz skróty klawiszowe',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Otwórz powierzchnię plików',
|
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Otwórz powierzchnię plików',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Przełącz powierzchnię panelu kontekstu',
|
||||||
|
'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Otwórz powierzchnię Git',
|
'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Otwórz powierzchnię Git',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Otwórz ustawienia',
|
'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Otwórz ustawienia',
|
||||||
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu',
|
'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu',
|
||||||
@@ -1360,6 +1365,9 @@ export const settingsDict = {
|
|||||||
'settings.projects.sidebar.actions.addProject': 'Dodaj projekt',
|
'settings.projects.sidebar.actions.addProject': 'Dodaj projekt',
|
||||||
'settings.projects.sidebar.total': 'Suma: {count}',
|
'settings.projects.sidebar.total': 'Suma: {count}',
|
||||||
'settings.providers.page.actions.complete': 'Zakończ',
|
'settings.providers.page.actions.complete': 'Zakończ',
|
||||||
|
'settings.providers.page.actions.continue': 'Kontynuuj',
|
||||||
|
'settings.providers.page.actions.cancel': 'Anuluj',
|
||||||
|
'settings.providers.page.actions.tryAgain': 'Spróbuj ponownie',
|
||||||
'settings.providers.page.actions.connect': 'Połącz',
|
'settings.providers.page.actions.connect': 'Połącz',
|
||||||
'settings.providers.page.actions.copy': 'Kopiuj',
|
'settings.providers.page.actions.copy': 'Kopiuj',
|
||||||
'settings.providers.page.actions.copyCode': 'Kopiuj kod',
|
'settings.providers.page.actions.copyCode': 'Kopiuj kod',
|
||||||
@@ -1385,6 +1393,17 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.auth.loadingMethods': 'Ładowanie metod uwierzytelniania...',
|
'settings.providers.page.auth.loadingMethods': 'Ładowanie metod uwierzytelniania...',
|
||||||
'settings.providers.page.auth.oauthMethodFallback': 'Metoda OAuth {index}',
|
'settings.providers.page.auth.oauthMethodFallback': 'Metoda OAuth {index}',
|
||||||
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Wklej kod autoryzacyjny',
|
'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Wklej kod autoryzacyjny',
|
||||||
|
'settings.providers.page.auth.oauth.starting': 'Rozpoczynanie autoryzacji…',
|
||||||
|
'settings.providers.page.auth.oauth.waiting': 'Oczekiwanie na autoryzację…',
|
||||||
|
'settings.providers.page.auth.oauth.waitingHint': 'Dokończ logowanie w przeglądarce. Zostaw tę stronę otwartą — połączenie zakończy się samo.',
|
||||||
|
'settings.providers.page.auth.oauth.codeHint': 'Skopiuj kod autoryzacji z przeglądarki i wklej go tutaj.',
|
||||||
|
'settings.providers.page.auth.oauth.deviceCodeLabel': 'Kod urządzenia',
|
||||||
|
'settings.providers.page.auth.oauth.linkLabel': 'Link autoryzacyjny',
|
||||||
|
'settings.providers.page.auth.oauth.promptRequired': 'Wypełnij pole „{field}”, aby kontynuować',
|
||||||
|
'settings.providers.page.auth.oauth.error.sessionExpired': 'Żądanie autoryzacji wygasło. Połącz ponownie, aby zacząć od nowa.',
|
||||||
|
'settings.providers.page.auth.oauth.error.codeRequired': 'Ten dostawca wymaga kodu autoryzacji z przeglądarki.',
|
||||||
|
'settings.providers.page.auth.oauth.error.declined': 'Autoryzacja została odrzucona lub nie została ukończona.',
|
||||||
|
'settings.providers.page.auth.oauth.error.invalidInput': 'Wprowadzone dane zostały odrzucone.',
|
||||||
'settings.providers.page.auth.title': 'Uwierzytelnianie',
|
'settings.providers.page.auth.title': 'Uwierzytelnianie',
|
||||||
'settings.providers.page.auth.useReconnectHint': '· Użyj Połącz ponownie, aby zaktualizować dane logowania',
|
'settings.providers.page.auth.useReconnectHint': '· Użyj Połącz ponownie, aby zaktualizować dane logowania',
|
||||||
'settings.providers.page.custom.optionLabel': 'Inny / Niestandardowy',
|
'settings.providers.page.custom.optionLabel': 'Inny / Niestandardowy',
|
||||||
@@ -1474,7 +1493,6 @@ export const settingsDict = {
|
|||||||
'settings.providers.page.toast.apiKeySaveFailed': 'Nie udało się zapisać klucza API',
|
'settings.providers.page.toast.apiKeySaveFailed': 'Nie udało się zapisać klucza API',
|
||||||
'settings.providers.page.toast.apiKeySaved': 'Klucz API został zapisany',
|
'settings.providers.page.toast.apiKeySaved': 'Klucz API został zapisany',
|
||||||
'settings.providers.page.toast.authMethodsLoadFailed': 'Nie udało się załadować metod uwierzytelniania dostawcy',
|
'settings.providers.page.toast.authMethodsLoadFailed': 'Nie udało się załadować metod uwierzytelniania dostawcy',
|
||||||
'settings.providers.page.toast.completeOAuthInBrowser': 'Dokończ proces OAuth w przeglądarce',
|
|
||||||
'settings.providers.page.toast.deviceCodeCopied': 'Kod urządzenia został skopiowany',
|
'settings.providers.page.toast.deviceCodeCopied': 'Kod urządzenia został skopiowany',
|
||||||
'settings.providers.page.toast.deviceCodeCopyFailed': 'Nie udało się skopiować kodu urządzenia',
|
'settings.providers.page.toast.deviceCodeCopyFailed': 'Nie udało się skopiować kodu urządzenia',
|
||||||
'settings.providers.page.toast.oauthCompleteFailed': 'Nie udało się dokończyć procesu OAuth',
|
'settings.providers.page.toast.oauthCompleteFailed': 'Nie udało się dokończyć procesu OAuth',
|
||||||
|
|||||||
@@ -396,6 +396,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Wstrzymaj {taskName}',
|
'sessions.scheduledTasks.dialog.taskToggle.pauseAria': 'Wstrzymaj {taskName}',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Włączone',
|
'sessions.scheduledTasks.dialog.taskToggle.enabled': 'Włączone',
|
||||||
'sessions.scheduledTasks.dialog.taskToggle.paused': 'Wstrzymane',
|
'sessions.scheduledTasks.dialog.taskToggle.paused': 'Wstrzymane',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.note': 'Zarządzane przez plik pętli {file}',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Włączenie jest kontrolowane przez plik pętli; ustaw enabled w frontmatterze Markdown',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Zadania pętli są konfigurowane w pliku Markdown .agents/loops',
|
||||||
'sessions.scheduledTasks.editor.title.edit': 'Edytuj zaplanowane zadanie',
|
'sessions.scheduledTasks.editor.title.edit': 'Edytuj zaplanowane zadanie',
|
||||||
'sessions.scheduledTasks.editor.title.new': 'Nowe zaplanowane zadanie',
|
'sessions.scheduledTasks.editor.title.new': 'Nowe zaplanowane zadanie',
|
||||||
'sessions.scheduledTasks.editor.description': 'Skonfiguruj zadanie po stronie serwera, które tworzy nową sesję i wysyła prompt.',
|
'sessions.scheduledTasks.editor.description': 'Skonfiguruj zadanie po stronie serwera, które tworzy nową sesję i wysyła prompt.',
|
||||||
@@ -531,6 +534,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.session.status.pinned': 'Przypięta sesja',
|
'sessions.sidebar.session.status.pinned': 'Przypięta sesja',
|
||||||
'sessions.sidebar.session.status.movingToWorktree': 'Przenoszenie sesji do nowego worktree',
|
'sessions.sidebar.session.status.movingToWorktree': 'Przenoszenie sesji do nowego worktree',
|
||||||
'sessions.sidebar.session.status.permissionRequired': 'Wymagane uprawnienie',
|
'sessions.sidebar.session.status.permissionRequired': 'Wymagane uprawnienie',
|
||||||
|
'sessions.sidebar.session.status.questionPendingSingle': '1 oczekujące pytanie',
|
||||||
|
'sessions.sidebar.session.status.questionPendingMany': 'Liczba oczekujących pytań: {count}',
|
||||||
|
'sessions.sidebar.session.status.activeFor': 'Aktywna od {duration}',
|
||||||
|
'sessions.sidebar.session.status.lastTurnDuration': 'Ostatnia tura trwała {duration}',
|
||||||
'sessions.sidebar.session.subsessions.collapse': 'Zwiń pod-sesje',
|
'sessions.sidebar.session.subsessions.collapse': 'Zwiń pod-sesje',
|
||||||
'sessions.sidebar.session.subsessions.expand': 'Rozwiń pod-sesje',
|
'sessions.sidebar.session.subsessions.expand': 'Rozwiń pod-sesje',
|
||||||
'sessions.sidebar.dialogs.deleteSession.title': 'Usunąć sesję?',
|
'sessions.sidebar.dialogs.deleteSession.title': 'Usunąć sesję?',
|
||||||
@@ -804,6 +811,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…',
|
'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…',
|
||||||
'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.',
|
'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.',
|
||||||
'sessions.sidebar.group.empty.retry': 'Spróbuj ponownie',
|
'sessions.sidebar.group.empty.retry': 'Spróbuj ponownie',
|
||||||
|
'sessions.sidebar.group.empty.permissionDenied': 'Wymagany jest dostęp do folderu.',
|
||||||
|
'sessions.sidebar.group.empty.grantAccess': 'Przyznaj dostęp',
|
||||||
'chat.unifiedControls.title': 'Kontrolki',
|
'chat.unifiedControls.title': 'Kontrolki',
|
||||||
'chat.unifiedControls.model.title': 'Model',
|
'chat.unifiedControls.model.title': 'Model',
|
||||||
'chat.unifiedControls.model.noRecent': 'Brak ostatnich modeli',
|
'chat.unifiedControls.model.noRecent': 'Brak ostatnich modeli',
|
||||||
@@ -1417,6 +1426,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextPanel.editorEmpty.description': 'Wybierz plik z drzewa, aby rozpocząć edycję.',
|
'contextPanel.editorEmpty.description': 'Wybierz plik z drzewa, aby rozpocząć edycję.',
|
||||||
'contextRail.surface.editor.description': 'Edytuj pliki projektu',
|
'contextRail.surface.editor.description': 'Edytuj pliki projektu',
|
||||||
'contextRail.surface.git.description': 'Commity, gałęzie i pull requesty',
|
'contextRail.surface.git.description': 'Commity, gałęzie i pull requesty',
|
||||||
|
'contextRail.surface.git.changesCountAriaSingle': '{label}, {count} zmieniony plik',
|
||||||
|
'contextRail.surface.git.changesCountAriaPlural': '{label}, {count} zmienionych plików',
|
||||||
|
'contextRail.surface.git.changesCountTooltipSingle': '{count} zmieniony plik',
|
||||||
|
'contextRail.surface.git.changesCountTooltipPlural': '{count} zmienionych plików',
|
||||||
'contextRail.surface.terminal.description': 'Wbudowany terminal',
|
'contextRail.surface.terminal.description': 'Wbudowany terminal',
|
||||||
'contextRail.surface.diff.description': 'Przeglądaj bieżące zmiany',
|
'contextRail.surface.diff.description': 'Przeglądaj bieżące zmiany',
|
||||||
'contextPanel.mode.walkthrough': 'Przewodnik',
|
'contextPanel.mode.walkthrough': 'Przewodnik',
|
||||||
@@ -1456,8 +1469,11 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'walkthrough.toc.moreFiles': 'Więcej plików: {count}',
|
'walkthrough.toc.moreFiles': 'Więcej plików: {count}',
|
||||||
'walkthrough.toc.uncovered': 'Nieuwzględnione: {count}',
|
'walkthrough.toc.uncovered': 'Nieuwzględnione: {count}',
|
||||||
'walkthrough.toc.resize': 'Zmień szerokość kolumny spisu treści',
|
'walkthrough.toc.resize': 'Zmień szerokość kolumny spisu treści',
|
||||||
'walkthrough.importance.critical': 'Krytyczne',
|
'walkthrough.importance.critical': 'Kluczowa zmiana',
|
||||||
|
'walkthrough.importance.criticalHint': 'Ten krok napędza resztę zmiany, więc przeczytaj go uważnie. To nie jest problem znaleziony w Twoim kodzie.',
|
||||||
'walkthrough.importance.context': 'Kontekst',
|
'walkthrough.importance.context': 'Kontekst',
|
||||||
|
'walkthrough.importance.contextHint': 'Zmiana pomocnicza, dołączona po to, by reszta miała sens.',
|
||||||
|
'walkthrough.help.guide': 'Jak działają walkthroughy',
|
||||||
'walkthrough.blocked.noModel.title': 'Brak dostępnego małego modelu',
|
'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.noModel.description': 'Zaloguj się u dostawcy modeli, aby wygenerować przegląd.',
|
||||||
'walkthrough.blocked.emptyDiff.title': 'Nie ma czego przeglądać',
|
'walkthrough.blocked.emptyDiff.title': 'Nie ma czego przeglądać',
|
||||||
@@ -1472,6 +1488,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'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.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.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.onlyGenerated.description': 'Wszystkie zmiany to pliki blokad lub inne wyniki pracy narzędzi, które przegląd celowo pomija.',
|
||||||
|
'walkthrough.blocked.serverUnsupported.title': 'Ten serwer nie obsługuje walkthroughów',
|
||||||
|
'walkthrough.blocked.serverUnsupported.description': 'Serwer OpenChamber, z którym połączona jest ta aplikacja, nie odpowiedział na API walkthroughu — jest więc starszy niż aplikacja. Zaktualizuj serwer do wersji 1.18 lub nowszej i odśwież.',
|
||||||
'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.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.',
|
'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.plan.description': 'Zobacz bieżący plan',
|
||||||
@@ -1722,6 +1740,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'directoryExplorerDialog.browse.quickAdd': 'Dodaj',
|
'directoryExplorerDialog.browse.quickAdd': 'Dodaj',
|
||||||
'directoryExplorerDialog.browse.directories': 'Katalogi',
|
'directoryExplorerDialog.browse.directories': 'Katalogi',
|
||||||
'directoryExplorerDialog.browse.empty': 'Brak pasujących katalogów.',
|
'directoryExplorerDialog.browse.empty': 'Brak pasujących katalogów.',
|
||||||
|
'directoryExplorerDialog.browse.permissionDenied': 'OpenChamber potrzebuje dostępu do tego folderu.',
|
||||||
|
'directoryExplorerDialog.browse.loadFailed': 'Nie udało się wczytać tego folderu.',
|
||||||
|
'directoryExplorerDialog.browse.grantAccess': 'Przyznaj dostęp',
|
||||||
|
'directoryExplorerDialog.browse.retry': 'Spróbuj ponownie',
|
||||||
'directoryExplorerDialog.browse.loading': 'Ładowanie katalogów...',
|
'directoryExplorerDialog.browse.loading': 'Ładowanie katalogów...',
|
||||||
'directoryExplorerDialog.browse.parentDirectory': 'Katalog nadrzędny',
|
'directoryExplorerDialog.browse.parentDirectory': 'Katalog nadrzędny',
|
||||||
'directoryExplorerDialog.description': 'Wybierz folder, który chcesz dodać jako projekt.',
|
'directoryExplorerDialog.description': 'Wybierz folder, który chcesz dodać jako projekt.',
|
||||||
@@ -2328,7 +2350,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'helpDialog.item.openRightSidebarGitTab': 'Otwórz powierzchnię Git',
|
'helpDialog.item.openRightSidebarGitTab': 'Otwórz powierzchnię Git',
|
||||||
'helpDialog.item.openSettings': 'Otwórz ustawienia',
|
'helpDialog.item.openSettings': 'Otwórz ustawienia',
|
||||||
'helpDialog.item.showKeyboardShortcuts': 'Pokaż skróty klawiaturowe (to okno)',
|
'helpDialog.item.showKeyboardShortcuts': 'Pokaż skróty klawiaturowe (to okno)',
|
||||||
'helpDialog.item.switchProject': 'Przełącz projekt',
|
'helpDialog.item.switchContextSurface': 'Przełącz powierzchnię panelu kontekstu (klawisz liczbowy)',
|
||||||
'helpDialog.item.togglePlanContextPanel': 'Przełącz panel kontekstu planu',
|
'helpDialog.item.togglePlanContextPanel': 'Przełącz panel kontekstu planu',
|
||||||
'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu',
|
'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu',
|
||||||
'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług',
|
'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług',
|
||||||
@@ -2912,6 +2934,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'common.relative.daysAgoCompact': '{count}d ago',
|
'common.relative.daysAgoCompact': '{count}d ago',
|
||||||
'common.relative.weeksAgoCompact': '{count}w ago',
|
'common.relative.weeksAgoCompact': '{count}w ago',
|
||||||
'common.relative.yearsAgoCompact': '{count}y ago',
|
'common.relative.yearsAgoCompact': '{count}y ago',
|
||||||
|
'common.duration.secondsCompact': '{seconds}s',
|
||||||
|
'common.duration.minutesSecondsCompact': '{minutes}m {seconds}s',
|
||||||
|
'common.duration.hoursMinutesCompact': '{hours}h {minutes}m',
|
||||||
'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)',
|
'contextFileOpen.failure.tooLarge': 'File is too large to open (>{count} lines)',
|
||||||
'contextFileOpen.failure.missing': 'File not found',
|
'contextFileOpen.failure.missing': 'File not found',
|
||||||
'contextFileOpen.failure.unreadable': 'Failed to open file',
|
'contextFileOpen.failure.unreadable': 'Failed to open file',
|
||||||
|
|||||||
@@ -444,6 +444,9 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.about.title": "Sobre o OpenChamber",
|
"settings.openchamber.about.title": "Sobre o OpenChamber",
|
||||||
"settings.openchamber.about.field.version": "Versão",
|
"settings.openchamber.about.field.version": "Versão",
|
||||||
"settings.openchamber.about.field.openCodeVersion": "Versão do OpenCode",
|
"settings.openchamber.about.field.openCodeVersion": "Versão do OpenCode",
|
||||||
|
"settings.openchamber.about.field.instanceUrls": "URLs da instância",
|
||||||
|
"settings.openchamber.about.field.applicationUrl": "Aplicativo",
|
||||||
|
"settings.openchamber.about.field.tunnelUrl": "Túnel",
|
||||||
"settings.openchamber.about.state.checking": "Verificando...",
|
"settings.openchamber.about.state.checking": "Verificando...",
|
||||||
"settings.openchamber.about.state.upToDate": "Atualizado",
|
"settings.openchamber.about.state.upToDate": "Atualizado",
|
||||||
"settings.openchamber.about.state.unknown": "desconhecido",
|
"settings.openchamber.about.state.unknown": "desconhecido",
|
||||||
@@ -1088,6 +1091,8 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto',
|
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto',
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git',
|
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git',
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superfície de arquivos',
|
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superfície de arquivos',
|
||||||
|
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Alternar superfície do painel de contexto",
|
||||||
|
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão",
|
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão",
|
||||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree",
|
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree",
|
||||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat",
|
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat",
|
||||||
@@ -1372,6 +1377,17 @@ export const settingsDict = {
|
|||||||
"settings.providers.page.auth.apiKeyPlaceholder": "sk-...",
|
"settings.providers.page.auth.apiKeyPlaceholder": "sk-...",
|
||||||
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
|
"settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}",
|
||||||
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Colar código de autorização",
|
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Colar código de autorização",
|
||||||
|
"settings.providers.page.auth.oauth.starting": "Iniciando a autorização…",
|
||||||
|
"settings.providers.page.auth.oauth.waiting": "Aguardando a autorização…",
|
||||||
|
"settings.providers.page.auth.oauth.waitingHint": "Conclua o login no navegador. Mantenha esta página aberta — a conexão será concluída sozinha.",
|
||||||
|
"settings.providers.page.auth.oauth.codeHint": "Copie o código de autorização do navegador e cole aqui.",
|
||||||
|
"settings.providers.page.auth.oauth.deviceCodeLabel": "Código do dispositivo",
|
||||||
|
"settings.providers.page.auth.oauth.linkLabel": "Link de autorização",
|
||||||
|
"settings.providers.page.auth.oauth.promptRequired": "Preencha “{field}” para continuar",
|
||||||
|
"settings.providers.page.auth.oauth.error.sessionExpired": "A solicitação de autorização expirou. Conecte novamente para reiniciá-la.",
|
||||||
|
"settings.providers.page.auth.oauth.error.codeRequired": "Este provedor precisa do código de autorização do seu navegador.",
|
||||||
|
"settings.providers.page.auth.oauth.error.declined": "A autorização foi recusada ou não foi concluída.",
|
||||||
|
"settings.providers.page.auth.oauth.error.invalidInput": "Os dados informados foram recusados.",
|
||||||
"settings.providers.page.auth.connected": "Conectado",
|
"settings.providers.page.auth.connected": "Conectado",
|
||||||
"settings.providers.page.auth.incomplete": "Credenciais ausentes",
|
"settings.providers.page.auth.incomplete": "Credenciais ausentes",
|
||||||
"settings.providers.page.auth.incompleteHint": "· Adicione uma chave de API ou {env:VAR} antes de usar este provedor no chat",
|
"settings.providers.page.auth.incompleteHint": "· Adicione uma chave de API ou {env:VAR} antes de usar este provedor no chat",
|
||||||
@@ -1404,6 +1420,9 @@ export const settingsDict = {
|
|||||||
"settings.providers.page.actions.open": "Abrir",
|
"settings.providers.page.actions.open": "Abrir",
|
||||||
"settings.providers.page.actions.copy": "Copiar",
|
"settings.providers.page.actions.copy": "Copiar",
|
||||||
"settings.providers.page.actions.complete": "Completar",
|
"settings.providers.page.actions.complete": "Completar",
|
||||||
|
"settings.providers.page.actions.continue": "Continuar",
|
||||||
|
"settings.providers.page.actions.cancel": "Cancelar",
|
||||||
|
"settings.providers.page.actions.tryAgain": "Tentar novamente",
|
||||||
"settings.providers.page.actions.hide": "Ocultar",
|
"settings.providers.page.actions.hide": "Ocultar",
|
||||||
"settings.providers.page.actions.reconnect": "Reconectar",
|
"settings.providers.page.actions.reconnect": "Reconectar",
|
||||||
"settings.providers.page.actions.edit": "Editar",
|
"settings.providers.page.actions.edit": "Editar",
|
||||||
@@ -1419,7 +1438,6 @@ export const settingsDict = {
|
|||||||
"settings.providers.page.toast.apiKeySaved": "Chave API salva",
|
"settings.providers.page.toast.apiKeySaved": "Chave API salva",
|
||||||
"settings.providers.page.toast.oauthStartFailed": "Não foi possível iniciar o fluxo OAuth",
|
"settings.providers.page.toast.oauthStartFailed": "Não foi possível iniciar o fluxo OAuth",
|
||||||
"settings.providers.page.toast.oauthDetailsMissing": "Não se devolvieron detalhes de OAuth",
|
"settings.providers.page.toast.oauthDetailsMissing": "Não se devolvieron detalhes de OAuth",
|
||||||
"settings.providers.page.toast.completeOAuthInBrowser": "Complete o fluxo OAuth no navegador",
|
|
||||||
"settings.providers.page.toast.oauthCompleteFailed": "Não foi possível concluir o fluxo OAuth",
|
"settings.providers.page.toast.oauthCompleteFailed": "Não foi possível concluir o fluxo OAuth",
|
||||||
"settings.providers.page.toast.oauthCompleted": "Conexão OAuth concluída",
|
"settings.providers.page.toast.oauthCompleted": "Conexão OAuth concluída",
|
||||||
"settings.providers.page.toast.oauthLinkCopied": "Link de OAuth copiado",
|
"settings.providers.page.toast.oauthLinkCopied": "Link de OAuth copiado",
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}",
|
"sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Pausar {taskName}",
|
||||||
"sessions.scheduledTasks.dialog.taskToggle.enabled": "Ativado",
|
"sessions.scheduledTasks.dialog.taskToggle.enabled": "Ativado",
|
||||||
"sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado",
|
"sessions.scheduledTasks.dialog.taskToggle.paused": "Pausado",
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.note': 'Gerenciada pelo arquivo de loop {file}',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'A ativação é controlada pelo arquivo de loop; defina enabled no frontmatter Markdown',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Tarefas de loop são configuradas no arquivo Markdown .agents/loops',
|
||||||
"sessions.scheduledTasks.editor.title.edit": "Editar tarefa agendada",
|
"sessions.scheduledTasks.editor.title.edit": "Editar tarefa agendada",
|
||||||
"sessions.scheduledTasks.editor.title.new": "Nova tarefa agendada",
|
"sessions.scheduledTasks.editor.title.new": "Nova tarefa agendada",
|
||||||
"sessions.scheduledTasks.editor.description": "Configure uma tarefa do lado do servidor que cria uma nova sessão e envia um prompt.",
|
"sessions.scheduledTasks.editor.description": "Configure uma tarefa do lado do servidor que cria uma nova sessão e envia um prompt.",
|
||||||
@@ -531,6 +534,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.session.status.pinned": "Sessão fixada",
|
"sessions.sidebar.session.status.pinned": "Sessão fixada",
|
||||||
"sessions.sidebar.session.status.movingToWorktree": "Movendo a sessão para um novo worktree",
|
"sessions.sidebar.session.status.movingToWorktree": "Movendo a sessão para um novo worktree",
|
||||||
"sessions.sidebar.session.status.permissionRequired": "Permissão obrigatória",
|
"sessions.sidebar.session.status.permissionRequired": "Permissão obrigatória",
|
||||||
|
"sessions.sidebar.session.status.questionPendingSingle": "1 pergunta pendente",
|
||||||
|
"sessions.sidebar.session.status.questionPendingMany": "{count} perguntas pendentes",
|
||||||
|
"sessions.sidebar.session.status.activeFor": "Ativa há {duration}",
|
||||||
|
"sessions.sidebar.session.status.lastTurnDuration": "O último turno levou {duration}",
|
||||||
"sessions.sidebar.session.subsessions.collapse": "Recolher subsessões",
|
"sessions.sidebar.session.subsessions.collapse": "Recolher subsessões",
|
||||||
"sessions.sidebar.session.subsessions.expand": "Expandir subsessões",
|
"sessions.sidebar.session.subsessions.expand": "Expandir subsessões",
|
||||||
"sessions.sidebar.dialogs.deleteSession.title": "Excluir sessão?",
|
"sessions.sidebar.dialogs.deleteSession.title": "Excluir sessão?",
|
||||||
@@ -1105,6 +1112,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"contextPanel.editorEmpty.description": "Escolha um arquivo na árvore para começar a editar.",
|
"contextPanel.editorEmpty.description": "Escolha um arquivo na árvore para começar a editar.",
|
||||||
"contextRail.surface.editor.description": "Editar arquivos do projeto",
|
"contextRail.surface.editor.description": "Editar arquivos do projeto",
|
||||||
"contextRail.surface.git.description": "Commits, branches e pull requests",
|
"contextRail.surface.git.description": "Commits, branches e pull requests",
|
||||||
|
"contextRail.surface.git.changesCountAriaSingle": "{label}, {count} arquivo modificado",
|
||||||
|
"contextRail.surface.git.changesCountAriaPlural": "{label}, {count} arquivos modificados",
|
||||||
|
"contextRail.surface.git.changesCountTooltipSingle": "{count} arquivo modificado",
|
||||||
|
"contextRail.surface.git.changesCountTooltipPlural": "{count} arquivos modificados",
|
||||||
"contextRail.surface.terminal.description": "Terminal integrado",
|
"contextRail.surface.terminal.description": "Terminal integrado",
|
||||||
"contextRail.surface.diff.description": "Revisar alterações em andamento",
|
"contextRail.surface.diff.description": "Revisar alterações em andamento",
|
||||||
"contextPanel.mode.walkthrough": "Percurso",
|
"contextPanel.mode.walkthrough": "Percurso",
|
||||||
@@ -1144,8 +1155,11 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"walkthrough.toc.moreFiles": "Mais arquivos: {count}",
|
"walkthrough.toc.moreFiles": "Mais arquivos: {count}",
|
||||||
"walkthrough.toc.uncovered": "Sem cobertura: {count}",
|
"walkthrough.toc.uncovered": "Sem cobertura: {count}",
|
||||||
"walkthrough.toc.resize": "Redimensionar a coluna de conteúdo",
|
"walkthrough.toc.resize": "Redimensionar a coluna de conteúdo",
|
||||||
"walkthrough.importance.critical": "Crítico",
|
"walkthrough.importance.critical": "Mudança principal",
|
||||||
|
"walkthrough.importance.criticalHint": "Este passo conduz o restante da mudança, então leia com atenção. Não é um problema encontrado no seu código.",
|
||||||
"walkthrough.importance.context": "Contexto",
|
"walkthrough.importance.context": "Contexto",
|
||||||
|
"walkthrough.importance.contextHint": "Uma mudança de apoio, incluída para que o restante faça sentido.",
|
||||||
|
"walkthrough.help.guide": "Como funcionam os walkthroughs",
|
||||||
"walkthrough.blocked.noModel.title": "Nenhum modelo pequeno disponível",
|
"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.noModel.description": "Entre em um provedor de modelos para gerar uma revisão.",
|
||||||
"walkthrough.blocked.emptyDiff.title": "Nada para revisar",
|
"walkthrough.blocked.emptyDiff.title": "Nada para revisar",
|
||||||
@@ -1160,6 +1174,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"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.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.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.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.serverUnsupported.title": "Este servidor não oferece walkthroughs",
|
||||||
|
"walkthrough.blocked.serverUnsupported.description": "O servidor OpenChamber ao qual este app está conectado não respondeu à API de walkthrough, ou seja, é mais antigo que o app. Atualize o servidor para 1.18 ou mais recente e atualize a visualização.",
|
||||||
"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.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.",
|
"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.plan.description": "Ver o plano atual",
|
||||||
@@ -1618,6 +1634,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"directoryExplorerDialog.browse.directories": "Diretórios",
|
"directoryExplorerDialog.browse.directories": "Diretórios",
|
||||||
"directoryExplorerDialog.browse.loading": "Carregando diretórios...",
|
"directoryExplorerDialog.browse.loading": "Carregando diretórios...",
|
||||||
"directoryExplorerDialog.browse.empty": "Nenhum diretório correspondente.",
|
"directoryExplorerDialog.browse.empty": "Nenhum diretório correspondente.",
|
||||||
|
"directoryExplorerDialog.browse.permissionDenied": "OpenChamber precisa acessar esta pasta.",
|
||||||
|
"directoryExplorerDialog.browse.loadFailed": "Não foi possível carregar esta pasta.",
|
||||||
|
"directoryExplorerDialog.browse.grantAccess": "Conceder acesso",
|
||||||
|
"directoryExplorerDialog.browse.retry": "Tentar novamente",
|
||||||
"directoryExplorerDialog.browse.parentDirectory": "Diretório pai",
|
"directoryExplorerDialog.browse.parentDirectory": "Diretório pai",
|
||||||
"directoryExplorerDialog.browse.addedBadge": "Adicionado",
|
"directoryExplorerDialog.browse.addedBadge": "Adicionado",
|
||||||
"directoryExplorerDialog.browse.quickAdd": "Adicionar",
|
"directoryExplorerDialog.browse.quickAdd": "Adicionar",
|
||||||
@@ -1684,7 +1704,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"helpDialog.item.toggleTerminalExpanded": "Expandir ou recolher o terminal",
|
"helpDialog.item.toggleTerminalExpanded": "Expandir ou recolher o terminal",
|
||||||
"helpDialog.item.togglePlanContextPanel": "Alternar painel de contexto do plano",
|
"helpDialog.item.togglePlanContextPanel": "Alternar painel de contexto do plano",
|
||||||
"helpDialog.item.cycleTheme": "Alternar tema (Claro → Escuro → Sistema)",
|
"helpDialog.item.cycleTheme": "Alternar tema (Claro → Escuro → Sistema)",
|
||||||
"helpDialog.item.switchProject": "Alternar projeto",
|
"helpDialog.item.switchContextSurface": "Alternar superfície do painel de contexto (tecla numérica)",
|
||||||
"helpDialog.item.toggleServicesMenu": "Mostrar ou ocultar menu de serviços",
|
"helpDialog.item.toggleServicesMenu": "Mostrar ou ocultar menu de serviços",
|
||||||
"helpDialog.item.cycleServicesTab": "Alternar aba de serviços",
|
"helpDialog.item.cycleServicesTab": "Alternar aba de serviços",
|
||||||
"helpDialog.item.openSettings": "Abrir configurações",
|
"helpDialog.item.openSettings": "Abrir configurações",
|
||||||
@@ -1969,6 +1989,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…",
|
"sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…",
|
||||||
"sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.",
|
"sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.",
|
||||||
"sessions.sidebar.group.empty.retry": "Tentar novamente",
|
"sessions.sidebar.group.empty.retry": "Tentar novamente",
|
||||||
|
"sessions.sidebar.group.empty.permissionDenied": "É necessário acesso à pasta.",
|
||||||
|
"sessions.sidebar.group.empty.grantAccess": "Conceder acesso",
|
||||||
"chat.unifiedControls.title": "Controles",
|
"chat.unifiedControls.title": "Controles",
|
||||||
"chat.unifiedControls.model.title": "Modelo",
|
"chat.unifiedControls.model.title": "Modelo",
|
||||||
"chat.unifiedControls.model.noRecent": "Não há modelos recentes",
|
"chat.unifiedControls.model.noRecent": "Não há modelos recentes",
|
||||||
@@ -2896,6 +2918,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"common.relative.daysAgoCompact": "{count}d ago",
|
"common.relative.daysAgoCompact": "{count}d ago",
|
||||||
"common.relative.weeksAgoCompact": "{count}w ago",
|
"common.relative.weeksAgoCompact": "{count}w ago",
|
||||||
"common.relative.yearsAgoCompact": "{count}y ago",
|
"common.relative.yearsAgoCompact": "{count}y ago",
|
||||||
|
"common.duration.secondsCompact": "{seconds}s",
|
||||||
|
"common.duration.minutesSecondsCompact": "{minutes}m {seconds}s",
|
||||||
|
"common.duration.hoursMinutesCompact": "{hours}h {minutes}m",
|
||||||
"contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)",
|
"contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)",
|
||||||
"contextFileOpen.failure.missing": "File not found",
|
"contextFileOpen.failure.missing": "File not found",
|
||||||
"contextFileOpen.failure.unreadable": "Failed to open file",
|
"contextFileOpen.failure.unreadable": "Failed to open file",
|
||||||
|
|||||||
@@ -444,6 +444,9 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.about.title": "Про OpenChamber",
|
"settings.openchamber.about.title": "Про OpenChamber",
|
||||||
"settings.openchamber.about.field.version": "Версія",
|
"settings.openchamber.about.field.version": "Версія",
|
||||||
"settings.openchamber.about.field.openCodeVersion": "Версія OpenCode",
|
"settings.openchamber.about.field.openCodeVersion": "Версія OpenCode",
|
||||||
|
"settings.openchamber.about.field.instanceUrls": "URL-адреси екземпляра",
|
||||||
|
"settings.openchamber.about.field.applicationUrl": "Застосунок",
|
||||||
|
"settings.openchamber.about.field.tunnelUrl": "Тунель",
|
||||||
"settings.openchamber.about.state.checking": "Перевірка...",
|
"settings.openchamber.about.state.checking": "Перевірка...",
|
||||||
"settings.openchamber.about.state.upToDate": "В актуальному стані",
|
"settings.openchamber.about.state.upToDate": "В актуальному стані",
|
||||||
"settings.openchamber.about.state.unknown": "невідомо",
|
"settings.openchamber.about.state.unknown": "невідомо",
|
||||||
@@ -1088,6 +1091,8 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель',
|
"settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель',
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git',
|
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git',
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Відкрити поверхню файлів',
|
"settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Відкрити поверхню файлів',
|
||||||
|
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Перемкнути поверхню панелі контексту",
|
||||||
|
"settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0",
|
||||||
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія",
|
"settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія",
|
||||||
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree",
|
"settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree",
|
||||||
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat",
|
"settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat",
|
||||||
@@ -1372,6 +1377,17 @@ export const settingsDict = {
|
|||||||
"settings.providers.page.auth.apiKeyPlaceholder": "sk-...",
|
"settings.providers.page.auth.apiKeyPlaceholder": "sk-...",
|
||||||
"settings.providers.page.auth.oauthMethodFallback": "OAuth метод {index}",
|
"settings.providers.page.auth.oauthMethodFallback": "OAuth метод {index}",
|
||||||
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Вставити код авторизації",
|
"settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Вставити код авторизації",
|
||||||
|
"settings.providers.page.auth.oauth.starting": "Запускаємо авторизацію…",
|
||||||
|
"settings.providers.page.auth.oauth.waiting": "Очікуємо на авторизацію…",
|
||||||
|
"settings.providers.page.auth.oauth.waitingHint": "Завершіть вхід у браузері. Не закривайте цю сторінку — підключення завершиться саме.",
|
||||||
|
"settings.providers.page.auth.oauth.codeHint": "Скопіюйте код авторизації з браузера і вставте його сюди.",
|
||||||
|
"settings.providers.page.auth.oauth.deviceCodeLabel": "Код пристрою",
|
||||||
|
"settings.providers.page.auth.oauth.linkLabel": "Посилання для авторизації",
|
||||||
|
"settings.providers.page.auth.oauth.promptRequired": "Заповніть «{field}», щоб продовжити",
|
||||||
|
"settings.providers.page.auth.oauth.error.sessionExpired": "Термін дії запиту на авторизацію минув. Підключіться ще раз, щоб почати заново.",
|
||||||
|
"settings.providers.page.auth.oauth.error.codeRequired": "Цьому провайдеру потрібен код авторизації з браузера.",
|
||||||
|
"settings.providers.page.auth.oauth.error.declined": "Авторизацію відхилено або не завершено.",
|
||||||
|
"settings.providers.page.auth.oauth.error.invalidInput": "Введені дані відхилено.",
|
||||||
"settings.providers.page.auth.connected": "Підключено",
|
"settings.providers.page.auth.connected": "Підключено",
|
||||||
"settings.providers.page.auth.incomplete": "Облікові дані відсутні",
|
"settings.providers.page.auth.incomplete": "Облікові дані відсутні",
|
||||||
"settings.providers.page.auth.incompleteHint": "· Додайте API-ключ або {env:VAR} перед використанням цього провайдера в чаті",
|
"settings.providers.page.auth.incompleteHint": "· Додайте API-ключ або {env:VAR} перед використанням цього провайдера в чаті",
|
||||||
@@ -1404,6 +1420,9 @@ export const settingsDict = {
|
|||||||
"settings.providers.page.actions.open": "Відкрити",
|
"settings.providers.page.actions.open": "Відкрити",
|
||||||
"settings.providers.page.actions.copy": "Копіювати",
|
"settings.providers.page.actions.copy": "Копіювати",
|
||||||
"settings.providers.page.actions.complete": "Завершити",
|
"settings.providers.page.actions.complete": "Завершити",
|
||||||
|
"settings.providers.page.actions.continue": "Продовжити",
|
||||||
|
"settings.providers.page.actions.cancel": "Скасувати",
|
||||||
|
"settings.providers.page.actions.tryAgain": "Повторити спробу",
|
||||||
"settings.providers.page.actions.hide": "Сховати",
|
"settings.providers.page.actions.hide": "Сховати",
|
||||||
"settings.providers.page.actions.reconnect": "Перепідключити",
|
"settings.providers.page.actions.reconnect": "Перепідключити",
|
||||||
"settings.providers.page.actions.edit": "Редагувати",
|
"settings.providers.page.actions.edit": "Редагувати",
|
||||||
@@ -1419,7 +1438,6 @@ export const settingsDict = {
|
|||||||
"settings.providers.page.toast.apiKeySaved": "Ключ API збережено",
|
"settings.providers.page.toast.apiKeySaved": "Ключ API збережено",
|
||||||
"settings.providers.page.toast.oauthStartFailed": "Не вдалося запустити потік OAuth",
|
"settings.providers.page.toast.oauthStartFailed": "Не вдалося запустити потік OAuth",
|
||||||
"settings.providers.page.toast.oauthDetailsMissing": "Деталі OAuth не повернуто",
|
"settings.providers.page.toast.oauthDetailsMissing": "Деталі OAuth не повернуто",
|
||||||
"settings.providers.page.toast.completeOAuthInBrowser": "Завершіть процес OAuth у вашому браузері",
|
|
||||||
"settings.providers.page.toast.oauthCompleteFailed": "Не вдалося завершити потік OAuth",
|
"settings.providers.page.toast.oauthCompleteFailed": "Не вдалося завершити потік OAuth",
|
||||||
"settings.providers.page.toast.oauthCompleted": "Підключення OAuth завершено",
|
"settings.providers.page.toast.oauthCompleted": "Підключення OAuth завершено",
|
||||||
"settings.providers.page.toast.oauthLinkCopied": "Посилання OAuth скопійовано",
|
"settings.providers.page.toast.oauthLinkCopied": "Посилання OAuth скопійовано",
|
||||||
|
|||||||
@@ -269,6 +269,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Призупинити {taskName}",
|
"sessions.scheduledTasks.dialog.taskToggle.pauseAria": "Призупинити {taskName}",
|
||||||
"sessions.scheduledTasks.dialog.taskToggle.enabled": "Увімкнено",
|
"sessions.scheduledTasks.dialog.taskToggle.enabled": "Увімкнено",
|
||||||
"sessions.scheduledTasks.dialog.taskToggle.paused": "Призупинено",
|
"sessions.scheduledTasks.dialog.taskToggle.paused": "Призупинено",
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.note': 'Керується файлом циклу {file}',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.toggleDisabled': 'Активність контролюється файлом циклу; встановіть enabled у frontmatter Markdown',
|
||||||
|
'sessions.scheduledTasks.dialog.loopFile.actionsDisabled': 'Завдання циклів налаштовуються у файлі Markdown .agents/loops',
|
||||||
"sessions.scheduledTasks.editor.title.edit": "Редагувати заплановане завдання",
|
"sessions.scheduledTasks.editor.title.edit": "Редагувати заплановане завдання",
|
||||||
"sessions.scheduledTasks.editor.title.new": "Нове заплановане завдання",
|
"sessions.scheduledTasks.editor.title.new": "Нове заплановане завдання",
|
||||||
"sessions.scheduledTasks.editor.description": "Налаштувати завдання на стороні сервера, яке створює нову сесію і надсилає запит.",
|
"sessions.scheduledTasks.editor.description": "Налаштувати завдання на стороні сервера, яке створює нову сесію і надсилає запит.",
|
||||||
@@ -531,6 +534,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.session.status.pinned": "Закріплений сесія",
|
"sessions.sidebar.session.status.pinned": "Закріплений сесія",
|
||||||
"sessions.sidebar.session.status.movingToWorktree": "Перенесення сесії в новий worktree",
|
"sessions.sidebar.session.status.movingToWorktree": "Перенесення сесії в новий worktree",
|
||||||
"sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл",
|
"sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл",
|
||||||
|
"sessions.sidebar.session.status.questionPendingSingle": "1 запитання очікує відповіді",
|
||||||
|
"sessions.sidebar.session.status.questionPendingMany": "Кількість запитань, що очікують відповіді: {count}",
|
||||||
|
"sessions.sidebar.session.status.activeFor": "Активна вже {duration}",
|
||||||
|
"sessions.sidebar.session.status.lastTurnDuration": "Останній хід тривав {duration}",
|
||||||
"sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії",
|
"sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії",
|
||||||
"sessions.sidebar.session.subsessions.expand": "Розгорнути підсесії",
|
"sessions.sidebar.session.subsessions.expand": "Розгорнути підсесії",
|
||||||
"sessions.sidebar.dialogs.deleteSession.title": "Видалити сесія?",
|
"sessions.sidebar.dialogs.deleteSession.title": "Видалити сесія?",
|
||||||
@@ -1105,6 +1112,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"contextPanel.editorEmpty.description": "Виберіть файл у дереві, щоб почати редагування.",
|
"contextPanel.editorEmpty.description": "Виберіть файл у дереві, щоб почати редагування.",
|
||||||
"contextRail.surface.editor.description": "Редагування файлів проєкту",
|
"contextRail.surface.editor.description": "Редагування файлів проєкту",
|
||||||
"contextRail.surface.git.description": "Коміти, гілки та pull request-и",
|
"contextRail.surface.git.description": "Коміти, гілки та pull request-и",
|
||||||
|
"contextRail.surface.git.changesCountAriaSingle": "{label}, {count} змінений файл",
|
||||||
|
"contextRail.surface.git.changesCountAriaPlural": "{label}, {count} змінених файлів",
|
||||||
|
"contextRail.surface.git.changesCountTooltipSingle": "{count} змінений файл",
|
||||||
|
"contextRail.surface.git.changesCountTooltipPlural": "{count} змінених файлів",
|
||||||
"contextRail.surface.terminal.description": "Вбудований термінал",
|
"contextRail.surface.terminal.description": "Вбудований термінал",
|
||||||
"contextRail.surface.diff.description": "Перегляд поточних змін",
|
"contextRail.surface.diff.description": "Перегляд поточних змін",
|
||||||
"contextPanel.mode.walkthrough": "Розбір",
|
"contextPanel.mode.walkthrough": "Розбір",
|
||||||
@@ -1144,8 +1155,11 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"walkthrough.toc.moreFiles": "Ще файлів: {count}",
|
"walkthrough.toc.moreFiles": "Ще файлів: {count}",
|
||||||
"walkthrough.toc.uncovered": "Не описано: {count}",
|
"walkthrough.toc.uncovered": "Не описано: {count}",
|
||||||
"walkthrough.toc.resize": "Змінити ширину колонки змісту",
|
"walkthrough.toc.resize": "Змінити ширину колонки змісту",
|
||||||
"walkthrough.importance.critical": "Критично",
|
"walkthrough.importance.critical": "Ключова зміна",
|
||||||
|
"walkthrough.importance.criticalHint": "Цей крок веде за собою решту зміни, тож прочитайте його уважно. Це не знайдена у вашому коді проблема.",
|
||||||
"walkthrough.importance.context": "Контекст",
|
"walkthrough.importance.context": "Контекст",
|
||||||
|
"walkthrough.importance.contextHint": "Допоміжна зміна, додана, щоб решта мала сенс.",
|
||||||
|
"walkthrough.help.guide": "Як працюють walkthrough",
|
||||||
"walkthrough.blocked.noModel.title": "Немає доступної small model",
|
"walkthrough.blocked.noModel.title": "Немає доступної small model",
|
||||||
"walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.",
|
"walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.",
|
||||||
"walkthrough.blocked.emptyDiff.title": "Немає що оглядати",
|
"walkthrough.blocked.emptyDiff.title": "Немає що оглядати",
|
||||||
@@ -1160,6 +1174,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"walkthrough.blocked.outputExhausted.descriptionUnknownModel": "Small model витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.",
|
"walkthrough.blocked.outputExhausted.descriptionUnknownModel": "Small model витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.",
|
||||||
"walkthrough.blocked.onlyGenerated.title": "Змінились лише згенеровані файли",
|
"walkthrough.blocked.onlyGenerated.title": "Змінились лише згенеровані файли",
|
||||||
"walkthrough.blocked.onlyGenerated.description": "Усі зміни тут — це lock-файли чи інший результат роботи інструментів, які розбір свідомо пропускає.",
|
"walkthrough.blocked.onlyGenerated.description": "Усі зміни тут — це lock-файли чи інший результат роботи інструментів, які розбір свідомо пропускає.",
|
||||||
|
"walkthrough.blocked.serverUnsupported.title": "Цей сервер не підтримує walkthrough",
|
||||||
|
"walkthrough.blocked.serverUnsupported.description": "Сервер OpenChamber, до якого підключено застосунок, не відповів на walkthrough API — отже, він старіший за застосунок. Оновіть сервер до 1.18 або новішої версії та оновіть панель.",
|
||||||
"walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "Small model вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.",
|
"walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "Small model вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.",
|
||||||
"walkthrough.blocked.structuredOutput.descriptionUnknownModel": "Small model не підтримує структуровані відповіді, потрібні для розбору.",
|
"walkthrough.blocked.structuredOutput.descriptionUnknownModel": "Small model не підтримує структуровані відповіді, потрібні для розбору.",
|
||||||
"contextRail.surface.plan.description": "Перегляд поточного плану",
|
"contextRail.surface.plan.description": "Перегляд поточного плану",
|
||||||
@@ -1618,6 +1634,10 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"directoryExplorerDialog.browse.directories": "Каталоги",
|
"directoryExplorerDialog.browse.directories": "Каталоги",
|
||||||
"directoryExplorerDialog.browse.loading": "Завантаження каталогів...",
|
"directoryExplorerDialog.browse.loading": "Завантаження каталогів...",
|
||||||
"directoryExplorerDialog.browse.empty": "Немає відповідних каталогів.",
|
"directoryExplorerDialog.browse.empty": "Немає відповідних каталогів.",
|
||||||
|
"directoryExplorerDialog.browse.permissionDenied": "OpenChamber потрібен доступ до цієї папки.",
|
||||||
|
"directoryExplorerDialog.browse.loadFailed": "Не вдалося завантажити цю папку.",
|
||||||
|
"directoryExplorerDialog.browse.grantAccess": "Надати доступ",
|
||||||
|
"directoryExplorerDialog.browse.retry": "Спробувати знову",
|
||||||
"directoryExplorerDialog.browse.parentDirectory": "Батьківський каталог",
|
"directoryExplorerDialog.browse.parentDirectory": "Батьківський каталог",
|
||||||
"directoryExplorerDialog.browse.addedBadge": "Додано",
|
"directoryExplorerDialog.browse.addedBadge": "Додано",
|
||||||
"directoryExplorerDialog.browse.quickAdd": "Додати",
|
"directoryExplorerDialog.browse.quickAdd": "Додати",
|
||||||
@@ -1684,7 +1704,7 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"helpDialog.item.toggleTerminalExpanded": "Розгорнути або згорнути термінал",
|
"helpDialog.item.toggleTerminalExpanded": "Розгорнути або згорнути термінал",
|
||||||
"helpDialog.item.togglePlanContextPanel": "Перемкнути панель контексту плану",
|
"helpDialog.item.togglePlanContextPanel": "Перемкнути панель контексту плану",
|
||||||
"helpDialog.item.cycleTheme": "Перемкнути тему (Світла → Темна → Системна)",
|
"helpDialog.item.cycleTheme": "Перемкнути тему (Світла → Темна → Системна)",
|
||||||
"helpDialog.item.switchProject": "Перемкнути проєкт",
|
"helpDialog.item.switchContextSurface": "Перемкнути поверхню панелі контексту (цифрова клавіша)",
|
||||||
"helpDialog.item.toggleServicesMenu": "Перемкнути меню сервісів",
|
"helpDialog.item.toggleServicesMenu": "Перемкнути меню сервісів",
|
||||||
"helpDialog.item.cycleServicesTab": "Перемкнути вкладку сервісів",
|
"helpDialog.item.cycleServicesTab": "Перемкнути вкладку сервісів",
|
||||||
"helpDialog.item.openSettings": "Відкрити налаштування",
|
"helpDialog.item.openSettings": "Відкрити налаштування",
|
||||||
@@ -1969,6 +1989,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…",
|
"sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…",
|
||||||
"sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.",
|
"sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.",
|
||||||
"sessions.sidebar.group.empty.retry": "Спробувати знову",
|
"sessions.sidebar.group.empty.retry": "Спробувати знову",
|
||||||
|
"sessions.sidebar.group.empty.permissionDenied": "Потрібен доступ до папки.",
|
||||||
|
"sessions.sidebar.group.empty.grantAccess": "Надати доступ",
|
||||||
"chat.unifiedControls.title": "Елементи керування",
|
"chat.unifiedControls.title": "Елементи керування",
|
||||||
"chat.unifiedControls.model.title": "Модель",
|
"chat.unifiedControls.model.title": "Модель",
|
||||||
"chat.unifiedControls.model.noRecent": "Немає останніх моделей",
|
"chat.unifiedControls.model.noRecent": "Немає останніх моделей",
|
||||||
@@ -2896,6 +2918,9 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"common.relative.daysAgoCompact": "{count}d ago",
|
"common.relative.daysAgoCompact": "{count}d ago",
|
||||||
"common.relative.weeksAgoCompact": "{count}w ago",
|
"common.relative.weeksAgoCompact": "{count}w ago",
|
||||||
"common.relative.yearsAgoCompact": "{count}y ago",
|
"common.relative.yearsAgoCompact": "{count}y ago",
|
||||||
|
"common.duration.secondsCompact": "{seconds}с",
|
||||||
|
"common.duration.minutesSecondsCompact": "{minutes}хв {seconds}с",
|
||||||
|
"common.duration.hoursMinutesCompact": "{hours}год {minutes}хв",
|
||||||
"contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)",
|
"contextFileOpen.failure.tooLarge": "File is too large to open (>{count} lines)",
|
||||||
"contextFileOpen.failure.missing": "File not found",
|
"contextFileOpen.failure.missing": "File not found",
|
||||||
"contextFileOpen.failure.unreadable": "Failed to open file",
|
"contextFileOpen.failure.unreadable": "Failed to open file",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user