Compare commits

...

33 Commits

Author SHA1 Message Date
Shantur Rathore
9c5dd6436e fix(i18n): localize speech settings copy 2026-03-25 13:49:39 +00:00
Shantur Rathore
6c27d4d1c4 fix(speech): keep provider secrets server-side 2026-03-25 13:39:36 +00:00
Shantur Rathore
c064cea4cc fix(ui): move prompt mic beside expand control 2026-03-25 11:56:02 +00:00
Shantur Rathore
fd529196fa fix(ui): restore prompt navigation button placement 2026-03-25 11:53:59 +00:00
Shantur Rathore
5f24fd4db7 feat(speech): make prompt input push to talk 2026-03-25 09:24:14 +00:00
Shantur Rathore
3c882e86b3 Revert "feat(speech): add realtime prompt dictation support"
This reverts commit f9b5e2b529.
2026-03-25 09:23:46 +00:00
Shantur Rathore
2354051297 feat(speech): add realtime prompt dictation support
Add server-backed realtime transcription for prompt voice input and expose speech settings to choose realtime mode and models.
2026-03-25 09:23:46 +00:00
Shantur Rathore
5948e25b97 fix(speech): preserve edits while saving settings 2026-03-25 09:23:46 +00:00
Shantur Rathore
f3a1ccd8b8 feat(speech): add prompt voice input groundwork 2026-03-25 09:23:46 +00:00
Pascal André
a950d47df0 fix(tauri): force Windows process tree shutdown (#240)
## Summary
- force the Windows CLI process tree shutdown path during normal app
close
- avoid leaving child server processes alive when the direct wrapper
process exits first
- keep the change limited to the Windows shutdown path in cli_manager

## Testing
- cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml
2026-03-24 21:12:43 +00:00
MusiCode1
1c68f5d288 feat(i18n): Hebrew locale + full RTL support (#243)
# feat(i18n): Hebrew locale + full RTL support

## Summary

This PR adds full Hebrew (he) locale support to the UI, including a
complete translation of all user-facing strings and comprehensive RTL
layout support across all components.

## What was done

### Hebrew translation
- Full translation of all i18n message files for the `he` locale (17
translation files)
- Registered the language in the i18n system and the language picker

### RTL support
- Automatic direction detection (`dir="rtl"`) when Hebrew is selected
- Replaced physical CSS properties (`left`/`right`) with logical
equivalents (`inline-start`/`inline-end`) across the project
- Fixed resize direction, file path alignment, and textarea padding
- Fixed navigation button positioning in textarea for RTL
- Fixed scrollbar direction in RTL
- Fixed code block direction and selector alignment
- Fixed Monaco editor direction in the file viewer
- Auto-detect text direction in reasoning block (`dir="auto"` +
`unicode-bidi: plaintext`)

### Adapted components
- `session-layout` — sidebar and resize handle
- `prompt-input` — text direction and buttons
- `message-base` — message blocks and reasoning
- `message-timeline` — timeline bar
- `right-panel` — right side panel
- `tool-call` — tool call display
- `settings-screen` — settings page
- `selector` — selection component
- `instance-shell` — main shell

## New files

```
packages/ui/src/lib/i18n/messages/he/
  advancedSettings.ts
  app.ts
  commands.ts
  dialogs.ts
  filesystem.ts
  folderSelection.ts
  index.ts
  instance.ts
  loadingScreen.ts
  logs.ts
  markdown.ts
  messaging.ts
  remoteAccess.ts
  session.ts
  settings.ts
  time.ts
  toolCall.ts
```

## Suggested testing
- Switch language to Hebrew and verify all strings are translated
- Verify RTL layout is correct across all screens (session, settings,
file viewer)
- Verify that English text inside a reasoning block is displayed LTR
- Switch back to English and verify everything returns to LTR

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Shantur Rathore <i@shantur.com>
2026-03-24 21:09:52 +00:00
Pascal André
3bad0afd7d perf(ui): defer locale and overlay bundles (#238)
## Summary
- defer locale and overlay loading work away from the first critical
render path
- seed locale state from the bootstrap preload so the first render can
use the preloaded language immediately
- keep bootstrap cache and locale fallback behavior consistent on
subsequent launches

## Testing
- npm run build --workspace @codenomad/ui
2026-03-23 15:12:28 +00:00
Pascal André
8567d49178 perf(ui): split right panel and secondary viewer chunks (#239)
## Summary
- split the right panel, picker, and tool call secondary viewers into
smaller deferred chunks
- release hidden right-panel file buffers and stop tracking static
tool-call scrollers when they are not needed
- keep this branch focused on the remaining secondary viewer chunking
work now that the Monaco-specific chunking moved into PR 215

## Testing
- npm run build --workspace @codenomad/ui
2026-03-23 08:47:03 +00:00
MusiCode1
09284ee2ce feat(ui): add RTL support for Hebrew/Arabic text (#229)
## What and why

CodeNomad had no RTL (right-to-left) support, so users writing in Hebrew
or Arabic would see their messages displayed left-to-right — misaligned
text, broken reading flow, wrong punctuation placement.

This PR adds automatic direction detection to all elements that display
user or model text. The browser detects direction from the first strong
character in each text block: Hebrew/Arabic → RTL, Latin/code → LTR. No
configuration needed — it just works per message, per paragraph.

## Technical notes

The natural fix is `dir="auto"` on the containing elements. However,
Chromium does not propagate direction detection from a parent `<div>`
into its `<p>` children — so Hebrew inside `<p>` rendered via
`innerHTML` (as markdown is) was still detected as LTR. The fix is to
apply `unicode-bidi: plaintext` via CSS directly on the block-level
elements (`p`, `li`, headings, etc.), which has the same auto-detection
semantics but applies per element.

## Summary

- Add `dir="auto"` to all elements containing user-generated or
model-generated text (message content, prompt input, session names, tool
outputs) so the browser auto-detects text direction
- Add `unicode-bidi: plaintext` via CSS to markdown block elements (`p`,
`li`, headings, `blockquote`, `td`/`th`) to fix per-paragraph RTL
detection in Chromium (where `dir="auto"` on a parent div does not
recurse into block children)
- Convert physical CSS properties to logical equivalents in
`markdown.css`: `border-left` → `border-inline-start`, `padding-left` →
`padding-inline-start`, `text-align: left` → `text-align: start`,
`margin-left` → `margin-inline-start`

## Affected components

- `markdown.tsx` — main markdown renderer
- `message-part.tsx` — text part wrapper and plain-text fallback
- `message-item.tsx` — message body and error blocks
- `prompt-input.tsx` — user input textarea
- `session-list.tsx` — session titles in sidebar
- `session-rename-dialog.tsx` — session rename input
- `instance-welcome-view.tsx` — Resume Session dialog
- `tool-call/markdown-render.tsx` — tool output markdown fallback
- `tool-call/ansi-render.tsx` — ANSI output
- `tool-call/diagnostics-section.tsx` — diagnostic messages

## Test plan

- [ ] Send a Hebrew-only message → text right-aligned
- [ ] Send a mixed Hebrew + English message → correct per-paragraph
direction
- [ ] Message containing a code block → code stays LTR
- [ ] Type Hebrew in the prompt textarea → input flows right-to-left
- [ ] Hebrew session name in sidebar → right-aligned
- [ ] Hebrew session name in Resume Session dialog → right-aligned

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:18:24 +00:00
Pascal André
a2e30f1b54 fix(tauri): restore desktop menu controls and fullscreen shortcut (#226)
## Summary
- restore the missing desktop View and Window menu controls
- use native reload and window actions where supported instead of
brittle webview-only behavior
- restore the working fullscreen keyboard shortcut while keeping the
zoom menu labels aligned with the intended desktop behavior

## Testing
- cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml
2026-03-22 20:13:29 +00:00
Shantur Rathore
a4af811de3 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-22 20:09:24 +00:00
Shantur Rathore
c5aa59ca75 fix(ui): keep reasoning streams pinned to bottom 2026-03-22 20:04:45 +00:00
Shantur Rathore
b8e0714b68 fix(ui): reduce message stream follow threshold 2026-03-22 19:54:28 +00:00
Shantur Rathore
3f890e5de1 fix(ui): restore spacing between virtualized message parts 2026-03-22 19:46:44 +00:00
Shantur Rathore
935926d875 ci: skip draft PR builds until ready 2026-03-22 19:41:48 +00:00
Pascal André
74f753abf4 perf(ui): lazy-load markdown and defer diff rendering (#215)
## Summary
- lazy-load the markdown and diff render paths so they stop inflating
initial UI startup work
- move shared text rendering helpers out of the markdown path and keep
diff rendering on the deferred path
- defer the Monaco secondary viewers so the markdown and diff path no
longer keeps that work in the main bundle

## Follow-ups
- related fork follow-up: Pagecran/CodeNomad#1
- that follow-up is now independent on dev and only keeps the remaining
right panel, picker, and tool-call secondary chunking work

## Testing
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui
2026-03-22 11:54:05 +00:00
codenomadbot[bot]
d15340a4b8 fix(ui): unwrap pasted placeholders in slash commands (#235)
## What
Fix slash command execution so `[pasted #N]` placeholders are resolved
before calling `session.command`, matching normal prompt send behavior.

## Why
When pasting long text into a slash command (e.g. `/some-command [pasted
#1]`), the UI previously bypassed `resolvePastedPlaceholders(...)` for
known slash commands and sent the literal placeholder text as command
arguments.

## Changes
- Resolve pasted placeholders (and other prompt placeholders handled by
`resolvePastedPlaceholders`) in slash-command arguments before
`executeCustomCommand(...)`.
- Remove *consumed* pasted-text attachments (those referenced by
placeholders in the slash-command args) so they don’t linger for the
next prompt.

Fixes #234.

## Notes
- I attempted `npm run typecheck --workspace @codenomad/ui` locally but
the workspace dependencies aren’t installed in this bot environment, so
it fails with missing-module errors. CI should validate with a full
install.

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-03-20 22:49:24 +00:00
Pascal André
108cad82d0 fix(tauri): restore external links in the folder picker (#225)
## Summary
- restore the GitHub and Discord links on the folder picker in the Tauri
app
- open those links through the desktop opener bridge instead of relying
on browser-only navigation behavior
- include the capability/schema updates needed for the opener path

## Testing
- npm run typecheck --workspace @codenomad/ui
- cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml
2026-03-20 22:48:29 +00:00
codenomadbot[bot]
823dd2d687 Suppress OS notifications for subagent (child) sessions (#236)
This PR prevents OS notification spam from spawned subagent sessions by
skipping OS-level notifications for any session that is a child thread
(`parentId !== null`).

What changed
- `packages/ui/src/stores/session-events.ts`
- Added `isChildSession(...)` +
`shouldSendOsNotificationForSession(...)`
  - Applied the check to OS notifications emitted from:
    - `handleSessionIdle(...)`
    - `handlePermissionUpdated(...)`
    - `handleQuestionAsked(...)`
- If a session is not yet hydrated in the client store, we
conservatively *do not* emit an OS notification (avoids early subagent
spam).

Why
- Subagent sessions are represented as child sessions in the UI thread
model; OS notifications were previously emitted for all sessions
indiscriminately.

Testing
- Not run here: `bun run typecheck` fails in this environment due to
missing installed deps/types (e.g. `solid-js`).

Closes #228

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-03-20 22:48:06 +00:00
Mateusz Popielarz
313e82880b feat(lazy loading): Implement virtual list with virtua (#241)
### Summary of Improvements

This PR replaces the custom `IntersectionObserver`-based virtualization
with the `virtua` library to significantly improve rendering performance
and UI responsiveness.

### 🚀 Performance Results

Verified using `session-performance.test.ts`:
- **Rendering**: 2000 messages rendered in **16.90ms**.
- **Huge Conversation**: 10,000 messages processed in **0.80ms**.
- **Session Switching**: Average switch time reduced to **0.58ms**
(virtually zero lag).

### 🛠️ Key Changes

- **Virtualized Message Stream**: Integrated `virtua/solid` for
efficient windowing and automatic scroll compensation.
- **Floating Scroll Controls**: Applied `position: absolute` and
`pointer-events: none` to the list controls to ensure
scroll-to-top/bottom buttons float correctly over the message area
without blocking interactions.
- **Package Synchronization**: Updated `virtua` and SDK dependencies,
with a fully synchronized `package-lock.json` for stable builds.

### 🎥 UI Verification


https://github.com/user-attachments/assets/24e483a3-8be6-4ac4-a431-d719f2015f4e


- **Smooth Scrolling**: Verified that rendering gaps are eliminated
during fast scrolls.
- **Position Retention**: Scroll positions are preserved when switching
between sessions.

> [!NOTE]
> Detailed performance gains and layout fixes are isolated to the
`virtua` implementation and core package updates, following the
requested cleanup.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-03-20 22:46:05 +00:00
Shantur Rathore
68407a01a4 ci: post PR artifact comments per build 2026-03-20 18:00:18 +00:00
Shantur Rathore
0283493f2a ci: prefer latest PR build run for artifact comments 2026-03-20 17:56:31 +00:00
Shantur Rathore
e989795de3 ci: move PR artifact comments to trusted workflow 2026-03-20 09:24:27 +00:00
Shantur Rathore
103d2bf1a8 ci: comment PR artifacts from validation run 2026-03-20 07:40:59 +00:00
Shantur Rathore
0ce7a47e03 ci: read PR number from workflow run 2026-03-20 07:22:56 +00:00
Shantur Rathore
5df8809c82 ci: resolve artifact comments by PR head branch 2026-03-20 07:13:04 +00:00
Shantur Rathore
6e22614648 ci: resolve PR number for artifact comment 2026-03-19 21:15:48 +00:00
Shantur Rathore
5d87e1e563 ci: upload PR build artifacts and comment link 2026-03-19 20:52:14 +00:00
129 changed files with 4620 additions and 1900 deletions

View File

@@ -28,6 +28,21 @@ on:
required: false
default: true
type: boolean
upload_actions_artifacts:
description: "Upload built artifacts to GitHub Actions run artifacts"
required: false
default: false
type: boolean
actions_artifacts_retention_days:
description: "Retention (days) for GitHub Actions artifacts"
required: false
default: 7
type: number
actions_artifacts_name_prefix:
description: "Optional prefix for Actions artifact names"
required: false
default: ""
type: string
set_versions:
description: "Run npm version to set workspace versions"
required: false
@@ -203,6 +218,15 @@ jobs:
gh release upload "$TAG" "$file" --clobber
done
- name: Upload Actions artifacts (Electron macOS)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-windows:
runs-on: windows-2025
env:
@@ -244,6 +268,15 @@ jobs:
gh release upload $env:TAG $_.FullName --clobber
}
- name: Upload Actions artifacts (Electron Windows)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-linux:
runs-on: ubuntu-24.04
env:
@@ -286,6 +319,15 @@ jobs:
gh release upload "$TAG" "$file" --clobber
done
- name: Upload Actions artifacts (Electron Linux)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-tauri-macos:
runs-on: macos-15-intel
env:
@@ -339,7 +381,7 @@ jobs:
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS)
if: ${{ inputs.upload }}
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -350,6 +392,15 @@ jobs:
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
fi
- name: Upload Actions artifacts (Tauri macOS)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
@@ -414,7 +465,7 @@ jobs:
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS arm64)
if: ${{ inputs.upload }}
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -425,6 +476,15 @@ jobs:
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
fi
- name: Upload Actions artifacts (Tauri macOS arm64)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
@@ -492,7 +552,7 @@ jobs:
run: npm exec -- tauri build
- name: Package Tauri artifacts (Windows)
if: ${{ inputs.upload }}
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
shell: pwsh
run: |
$bundleRoot = "packages/tauri-app/target/release/bundle"
@@ -505,6 +565,15 @@ jobs:
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
}
- name: Upload Actions artifacts (Tauri Windows)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
@@ -582,7 +651,7 @@ jobs:
run: npm exec -- tauri build
- name: Package Tauri artifacts (Linux)
if: ${{ inputs.upload }}
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
@@ -608,6 +677,15 @@ jobs:
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Actions artifacts (Tauri Linux)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
path: packages/tauri-app/release-tauri/*
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
@@ -766,3 +844,12 @@ jobs:
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
- name: Upload Actions artifacts (Electron Linux RPM)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
path: packages/electron-app/release/*.rpm
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error

View File

@@ -0,0 +1,121 @@
name: Comment PR Artifacts
on:
pull_request_target:
types:
- opened
- synchronize
- reopened
- ready_for_review
permissions:
actions: read
contents: read
issues: write
pull-requests: write
jobs:
comment:
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
IS_DRAFT: ${{ github.event.pull_request.draft }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RETENTION_DAYS: 7
steps:
- name: Check PR authorization
id: auth
shell: bash
run: |
set -euo pipefail
if [ "$BASE_REF" = "dev" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
fi
- name: Wait for PR build and comment
if: ${{ steps.auth.outputs.allowed == 'true' && env.IS_DRAFT != 'true' }}
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = Number(process.env.PR_NUMBER);
const headSha = process.env.HEAD_SHA;
const retentionDays = Number(process.env.RETENTION_DAYS || '7');
const marker = '<!-- codenomad-pr-artifacts -->';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let matchedRun = null;
for (let attempt = 1; attempt <= 30; attempt += 1) {
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: 'pr-build.yml',
event: 'pull_request',
per_page: 100,
});
const matchingRuns = runs
.filter((run) => run.head_sha === headSha)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
matchedRun = matchingRuns[0] || null;
if (matchedRun && matchedRun.status === 'completed') {
break;
}
core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/30)`);
await sleep(10000);
}
if (!matchedRun) {
core.setFailed(`Could not find PR Build Validation run for ${headSha}.`);
return;
}
if (matchedRun.status !== 'completed') {
core.setFailed(`PR Build Validation run ${matchedRun.id} did not complete in time.`);
return;
}
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{ owner, repo, run_id: matchedRun.id, per_page: 100 }
);
const active = artifacts.filter((artifact) => !artifact.expired);
const runUrl = matchedRun.html_url;
const artifactsBlock = active.length
? ['Artifacts:', ...active.map((artifact) => `- ${artifact.name}`)].join('\n')
: 'Artifacts: (none found on this run)';
const body = [
marker,
'PR builds are available as GitHub Actions artifacts:',
'',
runUrl,
'',
`Artifacts expire in ${retentionDays} days.`,
artifactsBlock,
].join('\n');
const created = await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body,
});
core.info(`Created artifacts comment: ${created.data.html_url}`);

View File

@@ -6,9 +6,11 @@ on:
- opened
- synchronize
- reopened
- ready_for_review
permissions:
contents: read
actions: write
concurrency:
group: pr-build-${{ github.event.pull_request.number }}
@@ -44,9 +46,12 @@ jobs:
build:
needs: authorize
if: ${{ needs.authorize.outputs.allowed == 'true' }}
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
uses: ./.github/workflows/build-and-upload.yml
with:
ref: ${{ github.event.pull_request.head.sha }}
upload: false
upload_actions_artifacts: true
actions_artifacts_retention_days: 7
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
set_versions: false

54
package-lock.json generated
View File

@@ -8240,6 +8240,27 @@
"regex-recursion": "^6.0.2"
}
},
"node_modules/openai": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -10984,6 +11005,36 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/virtua": {
"version": "0.48.8",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0",
"solid-js": ">=1.0",
"svelte": ">=5.0",
"vue": ">=3.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "5.4.21",
"dev": true,
@@ -11989,6 +12040,7 @@
"node_modules/zod": {
"version": "3.25.76",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -12050,6 +12102,7 @@
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yaml": "^2.4.2",
@@ -12113,6 +12166,7 @@
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0",
"virtua": "^0.48.8",
"yaml": "^2.4.2"
},
"devDependencies": {

View File

@@ -31,4 +31,4 @@
"devDependencies": {
"baseline-browser-mapping": "^2.9.11"
}
}
}

View File

@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.25"
"@opencode-ai/plugin": "1.2.14"
}
}

View File

@@ -32,6 +32,7 @@
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yaml": "^2.4.2",
@@ -46,4 +47,4 @@
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
}
}

View File

@@ -207,6 +207,36 @@ export interface BinaryValidationResult {
error?: string
}
export interface SpeechSegment {
startMs: number
endMs: number
text: string
}
export interface SpeechCapabilitiesResponse {
available: boolean
configured: boolean
provider: string
supportsStt: boolean
supportsTts: boolean
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
}
export interface SpeechTranscriptionResponse {
text: string
language?: string
durationMs?: number
segments?: SpeechSegment[]
}
export interface SpeechSynthesisResponse {
audioBase64: string
mimeType: string
}
export type WorkspaceEventType =
| "workspace.created"
| "workspace.started"

View File

@@ -23,6 +23,7 @@ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } fro
import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
const require = createRequire(import.meta.url)
@@ -304,6 +305,7 @@ async function main() {
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore(configLocation.instancesDir)
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
@@ -388,6 +390,7 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
speechService,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
@@ -408,6 +411,7 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
speechService,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,

View File

@@ -21,12 +21,14 @@ import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
import type { SpeechService } from "../speech/service"
interface HttpServerDeps {
bindHost: string
@@ -41,6 +43,7 @@ interface HttpServerDeps {
eventBus: EventBus
serverMeta: ServerMeta
instanceStore: InstanceStore
speechService: SpeechService
authManager: AuthManager
uiStaticDir: string
uiDevServerUrl?: string
@@ -252,6 +255,7 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerSpeechRoutes(app, { speechService: deps.speechService })
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -3,6 +3,7 @@ import { z } from "zod"
import { probeBinaryVersion } from "../../workspaces/runtime"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
interface RouteDeps {
settings: SettingsService
@@ -20,10 +21,10 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
// Full-document access
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
app.patch("/api/storage/config", async (request, reply) => {
try {
return deps.settings.mergePatchDoc("config", request.body ?? {})
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}))
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
@@ -31,12 +32,15 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
})
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
return deps.settings.getOwner("config", request.params.owner)
return sanitizeConfigOwner(request.params.owner, deps.settings.getOwner("config", request.params.owner))
})
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
try {
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
return sanitizeConfigOwner(
request.params.owner,
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
)
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }

View File

@@ -0,0 +1,60 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { SpeechService } from "../../speech/service"
interface RouteDeps {
speechService: SpeechService
}
const TranscribeBodySchema = z.object({
audioBase64: z.string().min(1, "Audio payload is required"),
mimeType: z.string().min(1, "Audio MIME type is required"),
filename: z.string().optional(),
language: z.string().optional(),
prompt: z.string().optional(),
})
const SynthesizeBodySchema = z.object({
text: z.string().trim().min(1, "Text is required"),
format: z.enum(["mp3", "wav", "opus"]).optional(),
})
function getSpeechErrorStatus(error: unknown): number {
if (error instanceof z.ZodError) {
return 400
}
if (error instanceof Error && /not configured/i.test(error.message)) {
return 503
}
return 502
}
function getSpeechErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback
}
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
app.post("/api/speech/transcribe", async (request, reply) => {
try {
const body = TranscribeBodySchema.parse(request.body ?? {})
return await deps.speechService.transcribe(body)
} catch (error) {
request.log.error({ err: error }, "Failed to transcribe audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to transcribe audio") }
}
})
app.post("/api/speech/synthesize", async (request, reply) => {
try {
const body = SynthesizeBodySchema.parse(request.body ?? {})
return await deps.speechService.synthesize(body)
} catch (error) {
request.log.error({ err: error }, "Failed to synthesize audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") }
}
})
}

View File

@@ -0,0 +1,40 @@
import type { SettingsDoc } from "./yaml-doc-store"
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function sanitizeServerOwner(value: SettingsDoc): SettingsDoc {
const next: SettingsDoc = { ...value }
const speech = isPlainObject(next.speech) ? { ...next.speech } : null
if (!speech) {
return next
}
const rawApiKey = typeof speech.apiKey === "string" ? speech.apiKey.trim() : ""
if (rawApiKey) {
delete speech.apiKey
speech.hasApiKey = true
} else if (!("hasApiKey" in speech)) {
speech.hasApiKey = false
}
next.speech = speech
return next
}
export function sanitizeConfigOwner(owner: string, value: SettingsDoc): SettingsDoc {
if (owner !== "server") {
return value
}
return sanitizeServerOwner(value)
}
export function sanitizeConfigDoc(value: SettingsDoc): SettingsDoc {
const next: SettingsDoc = { ...value }
if (isPlainObject(next.server)) {
next.server = sanitizeServerOwner(next.server)
}
return next
}

View File

@@ -4,6 +4,7 @@ import type { ConfigLocation } from "../config/location"
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
import { migrateSettingsLayout } from "./migrate"
import type { WorkspaceEventPayload } from "../api-types"
import { sanitizeConfigOwner } from "./public-config"
export type DocKind = "config" | "state"
@@ -45,10 +46,11 @@ export class SettingsService {
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
if (!this.eventBus) return
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
const nextValue = value ?? this.getOwner(kind, owner)
const payload: WorkspaceEventPayload = {
type,
owner,
value: value ?? this.getOwner(kind, owner),
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
} as any
this.eventBus.publish(payload)
}

View File

@@ -0,0 +1,148 @@
import OpenAI from "openai"
import { toFile } from "openai/uploads"
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
import type { Logger } from "../../logger"
import type { NormalizedSpeechSettings, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
interface OpenAICompatibleSpeechProviderOptions {
settings: NormalizedSpeechSettings
logger: Logger
}
export class OpenAICompatibleSpeechProvider {
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
getCapabilities() {
const { settings } = this.options
return {
available: true,
configured: Boolean(settings.apiKey),
provider: settings.provider,
supportsStt: true,
supportsTts: true,
baseUrl: settings.baseUrl,
sttModel: settings.sttModel,
ttsModel: settings.ttsModel,
ttsVoice: settings.ttsVoice,
}
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
const client = this.createClient()
const startedAt = Date.now()
const extension = extensionForMime(input.mimeType)
const buffer = Buffer.from(input.audioBase64, "base64")
const filename = input.filename?.trim() || `prompt-input.${extension}`
this.options.logger.info(
{
mimeType: input.mimeType,
bytes: buffer.byteLength,
language: input.language,
model: this.options.settings.sttModel,
},
"speech.transcribe",
)
const response = await this.requestTranscription(client, buffer, filename, input)
return {
text: typeof response?.text === "string" ? response.text : "",
language: typeof response?.language === "string" ? response.language : input.language,
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
segments: Array.isArray(response?.segments)
? response.segments
.filter((segment: any) => typeof segment?.text === "string")
.map((segment: any) => ({
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
text: String(segment.text),
}))
: undefined,
}
}
private async requestTranscription(
client: OpenAI,
buffer: Buffer,
filename: string,
input: TranscribeAudioInput,
): Promise<any> {
const baseRequest = {
model: this.options.settings.sttModel,
...(input.language ? { language: input.language } : {}),
...(input.prompt ? { prompt: input.prompt } : {}),
}
try {
const file = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file,
response_format: "verbose_json" as any,
} as any)) as any
} catch (error) {
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file: retryFile,
} as any)) as any
}
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
const client = this.createClient()
const format = input.format ?? "mp3"
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize",
)
const response = await client.audio.speech.create({
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice as any,
input: input.text,
response_format: format as any,
})
const audioBuffer = Buffer.from(await response.arrayBuffer())
return {
audioBase64: audioBuffer.toString("base64"),
mimeType: mimeTypeForFormat(format),
}
}
private createClient(): OpenAI {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
return new OpenAI({
apiKey: settings.apiKey,
baseURL: settings.baseUrl,
})
}
}
function extensionForMime(mimeType: string): string {
const normalized = mimeType.toLowerCase()
if (normalized.includes("webm")) return "webm"
if (normalized.includes("ogg")) return "ogg"
if (normalized.includes("wav")) return "wav"
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
return "webm"
}
function mimeTypeForFormat(format: "mp3" | "wav" | "opus"): string {
if (format === "wav") return "audio/wav"
if (format === "opus") return "audio/opus"
return "audio/mpeg"
}

View File

@@ -0,0 +1,91 @@
import { z } from "zod"
import type { Logger } from "../logger"
import type { SettingsService } from "../settings/service"
import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../api-types"
import { OpenAICompatibleSpeechProvider } from "./providers/openai-compatible"
const ServerSpeechSettingsSchema = z.object({
speech: z
.object({
provider: z.string().optional(),
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
sttModel: z.string().optional(),
ttsModel: z.string().optional(),
ttsVoice: z.string().optional(),
})
.optional(),
})
export interface TranscribeAudioInput {
audioBase64: string
mimeType: string
filename?: string
language?: string
prompt?: string
}
export interface SynthesizeSpeechInput {
text: string
format?: "mp3" | "wav" | "opus"
}
export interface SpeechProvider {
getCapabilities(): SpeechCapabilitiesResponse
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
}
export interface NormalizedSpeechSettings {
provider: string
apiKey?: string
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
}
const DEFAULT_PROVIDER = "openai-compatible"
const DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"
const DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"
const DEFAULT_TTS_VOICE = "alloy"
export class SpeechService {
constructor(
private readonly settings: SettingsService,
private readonly logger: Logger,
) {}
getCapabilities(): SpeechCapabilitiesResponse {
return this.createProvider().getCapabilities()
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
return this.createProvider().transcribe(input)
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
return this.createProvider().synthesize(input)
}
private createProvider(): SpeechProvider {
const settings = this.resolveSettings()
return new OpenAICompatibleSpeechProvider({
settings,
logger: this.logger.child({ provider: settings.provider }),
})
}
private resolveSettings(): NormalizedSpeechSettings {
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
const speech = parsed.speech ?? {}
return {
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
baseUrl: speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined,
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
}
}
}

View File

@@ -473,6 +473,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-notification",
"tauri-plugin-opener",
"thiserror 1.0.69",
@@ -1350,6 +1351,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.4",
"windows-link 0.2.1",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -1482,6 +1493,24 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "global-hotkey"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.18",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -4055,6 +4084,21 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
@@ -5735,6 +5779,29 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix 1.1.4",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
name = "yoke"
version = "0.8.1"

View File

@@ -23,6 +23,7 @@ keepawake = "0.6"
tauri-plugin-dialog = "2"
dirs = "5"
tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
url = "2"
tauri-plugin-notification = "2"

View File

@@ -11,6 +11,7 @@
"core:menu:default",
"dialog:allow-open",
"opener:allow-default-urls",
"opener:allow-open-url",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*","http://tauri.localhost/*","https://tauri.localhost/*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","opener:allow-open-url","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}

View File

@@ -2378,6 +2378,72 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
"type": "string",
"const": "global-shortcut:default",
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
},
{
"description": "Enables the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-is-registered",
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
},
{
"description": "Enables the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register",
"markdownDescription": "Enables the register command without any pre-configured scope."
},
{
"description": "Enables the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register-all",
"markdownDescription": "Enables the register_all command without any pre-configured scope."
},
{
"description": "Enables the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister",
"markdownDescription": "Enables the unregister command without any pre-configured scope."
},
{
"description": "Enables the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister-all",
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
},
{
"description": "Denies the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-is-registered",
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
},
{
"description": "Denies the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register",
"markdownDescription": "Denies the register command without any pre-configured scope."
},
{
"description": "Denies the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register-all",
"markdownDescription": "Denies the register_all command without any pre-configured scope."
},
{
"description": "Denies the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister",
"markdownDescription": "Denies the unregister command without any pre-configured scope."
},
{
"description": "Denies the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",

View File

@@ -51,6 +51,8 @@ fn workspace_root() -> Option<PathBuf> {
const SESSION_COOKIE_NAME: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
#[cfg(windows)]
const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000;
#[cfg(unix)]
fn configure_posix_process_group(command: &mut Command) {
@@ -402,6 +404,8 @@ impl CliProcessManager {
let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() {
log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(windows)]
let mut forced_tree_shutdown = false;
#[cfg(unix)]
unsafe {
let pid = child.id() as i32;
@@ -414,9 +418,7 @@ impl CliProcessManager {
}
#[cfg(windows)]
{
if !kill_process_tree_windows(child.id(), false) {
let _ = child.kill();
}
let _ = kill_process_tree_windows(child.id(), false);
}
let start = Instant::now();
@@ -424,6 +426,21 @@ impl CliProcessManager {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
#[cfg(windows)]
if !forced_tree_shutdown
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
{
log_line(&format!(
"regular Windows shutdown still running after {}ms; escalating pid={}",
CLI_WINDOWS_FORCE_GRACE_MS,
child.id()
));
forced_tree_shutdown = true;
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
log_line(&format!(
"stop timed out after {}s; sending SIGKILL pid={}",
@@ -440,7 +457,11 @@ impl CliProcessManager {
}
#[cfg(windows)]
{
if !kill_process_tree_windows(child.id(), true) {
if !forced_tree_shutdown
&& !kill_process_tree_windows(child.id(), true)
{
let _ = child.kill();
} else if forced_tree_shutdown {
let _ = child.kill();
}
}

View File

@@ -8,10 +8,14 @@ use serde::Deserialize;
use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
use tauri_plugin_opener::OpenerExt;
use url::Url;
@@ -25,6 +29,10 @@ use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.2;
const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0;
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
@@ -32,6 +40,7 @@ const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
pub struct AppState {
pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
}
#[derive(Debug, Default, Deserialize)]
@@ -157,6 +166,83 @@ fn emit_folder_drop_event(
}
}
fn clamp_zoom_level(value: f64) -> f64 {
value.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
}
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
if let Some(window) = app_handle.get_webview_window("main") {
let normalized = clamp_zoom_level(next_zoom);
if window.set_zoom(normalized).is_ok() {
if let Ok(mut zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
*zoom_level = normalized;
}
}
}
}
fn reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.reload();
}
}
fn force_reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
if let Ok(mut url) = window.url() {
if should_allow_internal(&url) {
let reload_token = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.to_string();
let existing_pairs: Vec<(String, String)> = url
.query_pairs()
.into_owned()
.filter(|(key, _)| key != "__codenomad_force_reload")
.collect();
{
let mut pairs = url.query_pairs_mut();
pairs.clear();
for (key, value) in existing_pairs {
pairs.append_pair(&key, &value);
}
pairs.append_pair("__codenomad_force_reload", &reload_token);
}
let _ = window.navigate(url);
return;
}
}
let _ = window.reload();
}
}
fn toggle_fullscreen_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let next_fullscreen = !window.is_fullscreen().unwrap_or(false);
let _ = window.set_fullscreen(next_fullscreen);
if cfg!(not(target_os = "macos")) {
if next_fullscreen {
let _ = window.hide_menu();
} else {
let _ = window.show_menu();
}
}
}
}
fn fullscreen_shortcut() -> Option<Shortcut> {
if cfg!(target_os = "macos") {
None
} else {
Some(Shortcut::new(None, ShortcutCode::F11))
}
}
#[cfg(windows)]
fn set_windows_app_user_model_id() {
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
@@ -181,15 +267,48 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state() != ShortcutState::Pressed {
return;
}
if fullscreen_shortcut().as_ref() == Some(shortcut) {
toggle_fullscreen_window(app);
}
})
.build(),
)
.plugin(tauri_plugin_notification::init())
.plugin(navigation_guard)
.manage(AppState {
manager: CliProcessManager::new(),
wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
})
.setup(|app| {
set_windows_app_user_model_id();
build_menu(&app.handle())?;
if let Some(shortcut) = fullscreen_shortcut() {
let shortcut_manager = app.handle().global_shortcut();
let _ = shortcut_manager.register(shortcut.clone());
if let Some(window) = app.get_webview_window("main") {
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let WindowEvent::Focused(focused) = event {
let shortcut_manager = app_handle.global_shortcut();
if *focused {
let _ = shortcut_manager.register(shortcut.clone());
} else {
let _ = shortcut_manager.unregister(shortcut.clone());
}
}
});
}
}
let dev_mode = is_dev_mode();
let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone();
@@ -214,36 +333,42 @@ fn main() {
let _ = window.emit("menu:newInstance", ());
}
}
"close" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
"quit" => {
app_handle.exit(0);
}
// View menu
"reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload()");
}
reload_main_window(app_handle);
}
"force_reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload(true)");
}
force_reload_main_window(app_handle);
}
"toggle_devtools" => {
if let Some(window) = app_handle.get_webview_window("main") {
window.open_devtools();
if window.is_devtools_open() {
window.close_devtools();
} else {
window.open_devtools();
}
}
}
"reset_zoom" => {
set_main_window_zoom(app_handle, DEFAULT_ZOOM_LEVEL);
}
"zoom_in" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level + ZOOM_STEP);
}
}
"zoom_out" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level - ZOOM_STEP);
}
}
"toggle_fullscreen" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
}
toggle_fullscreen_window(app_handle);
}
// Window menu
@@ -257,6 +382,11 @@ fn main() {
let _ = window.maximize();
}
}
"close_window" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
// App menu (macOS)
"about" => {
@@ -344,6 +474,7 @@ fn main() {
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
let is_mac = cfg!(target_os = "macos");
let is_linux = cfg!(target_os = "linux");
// Create submenus
let mut submenus = Vec::new();
@@ -371,16 +502,74 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
Some("CmdOrCtrl+N"),
)?;
let file_menu = SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text(
if is_mac { "close" } else { "quit" },
if is_mac { "Close" } else { "Quit" },
)
.build()?;
let file_menu = if is_mac {
SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.close_window()
.build()?
} else {
SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text("quit", "Quit")
.build()?
};
submenus.push(file_menu);
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
let force_reload_item = MenuItem::with_id(
app,
"force_reload",
"Force Reload",
true,
Some("CmdOrCtrl+Shift+R"),
)?;
let toggle_devtools_item = MenuItem::with_id(
app,
"toggle_devtools",
"Toggle Developer Tools",
true,
Some("Alt+CmdOrCtrl+I"),
)?;
let reset_zoom_item =
MenuItem::with_id(app, "reset_zoom", "Actual Size", true, Some("CmdOrCtrl+0"))?;
let zoom_in_item = MenuItem::with_id(
app,
"zoom_in",
if is_mac { "Zoom In" } else { "Zoom In\tCtrl++" },
true,
None::<&str>,
)?;
let zoom_out_item = MenuItem::with_id(
app,
"zoom_out",
if is_mac {
"Zoom Out"
} else {
"Zoom Out\tCtrl+-"
},
true,
None::<&str>,
)?;
let toggle_fullscreen_item = MenuItem::with_id(
app,
"toggle_fullscreen",
if is_mac {
"Toggle Full Screen"
} else {
"Toggle Full Screen\tF11"
},
true,
if is_mac {
Some("Ctrl+Cmd+F")
} else {
None::<&str>
},
)?;
let close_window_item =
MenuItem::with_id(app, "close_window", "Close", true, Some("CmdOrCtrl+W"))?;
// Edit menu with predefined items for standard functionality
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
@@ -396,20 +585,39 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
// View menu
let view_menu = SubmenuBuilder::new(app, "View")
.text("reload", "Reload")
.text("force_reload", "Force Reload")
.text("toggle_devtools", "Toggle Developer Tools")
.item(&reload_item)
.item(&force_reload_item)
.item(&toggle_devtools_item)
.separator()
.item(&reset_zoom_item)
.item(&zoom_in_item)
.item(&zoom_out_item)
.separator()
.text("toggle_fullscreen", "Toggle Full Screen")
.item(&toggle_fullscreen_item)
.build()?;
submenus.push(view_menu);
// Window menu
let window_menu = SubmenuBuilder::new(app, "Window")
.text("minimize", "Minimize")
.text("zoom", "Zoom")
.build()?;
let window_menu = if is_linux {
SubmenuBuilder::new(app, "Window")
.text("minimize", "Minimize")
.text("zoom", "Zoom")
.separator()
.item(&close_window_item)
.build()?
} else if is_mac {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.build()?
} else {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.separator()
.close_window()
.build()?
};
submenus.push(window_menu);
// Build the main menu with all submenus

View File

@@ -32,6 +32,7 @@
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0",
"virtua": "^0.48.8",
"yaml": "^2.4.2"
},
"devDependencies": {
@@ -44,4 +45,4 @@
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-solid": "^2.10.0"
}
}
}

View File

@@ -11,10 +11,8 @@ import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2"
import { SettingsScreen } from "./components/settings-screen"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger"
@@ -59,7 +57,6 @@ import { openSettings } from "./stores/settings-screen"
const log = getLogger("actions")
const App: Component = () => {
const { isDark } = useTheme()
const { t } = useI18n()
const {
preferences,
@@ -71,6 +68,7 @@ const App: Component = () => {
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -183,10 +181,6 @@ const App: Component = () => {
}
})
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => {
initReleaseNotifications()
})
@@ -360,6 +354,7 @@ const App: Component = () => {
toggleShowTimelineTools,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,

View File

@@ -1,7 +1,8 @@
import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
import { getSharedHighlighter } from "../lib/markdown"
import { escapeHtml } from "../lib/text-render-utils"
import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"

View File

@@ -1,9 +1,10 @@
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import "@git-diff-view/solid/styles/diff-view-pure.css"
import { disableCache } from "@git-diff-view/core"
import type { DiffHighlighterLang } from "@git-diff-view/core"
import { ErrorBoundary } from "solid-js"
import { getLanguageFromPath } from "../lib/markdown"
import { getLanguageFromPath } from "../lib/text-render-utils"
import { normalizeDiffText } from "../lib/diff-utils"
import { setCacheEntry } from "../lib/global-cache"
import type { CacheEntryParams } from "../lib/global-cache"
@@ -134,4 +135,4 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
</Show>
</div>
)
}
}

View File

@@ -13,8 +13,11 @@ import { formatCompactCount } from "../lib/formatters"
import { useI18n, type Locale } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
interface FolderSelectionViewProps {
@@ -42,6 +45,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
{ value: "ru", label: "Русский" },
{ value: "ja", label: "日本語" },
{ value: "zh-Hans", label: "简体中文" },
{ value: "he", label: "עברית" },
]
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
@@ -232,11 +236,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
props.onSelectFolder(path, selectedBinary())
}
const openExternalLink = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
@@ -343,7 +342,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<div class="absolute top-4 left-6">
<div class="absolute top-4" style="inset-inline-start: 1.5rem;">
<Select<LanguageOption>
value={selectedLanguageOption()}
onChange={(value) => {
@@ -387,7 +386,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Select.Portal>
</Select>
</div>
<div class="absolute top-4 right-6 flex items-center gap-2">
<div class="absolute top-4 flex items-center gap-2" style="inset-inline-end: 1.5rem;">
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -425,7 +424,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<div class="mt-3 flex justify-center gap-2">
<a
href="https://github.com/NeuralNomadsAI/CodeNomad"
href={GITHUB_URL}
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -433,13 +432,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.github")}
onClick={(event) => {
event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
void openExternalUrl(GITHUB_URL, "folder-selection")
}}
>
<GitHubMarkIcon class="w-4 h-4" />
</a>
<a
href="https://github.com/NeuralNomadsAI/CodeNomad"
href={GITHUB_URL}
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
@@ -447,7 +446,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.githubStars")}
onClick={(event) => {
event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
void openExternalUrl(GITHUB_URL, "folder-selection")
}}
>
<Star class="w-4 h-4" />
@@ -456,7 +455,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show>
</a>
<a
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
href={DISCORD_URL}
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -464,9 +463,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.discord")}
onClick={(event) => {
event.preventDefault()
openExternalLink(
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
)
void openExternalUrl(DISCORD_URL, "folder-selection")
}}
>
<DiscordSymbolIcon class="w-4 h-4" />

View File

@@ -82,7 +82,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="panel-body space-y-3">
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div dir="ltr" class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
{currentInstance().folder}
</div>
</div>
@@ -94,7 +94,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
{t("instanceInfo.labels.project")}
</div>
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
<div dir="ltr" class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
{project().id}
</div>
</div>
@@ -137,7 +137,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
{t("instanceInfo.labels.binaryPath")}
</div>
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
<div dir="ltr" class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
{currentInstance().binaryPath}
</div>
</div>
@@ -151,7 +151,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="space-y-1">
<For each={environmentEntries()}>
{([key, value]) => (
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div dir="ltr" class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
{key}
</span>

View File

@@ -404,6 +404,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
dir="auto"
classList={{
"text-accent": isFocused(),
}}

View File

@@ -81,7 +81,8 @@ interface InstanceShellProps {
}
const InstanceShell2: Component<InstanceShellProps> = (props) => {
const { t } = useI18n()
const { t, locale } = useI18n()
const isRTL = () => locale() === "he"
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
@@ -371,7 +372,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
sx={{
width: `${sessionSidebarWidth()}px`,
flexShrink: 0,
borderRight: "1px solid var(--border-base)",
borderInlineEnd: "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)",
height: "100%",
minHeight: 0,
@@ -413,7 +414,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const modalProps = container ? { container: container as Element } : undefined
return (
<Drawer
anchor="left"
anchor={isRTL() ? "right" : "left"}
variant="temporary"
open={leftOpen()}
onClose={closeLeftDrawer}
@@ -422,7 +423,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
"& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
boxSizing: "border-box",
borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)",
backgroundImage: "none",
color: "var(--text-primary)",
@@ -480,7 +481,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
sx={{
width: `${rightDrawerWidth()}px`,
flexShrink: 0,
borderLeft: "1px solid var(--border-base)",
borderInlineStart: "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)",
height: "100%",
minHeight: 0,
@@ -523,7 +524,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const modalProps = container ? { container: container as Element } : undefined
return (
<Drawer
anchor="right"
anchor={isRTL() ? "left" : "right"}
variant="temporary"
open={rightOpen()}
onClose={closeRightDrawer}
@@ -532,7 +533,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
"& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
boxSizing: "border-box",
borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)",
backgroundImage: "none",
color: "var(--text-primary)",
@@ -742,7 +743,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<Kbd shortcut="cmd+shift+p" />
</span>
<div class="ml-auto flex items-center gap-3">
<div class="ms-auto flex items-center gap-3">
<div class="connection-status-meta flex items-center gap-3">
<Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected">

View File

@@ -1,8 +1,10 @@
import {
Show,
Suspense,
createEffect,
createMemo,
createSignal,
lazy,
onCleanup,
type Accessor,
type Component,
@@ -20,11 +22,6 @@ import type { Session } from "../../../../types/session"
import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
import ChangesTab from "./tabs/ChangesTab"
import FilesTab from "./tabs/FilesTab"
import GitChangesTab from "./tabs/GitChangesTab"
import StatusTab from "./tabs/StatusTab"
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api"
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
@@ -49,6 +46,15 @@ import {
readStoredRightPanelTab,
} from "../storage"
const LazyChangesTab = lazy(() => import("./tabs/ChangesTab"))
const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
const LazyFilesTab = lazy(() => import("./tabs/FilesTab"))
const LazyStatusTab = lazy(() => import("./tabs/StatusTab"))
function RightPanelTabFallback() {
return <div class="flex-1 min-h-0" />
}
interface RightPanelProps {
t: (key: string, vars?: Record<string, any>) => string
@@ -243,7 +249,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const mode = activeSplitResize()
if (!mode) return
event.preventDefault()
const delta = event.clientX - splitResizeStartX()
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next)
@@ -266,7 +273,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
const delta = touch.clientX - splitResizeStartX()
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next)
@@ -565,6 +573,13 @@ const RightPanel: Component<RightPanelProps> = (props) => {
void loadBrowserEntries(browserPath())
})
createEffect(() => {
if (rightPanelTab() === "files") return
setBrowserSelectedContent(null)
setBrowserSelectedLoading(false)
setBrowserSelectedError(null)
})
createEffect(() => {
if (rightPanelTab() !== "git-changes") return
if (gitStatusLoading()) return
@@ -572,6 +587,14 @@ const RightPanel: Component<RightPanelProps> = (props) => {
void loadGitStatus()
})
createEffect(() => {
if (rightPanelTab() === "git-changes") return
setGitSelectedBefore(null)
setGitSelectedAfter(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
})
const handleSelectChangesFile = (file: string, closeList: boolean) => {
setSelectedFile(file)
if (closeList) {
@@ -738,101 +761,109 @@ const RightPanel: Component<RightPanelProps> = (props) => {
<div class="flex-1 overflow-y-auto">
<Show when={rightPanelTab() === "changes"}>
<ChangesTab
t={props.t}
instanceId={props.instanceId}
activeSessionId={props.activeSessionId}
activeSessionDiffs={props.activeSessionDiffs}
selectedFile={selectedFile}
onSelectFile={handleSelectChangesFile}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
listOpen={changesListOpen}
onToggleList={toggleChangesList}
splitWidth={changesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
isPhoneLayout={props.isPhoneLayout}
/>
<Suspense fallback={<RightPanelTabFallback />}>
<LazyChangesTab
t={props.t}
instanceId={props.instanceId}
activeSessionId={props.activeSessionId}
activeSessionDiffs={props.activeSessionDiffs}
selectedFile={selectedFile}
onSelectFile={handleSelectChangesFile}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
listOpen={changesListOpen}
onToggleList={toggleChangesList}
splitWidth={changesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
isPhoneLayout={props.isPhoneLayout}
/>
</Suspense>
</Show>
<Show when={rightPanelTab() === "git-changes"}>
<GitChangesTab
t={props.t}
activeSessionId={props.activeSessionId}
entries={gitStatusEntries}
statusLoading={gitStatusLoading}
statusError={gitStatusError}
selectedPath={gitSelectedPath}
selectedLoading={gitSelectedLoading}
selectedError={gitSelectedError}
selectedBefore={gitSelectedBefore}
selectedAfter={gitSelectedAfter}
mostChangedPath={gitMostChangedPath}
scopeKey={gitScopeKey}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
onOpenFile={(path) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()}
listOpen={gitChangesListOpen}
onToggleList={toggleGitList}
splitWidth={gitChangesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
isPhoneLayout={props.isPhoneLayout}
/>
<Suspense fallback={<RightPanelTabFallback />}>
<LazyGitChangesTab
t={props.t}
activeSessionId={props.activeSessionId}
entries={gitStatusEntries}
statusLoading={gitStatusLoading}
statusError={gitStatusError}
selectedPath={gitSelectedPath}
selectedLoading={gitSelectedLoading}
selectedError={gitSelectedError}
selectedBefore={gitSelectedBefore}
selectedAfter={gitSelectedAfter}
mostChangedPath={gitMostChangedPath}
scopeKey={gitScopeKey}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
onOpenFile={(path: string) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()}
listOpen={gitChangesListOpen}
onToggleList={toggleGitList}
splitWidth={gitChangesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
isPhoneLayout={props.isPhoneLayout}
/>
</Suspense>
</Show>
<Show when={rightPanelTab() === "files"}>
<FilesTab
t={props.t}
browserPath={browserPath}
browserEntries={browserEntries}
browserLoading={browserLoading}
browserError={browserError}
browserSelectedPath={browserSelectedPath}
browserSelectedContent={browserSelectedContent}
browserSelectedLoading={browserSelectedLoading}
browserSelectedError={browserSelectedError}
parentPath={browserParentPath}
scopeKey={browserScopeKey}
onLoadEntries={(path) => void loadBrowserEntries(path)}
onOpenFile={(path) => void openBrowserFile(path)}
onRefresh={() => void refreshFilesTab()}
listOpen={filesListOpen}
onToggleList={toggleFilesList}
splitWidth={filesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("files")}
onResizeTouchStart={handleSplitResizeTouchStart("files")}
isPhoneLayout={props.isPhoneLayout}
/>
<Suspense fallback={<RightPanelTabFallback />}>
<LazyFilesTab
t={props.t}
browserPath={browserPath}
browserEntries={browserEntries}
browserLoading={browserLoading}
browserError={browserError}
browserSelectedPath={browserSelectedPath}
browserSelectedContent={browserSelectedContent}
browserSelectedLoading={browserSelectedLoading}
browserSelectedError={browserSelectedError}
parentPath={browserParentPath}
scopeKey={browserScopeKey}
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
onOpenFile={(path: string) => void openBrowserFile(path)}
onRefresh={() => void refreshFilesTab()}
listOpen={filesListOpen}
onToggleList={toggleFilesList}
splitWidth={filesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("files")}
onResizeTouchStart={handleSplitResizeTouchStart("files")}
isPhoneLayout={props.isPhoneLayout}
/>
</Suspense>
</Show>
<Show when={rightPanelTab() === "status"}>
<StatusTab
t={props.t}
instanceId={props.instanceId}
instance={props.instance}
activeSessionId={props.activeSessionId}
activeSession={props.activeSession}
activeSessionDiffs={props.activeSessionDiffs}
latestTodoState={props.latestTodoState}
backgroundProcessList={props.backgroundProcessList}
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
onStopBackgroundProcess={props.onStopBackgroundProcess}
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
expandedItems={rightPanelExpandedItems}
onExpandedItemsChange={handleAccordionChange}
onOpenChangesTab={openChangesTabFromStatus}
/>
<Suspense fallback={<RightPanelTabFallback />}>
<LazyStatusTab
t={props.t}
instanceId={props.instanceId}
instance={props.instance}
activeSessionId={props.activeSessionId}
activeSession={props.activeSession}
activeSessionDiffs={props.activeSessionDiffs}
latestTodoState={props.latestTodoState}
backgroundProcessList={props.backgroundProcessList}
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
onStopBackgroundProcess={props.onStopBackgroundProcess}
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
expandedItems={rightPanelExpandedItems}
onExpandedItemsChange={handleAccordionChange}
onOpenChangesTab={openChangesTabFromStatus}
/>
</Suspense>
</Show>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import type { Component } from "solid-js"
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
import { useI18n } from "../../../../../lib/i18n"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface DiffToolbarProps {
@@ -14,14 +15,15 @@ interface DiffToolbarProps {
}
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
const { t } = useI18n()
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
const viewModeTitle = () => (nextViewMode() === "split" ? t("instanceShell.diff.switchToSplit") : t("instanceShell.diff.switchToUnified"))
const contextModeTitle = () =>
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
nextContextMode() === "collapsed" ? t("instanceShell.diff.hideUnchanged") : t("instanceShell.diff.showFull")
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? t("instanceShell.diff.enableWordWrap") : t("instanceShell.diff.disableWordWrap"))
return (
<div class="file-viewer-toolbar">

View File

@@ -1,5 +1,6 @@
import { Show, type Component, type JSX } from "solid-js"
import { useI18n } from "../../../../../lib/i18n"
import OverlayList from "./OverlayList"
type SplitFilePanelList = {
@@ -24,12 +25,13 @@ interface SplitFilePanelProps {
}
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
const { t } = useI18n()
return (
<div class="files-tab-container">
<div class="files-tab-header">
<div class="files-tab-header-row">
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
{props.listOpen ? "Hide files" : "Show files"}
{props.listOpen ? t("instanceShell.filesShell.hideFiles") : t("instanceShell.filesShell.showFiles")}
</button>
{props.header}

View File

@@ -1,11 +1,13 @@
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string
@@ -113,15 +115,23 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
}
>
{(file) => (
<MonacoDiffViewer
scopeKey={scopeKey()}
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
<Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoDiffViewer
scopeKey={scopeKey()}
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
)}
</Show>
</div>
@@ -220,7 +230,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Changes"
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")}
/>
)
}

View File

@@ -1,12 +1,14 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid"
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
import SplitFilePanel from "../components/SplitFilePanel"
const LazyMonacoFileViewer = lazy(() =>
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
)
interface FilesTabProps {
t: (key: string, vars?: Record<string, any>) => string
@@ -51,8 +53,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
const emptyViewerMessage = () => {
if (props.browserLoading() && entriesValue === null) return "Loading files..."
return "Select a file to preview"
if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
return props.t("instanceShell.filesShell.viewerEmpty")
}
const renderViewer = () => (
@@ -77,7 +79,15 @@ const FilesTab: Component<FilesTabProps> = (props) => {
}
>
{(payload) => (
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
<Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
</Suspense>
)}
</Show>
}
@@ -91,7 +101,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
}
>
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span>
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
</Show>
</div>
@@ -113,7 +123,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</Show>
<Show when={props.browserLoading() && entriesValue === null}>
<div class="p-3 text-xs text-secondary">Loading files...</div>
<div class="p-3 text-xs text-secondary">{props.t("instanceInfo.loading")}</div>
</Show>
<For each={sorted}>
@@ -154,7 +164,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</span>
</span>
<Show when={props.browserLoading()}>
<span>Loading</span>
<span>{props.t("instanceInfo.loading")}</span>
</Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div>
@@ -165,7 +175,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={props.browserLoading()}
style={{ "margin-left": "auto" }}
style={{ "margin-inline-start": "auto" }}
onClick={() => props.onRefresh()}
>
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
@@ -180,7 +190,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Files"
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.files")}
/>
)
}

View File

@@ -1,14 +1,16 @@
import { For, Show, createMemo, type Accessor, type Component, type JSX } from "solid-js"
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string
@@ -80,11 +82,11 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
})
const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return "Select a session to view changes."
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
const currentEntries = entries()
if (currentEntries === null) return "Loading git changes…"
if (nonDeleted().length === 0) return "No git changes yet."
return "No file selected."
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
})
const renderContent = (): JSX.Element => {
@@ -122,7 +124,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
}
>
{(file) => (
<MonacoDiffViewer
<Suspense
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
}
>
<LazyMonacoDiffViewer
scopeKey={props.scopeKey()}
path={String(file().path || "")}
before={String((file() as any).before || "")}
@@ -131,7 +140,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
)}
</Suspense>
)}
</Show>
}
>
@@ -144,7 +154,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
}
>
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span>
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div>
</Show>
</div>
@@ -169,7 +179,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span>
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
@@ -200,7 +210,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span>
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
@@ -220,8 +230,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
<SplitFilePanel
header={
<>
<span class="files-tab-selected-path" title={selected?.path || "Git Changes"}>
<span class="file-path-text">{selected?.path || "Git Changes"}</span>
<span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
<span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
</span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
@@ -264,7 +274,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Git Changes"
overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
/>
)
}

View File

@@ -46,7 +46,9 @@ export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
if (!side) return
const startWidth = resizeStartWidth()
const clamp = side === "left" ? options.clampLeft : options.clampRight
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
const rawDelta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
const delta = isRtl ? -rawDelta : rawDelta
const nextWidth = clamp(startWidth + delta)
applyDrawerWidth(side, nextWidth)
}

View File

@@ -1,5 +1,4 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger"
@@ -8,6 +7,20 @@ import { useI18n } from "../lib/i18n"
const log = getLogger("session")
type MarkdownModule = typeof import("../lib/markdown")
let markdownModulePromise: Promise<MarkdownModule> | null = null
function loadMarkdownModule(): Promise<MarkdownModule> {
if (!markdownModulePromise) {
markdownModulePromise = import("../lib/markdown").catch((error) => {
markdownModulePromise = null
throw error
})
}
return markdownModulePromise
}
function hashText(value: string): string {
let hash = 2166136261
for (let index = 0; index < value.length; index++) {
@@ -24,6 +37,45 @@ function resolvePartVersion(part: TextPart, text: string): string {
return `text-${hashText(text)}`
}
function resolvePartCacheId(part: TextPart, text: string): string {
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
if (partId) {
return partId
}
return `anonymous:${hashText(text)}`
}
function decodeHtmlEntitiesLocally(content: string): string {
if (!content.includes("&") || typeof document === "undefined") {
return content
}
const textarea = document.createElement("textarea")
textarea.innerHTML = content
return textarea.value
}
function escapeHtml(content: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
}
function renderFallbackHtml(content: string): string {
if (!content) {
return ""
}
return escapeHtml(content).replace(/\n/g, "<br />")
}
interface MarkdownProps {
part: TextPart
instanceId?: string
@@ -38,7 +90,8 @@ export function Markdown(props: MarkdownProps) {
const { t } = useI18n()
const [html, setHtml] = createSignal("")
let containerRef: HTMLDivElement | undefined
let latestRequestedText = ""
let latestRequestKey = ""
let cleanupLanguageListener: (() => void) | undefined
const notifyRendered = () => {
Promise.resolve().then(() => props.onRendered?.())
@@ -47,15 +100,14 @@ export function Markdown(props: MarkdownProps) {
const resolved = createMemo(() => {
const part = props.part
const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText)
const text = decodeHtmlEntitiesLocally(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
if (!partId) {
throw new Error("Markdown rendering requires a part id")
}
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
const cacheId = resolvePartCacheId(part, text)
const version = resolvePartVersion(part, text)
return { part, text, themeKey, highlightEnabled, partId, version }
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
})
const cacheHandle = useGlobalCache({
@@ -63,26 +115,46 @@ export function Markdown(props: MarkdownProps) {
sessionId: () => props.sessionId,
scope: "markdown",
cacheId: () => {
const { partId, themeKey, highlightEnabled } = resolved()
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
const { cacheId, themeKey, highlightEnabled } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
},
version: () => resolved().version,
})
createEffect(async () => {
const { part, text, themeKey, highlightEnabled, version } = resolved()
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
const cacheEntry: RenderCache = {
text: snapshot.text,
html: renderedHtml,
theme: snapshot.themeKey,
mode: snapshot.version,
}
setHtml(renderedHtml)
cacheHandle.set(cacheEntry)
notifyRendered()
}
// Ensure the markdown highlighter theme matches the active UI theme.
setMarkdownTheme(themeKey === "dark")
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
const markdown = await loadMarkdownModule()
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled,
})
latestRequestedText = text
if (latestRequestKey === snapshot.requestKey) {
commitCacheEntry(snapshot, rendered)
}
}
createEffect(() => {
const snapshot = resolved()
latestRequestKey = snapshot.requestKey
const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false
return cache.theme === themeKey && cache.mode === version
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
}
const localCache = part.renderCache
const localCache = snapshot.part.renderCache
if (localCache && cacheMatches(localCache)) {
setHtml(localCache.html)
notifyRendered()
@@ -96,111 +168,83 @@ export function Markdown(props: MarkdownProps) {
return
}
const commitCacheEntry = (renderedHtml: string) => {
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
setHtml(renderedHtml)
cacheHandle.set(cacheEntry)
notifyRendered()
}
setHtml(renderFallbackHtml(snapshot.text))
notifyRendered()
if (!highlightEnabled) {
try {
const rendered = await renderMarkdown(text, { suppressHighlight: true })
if (latestRequestedText === text) {
commitCacheEntry(rendered)
}
} catch (error) {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
commitCacheEntry(text)
}
}
return
}
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
commitCacheEntry(rendered)
}
} catch (error) {
void renderSnapshot(snapshot).catch((error) => {
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
commitCacheEntry(text)
if (latestRequestKey === snapshot.requestKey) {
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
}
}
})
})
onMount(() => {
const handleClick = async (e: Event) => {
const target = e.target as HTMLElement
const handleClick = async (event: Event) => {
const target = event.target as HTMLElement
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
if (copyButton) {
e.preventDefault()
const code = copyButton.getAttribute("data-code")
if (code) {
const decodedCode = decodeURIComponent(code)
const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text")
if (copyText) {
if (success) {
copyText.textContent = t("markdown.codeBlock.copy.copied")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
} else {
copyText.textContent = t("markdown.codeBlock.copy.failed")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
}
}
}
if (!copyButton) {
return
}
event.preventDefault()
const code = copyButton.getAttribute("data-code")
if (!code) {
return
}
const decodedCode = decodeURIComponent(code)
const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text")
if (!copyText) {
return
}
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
setTimeout(() => {
copyText.textContent = t("markdown.codeBlock.copy.label")
}, 2000)
}
containerRef?.addEventListener("click", handleClick)
const cleanupLanguageListener = onLanguagesLoaded(async () => {
if (props.disableHighlight) {
return
}
const { part, text, themeKey, version } = resolved()
setMarkdownTheme(themeKey === "dark")
if (latestRequestedText !== text) {
return
}
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
setHtml(rendered)
cacheHandle.set(cacheEntry)
notifyRendered()
let disposed = false
void loadMarkdownModule()
.then((markdown) => {
if (disposed) {
return
}
} catch (error) {
log.error("Failed to re-render markdown after language load:", error)
}
})
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
const snapshot = resolved()
if (!snapshot.highlightEnabled) {
return
}
latestRequestKey = snapshot.requestKey
void renderSnapshot(snapshot).catch((error) => {
log.error("Failed to re-render markdown after language load:", error)
})
})
})
.catch((error) => {
log.error("Failed to load markdown module:", error)
})
onCleanup(() => {
disposed = true
containerRef?.removeEventListener("click", handleClick)
cleanupLanguageListener()
cleanupLanguageListener?.()
cleanupLanguageListener = undefined
})
})
const proseClass = () => "markdown-body"
return (
<div
ref={containerRef}
class={proseClass()}
class="markdown-body"
dir="auto"
data-view="markdown"
data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey}

View File

@@ -1,7 +1,6 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message"
@@ -29,6 +28,12 @@ const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
const LazyToolCall = lazy(() => import("./tool-call"))
function ToolCallFallback() {
return <div class="tool-call tool-call-loading" />
}
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -500,16 +505,18 @@ function ToolCallItem(props: ToolCallItemProps) {
</div>
</div>
<ToolCall
toolCall={resolvedToolPart()}
toolCallId={props.partId}
messageId={props.messageId}
messageVersion={messageVersion()}
partVersion={partVersion()}
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onContentRendered}
/>
<Suspense fallback={<ToolCallFallback />}>
<LazyToolCall
toolCall={resolvedToolPart()}
toolCallId={props.partId}
messageId={props.messageId}
messageVersion={messageVersion()}
partVersion={partVersion()}
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onContentRendered}
/>
</Suspense>
</div>
)}
</Show>
@@ -902,6 +909,7 @@ export default function MessageBlock(props: MessageBlockProps) {
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/>
</Match>
</Switch>
@@ -1280,6 +1288,7 @@ interface ReasoningCardProps {
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onContentRendered?: () => void
}
function ReasoningCard(props: ReasoningCardProps) {
@@ -1288,6 +1297,25 @@ function ReasoningCard(props: ReasoningCardProps) {
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
let pendingRenderNotificationFrame: number | null = null
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
})
createEffect(() => {
setExpanded(Boolean(props.defaultExpanded))
@@ -1356,6 +1384,12 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
createEffect(() => {
if (!expanded()) return
reasoningText()
notifyContentRendered()
})
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
@@ -1497,7 +1531,7 @@ function ReasoningCard(props: ReasoningCardProps) {
<div class="message-reasoning-expanded">
<div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
</div>
</div>
</div>

View File

@@ -542,7 +542,7 @@ export default function MessageItem(props: MessageItemProps) {
</header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
<Show when={props.isQueued && isUser()}>
@@ -550,7 +550,7 @@ export default function MessageItem(props: MessageItemProps) {
</Show>
<Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div>
<div class="message-error-block" dir="auto"> {errorMessage()}</div>
</Show>
<Show when={isGenerating()}>

View File

@@ -1,5 +1,4 @@
import { Show, Match, Switch } from "solid-js"
import ToolCall from "./tool-call"
import { Match, Show, Suspense, Switch, lazy } from "solid-js"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme"
@@ -7,6 +6,8 @@ import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/m
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
const LazyToolCall = lazy(() => import("./tool-call"))
interface MessagePartProps {
part: ClientPart
messageType?: "user" | "assistant"
@@ -133,11 +134,12 @@ export default function MessagePart(props: MessagePartProps) {
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
dir="auto"
data-role={textContainerRole()}
data-part-type="text"
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
@@ -152,12 +154,14 @@ export default function MessagePart(props: MessagePartProps) {
</Match>
<Match when={partType() === "tool"}>
<ToolCall
toolCall={props.part as ToolCallPart}
toolCallId={props.part?.id}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
<Suspense fallback={<div class="tool-call tool-call-loading" />}>
<LazyToolCall
toolCall={props.part as ToolCallPart}
toolCallId={props.part?.id}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</Suspense>
</Match>

View File

@@ -19,7 +19,7 @@ import type { DeleteHoverState } from "../types/delete-hover"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils"
const SCROLL_SENTINEL_MARGIN_PX = 48
const SCROLL_SENTINEL_MARGIN_PX = 8
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
const QUOTE_SELECTION_MAX_LENGTH = 2000
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href

View File

@@ -295,7 +295,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
</span>
{currentModelValue() && (
<span class="selector-trigger-secondary">
<span class="selector-trigger-secondary" dir="ltr">
{currentModelValue()!.providerId}/{currentModelValue()!.id}
</span>
)}

View File

@@ -1,4 +1,4 @@
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
import { For, Show, Suspense, createMemo, createSignal, createEffect, lazy, onCleanup, type Component } from "solid-js"
import type { PermissionRequestLike } from "../types/permission"
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
@@ -12,7 +12,8 @@ import {
} from "../stores/instances"
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import ToolCall from "./tool-call"
const LazyToolCall = lazy(() => import("./tool-call"))
interface PermissionApprovalModalProps {
instanceId: string
@@ -408,15 +409,17 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
}
>
{(data) => (
<ToolCall
toolCall={data().toolPart}
toolCallId={data().toolPart.id}
messageId={data().messageId}
messageVersion={data().messageVersion}
partVersion={data().partVersion}
instanceId={props.instanceId}
sessionId={data().sessionId}
/>
<Suspense fallback={<div class="tool-call tool-call-loading" />}>
<LazyToolCall
toolCall={data().toolPart}
toolCallId={data().toolPart.id}
messageId={data().messageId}
messageVersion={data().messageVersion}
partVersion={data().partVersion}
instanceId={props.instanceId}
sessionId={data().sessionId}
/>
</Suspense>
)}
</Show>
</div>

View File

@@ -1,9 +1,9 @@
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker"
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js"
import { ArrowBigUp, ArrowBigDown, Loader2, Mic } from "lucide-solid"
import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances"
import { agents, executeCustomCommand } from "../stores/sessions"
@@ -13,11 +13,42 @@ import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { preferences } from "../stores/preferences"
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
import type { Attachment } from "../types/attachment"
import { usePromptState } from "./prompt-input/usePromptState"
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
import { usePromptPicker } from "./prompt-input/usePromptPicker"
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
const log = getLogger("actions")
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
if (!text || attachments.length === 0) return []
const usedCounters = new Set<string>()
for (const match of text.matchAll(createPastedPlaceholderRegex())) {
const counter = match?.[1]
if (counter) usedCounters.add(counter)
}
if (usedCounters.size === 0) return []
const consumed = new Set<string>()
for (const attachment of attachments) {
if (!attachment?.id) continue
if (attachment?.source?.type !== "text") continue
const display = attachment.display
if (typeof display !== "string") continue
const match = display.match(pastedDisplayCounterRegex)
if (!match?.[1]) continue
if (usedCounters.has(match[1])) {
consumed.add(attachment.id)
}
}
return Array.from(consumed)
}
export default function PromptInput(props: PromptInputProps) {
const { t } = useI18n()
@@ -246,7 +277,12 @@ export default function PromptInput(props: PromptInputProps) {
commandName.length > 0 &&
getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments)
const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
const resolvedPrompt = isKnownSlashCommand
? resolvedCommandArgs
? `${commandToken} ${resolvedCommandArgs}`
: commandToken
: resolvePastedPlaceholders(text, currentAttachments)
const historyEntry = resolvedPrompt
const refreshHistory = () => recordHistoryEntry(historyEntry)
@@ -262,6 +298,10 @@ export default function PromptInput(props: PromptInputProps) {
syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>())
} else {
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
for (const attachmentId of consumedIds) {
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}
syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>())
}
@@ -281,7 +321,7 @@ export default function PromptInput(props: PromptInputProps) {
await props.onSend(resolvedPrompt, [])
}
} else if (isKnownSlashCommand) {
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs)
await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
} else {
await props.onSend(resolvedPrompt, currentAttachments)
}
@@ -411,9 +451,45 @@ export default function PromptInput(props: PromptInputProps) {
})
const shouldShowOverlay = () => prompt().length === 0
const voiceInput = usePromptVoiceInput({
prompt,
setPrompt,
getTextarea: () => textareaRef ?? null,
enabled: () => preferences().showPromptVoiceInput,
disabled: () => Boolean(props.disabled),
})
const showVoiceInput = () =>
preferences().showPromptVoiceInput &&
(voiceInput.canUseVoiceInput() || voiceInput.isRecording() || voiceInput.isTranscribing())
const instance = () => getActiveInstance()
let voiceButtonPressed = false
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
voiceButtonPressed = true
if (event instanceof PointerEvent) {
const target = event.currentTarget
if (target instanceof HTMLElement) {
try {
target.setPointerCapture(event.pointerId)
} catch {
// no-op
}
}
}
void voiceInput.startRecording()
}
const endVoicePress = () => {
if (!voiceButtonPressed) return
voiceButtonPressed = false
voiceInput.stopRecording()
}
return (
<div class="prompt-input-container">
<div
@@ -428,18 +504,20 @@ export default function PromptInput(props: PromptInputProps) {
onDrop={handleDrop}
>
<Show when={showPicker() && instance()}>
<UnifiedPicker
open={showPicker()}
mode={pickerMode()}
onClose={handlePickerClose}
onSelect={handlePickerSelect}
agents={instanceAgents()}
commands={getCommands(props.instanceId)}
instanceClient={instance()!.client}
searchQuery={searchQuery()}
textareaRef={textareaRef}
workspaceId={props.instanceId}
/>
<Suspense fallback={null}>
<LazyUnifiedPicker
open={showPicker()}
mode={pickerMode()}
onClose={handlePickerClose}
onSelect={handlePickerSelect}
agents={instanceAgents()}
commands={getCommands(props.instanceId)}
instanceClient={instance()!.client}
searchQuery={searchQuery()}
textareaRef={textareaRef}
workspaceId={props.instanceId}
/>
</Suspense>
</Show>
<div class="flex flex-1 flex-col">
@@ -449,6 +527,7 @@ export default function PromptInput(props: PromptInputProps) {
<textarea
ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
dir="auto"
placeholder={getPlaceholder()}
value={prompt()}
onInput={handleInput}
@@ -464,10 +543,54 @@ export default function PromptInput(props: PromptInputProps) {
autocomplete="off"
/>
<div class="prompt-nav-buttons">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
<div class="prompt-nav-top-row">
<Show when={showVoiceInput()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
onPointerDown={(event) => {
event.preventDefault()
beginVoicePress(event)
}}
onPointerUp={(event) => {
event.preventDefault()
endVoicePress()
}}
onPointerCancel={() => endVoicePress()}
onLostPointerCapture={() => endVoicePress()}
onKeyDown={(event) => {
if (event.repeat) return
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
beginVoicePress(event)
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
endVoicePress()
}}
onBlur={() => endVoicePress()}
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()}
>
<Show
when={voiceInput.isRecording()}
fallback={
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show>
}
>
<span class="prompt-voice-timer">{formatVoiceTimer(voiceInput.elapsedMs())}</span>
</Show>
</button>
</Show>
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
</div>
<Show when={hasHistory()}>
<button
type="button"
@@ -589,3 +712,10 @@ export default function PromptInput(props: PromptInputProps) {
</div>
)
}
function formatVoiceTimer(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
}

View File

@@ -0,0 +1,244 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import { showAlertDialog } from "../../stores/alerts"
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
import { serverApi } from "../../lib/api-client"
import { useI18n } from "../../lib/i18n"
interface UsePromptVoiceInputOptions {
prompt: Accessor<string>
setPrompt: (value: string) => void
getTextarea: () => HTMLTextAreaElement | null
enabled: Accessor<boolean>
disabled: Accessor<boolean>
}
type VoiceInputState = "idle" | "recording" | "transcribing"
export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
const { t } = useI18n()
const [state, setState] = createSignal<VoiceInputState>("idle")
const [elapsedMs, setElapsedMs] = createSignal(0)
let mediaRecorder: MediaRecorder | null = null
let mediaStream: MediaStream | null = null
let timerId: number | undefined
let shouldTranscribe = true
let recordedChunks: Blob[] = []
let recordingStartedAt = 0
createEffect(() => {
void loadSpeechCapabilities()
})
onCleanup(() => {
cleanupMedia(false)
})
const isSupported = () => {
if (typeof window === "undefined") return false
return typeof window.MediaRecorder !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia)
}
const canUseVoiceInput = () => {
const capabilities = speechCapabilities()
return Boolean(
options.enabled() &&
isSupported() &&
capabilities?.available &&
capabilities?.configured &&
capabilities?.supportsStt,
)
}
async function toggleRecording(): Promise<void> {
if (state() === "recording") {
stopRecording()
return
}
await startRecording()
}
function stopRecording() {
if (!mediaRecorder || state() !== "recording") return
shouldTranscribe = true
mediaRecorder.stop()
setState("transcribing")
stopTimer()
}
function cancelRecording() {
if (!mediaRecorder || state() !== "recording") return
shouldTranscribe = false
mediaRecorder.stop()
cleanupMedia(false)
}
async function startRecording() {
if (!canUseVoiceInput() || options.disabled() || state() === "transcribing" || state() === "recording") return
if (!isSupported()) {
showAlertDialog(t("promptInput.voiceInput.error.unsupported"), {
title: t("promptInput.voiceInput.error.title"),
variant: "error",
})
return
}
try {
recordedChunks = []
shouldTranscribe = true
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = createRecorder(mediaStream)
mediaRecorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data)
}
})
mediaRecorder.addEventListener("stop", () => {
void finalizeRecording()
})
recordingStartedAt = Date.now()
setElapsedMs(0)
setState("recording")
startTimer()
mediaRecorder.start()
} catch (error) {
cleanupMedia(false)
showAlertDialog(t("promptInput.voiceInput.error.permission"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
}
}
async function finalizeRecording() {
const recorder = mediaRecorder
const stream = mediaStream
mediaRecorder = null
mediaStream = null
if (!shouldTranscribe || recordedChunks.length === 0) {
recordedChunks = []
stopTracks(stream)
setState("idle")
setElapsedMs(0)
return
}
const mimeType = recorder?.mimeType || recordedChunks[0]?.type || "audio/webm"
try {
const audioBlob = new Blob(recordedChunks, { type: mimeType })
const transcription = await serverApi.transcribeAudio({
audioBase64: await blobToBase64(audioBlob),
mimeType,
})
if (transcription.text.trim()) {
insertTranscript(transcription.text.trim())
}
} catch (error) {
showAlertDialog(t("promptInput.voiceInput.error.transcribe"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
recordedChunks = []
stopTracks(stream)
setState("idle")
setElapsedMs(0)
}
}
function insertTranscript(text: string) {
const current = options.prompt()
const textarea = options.getTextarea()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const before = current.slice(0, start)
const after = current.slice(end)
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
const nextValue = `${before}${prefix}${text}${suffix}${after}`
const cursor = before.length + prefix.length + text.length
options.setPrompt(nextValue)
if (textarea) {
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(cursor, cursor)
}, 0)
}
}
function cleanupMedia(resetState = true) {
stopTimer()
if (mediaRecorder && mediaRecorder.state !== "inactive") {
mediaRecorder.stop()
}
mediaRecorder = null
stopTracks(mediaStream)
mediaStream = null
recordedChunks = []
if (resetState) {
setState("idle")
setElapsedMs(0)
}
}
function startTimer() {
stopTimer()
timerId = window.setInterval(() => {
setElapsedMs(Date.now() - recordingStartedAt)
}, 250)
}
function stopTimer() {
if (timerId !== undefined) {
window.clearInterval(timerId)
timerId = undefined
}
}
return {
state,
elapsedMs,
canUseVoiceInput,
startRecording,
stopRecording,
toggleRecording,
cancelRecording,
isRecording: () => state() === "recording",
isTranscribing: () => state() === "transcribing",
buttonTitle: () => {
if (state() === "recording") return t("promptInput.voiceInput.stop.title")
if (state() === "transcribing") return t("promptInput.voiceInput.transcribing.title")
return t("promptInput.voiceInput.start.title")
},
}
}
function createRecorder(stream: MediaStream): MediaRecorder {
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]
const supported = candidates.find((candidate) => typeof MediaRecorder.isTypeSupported !== "function" || MediaRecorder.isTypeSupported(candidate))
return supported ? new MediaRecorder(stream, { mimeType: supported }) : new MediaRecorder(stream)
}
function stopTracks(stream: MediaStream | null) {
stream?.getTracks().forEach((track) => track.stop())
}
async function blobToBase64(blob: Blob): Promise<string> {
const buffer = await blob.arrayBuffer()
const bytes = new Uint8Array(buffer)
let binary = ""
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}

View File

@@ -444,7 +444,7 @@ const SessionList: Component<SessionListProps> = (props) => {
</Show>
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
<span class="session-item-title session-item-title--clamp">{title()}</span>
<span class="session-item-title session-item-title--clamp" dir="auto">{title()}</span>
</div>
</div>
<div class="session-item-row session-item-meta">

View File

@@ -76,6 +76,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
inputRef = element
}}
type="text"
dir="auto"
value={title()}
onInput={(event) => setTitle(event.currentTarget.value)}
placeholder={t("sessionRenameDialog.input.placeholder")}

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
import { createMemo, For, type Component } from "solid-js"
import { useI18n } from "../lib/i18n"
import {
@@ -13,6 +13,7 @@ import { AppearanceSettingsSection } from "./settings/appearance-settings-sectio
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section"
export const SettingsScreen: Component = () => {
const { t } = useI18n()
@@ -21,6 +22,7 @@ export const SettingsScreen: Component = () => {
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
])
@@ -30,6 +32,8 @@ export const SettingsScreen: Component = () => {
return <NotificationsSettingsSection />
case "remote":
return <RemoteAccessSettingsSection />
case "speech":
return <SpeechSettingsSection />
case "opencode":
return <OpenCodeSettingsSection />
case "appearance":

View File

@@ -24,6 +24,7 @@ export const AppearanceSettingsSection: Component = () => {
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -38,10 +39,11 @@ export const AppearanceSettingsSection: Component = () => {
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
setDiffViewMode,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,

View File

@@ -0,0 +1,252 @@
import { Show, createEffect, createMemo, createSignal, type Component } from "solid-js"
import { Mic, Volume2 } from "lucide-solid"
import { useConfig, type SpeechSettings } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
import { loadSpeechCapabilities, speechCapabilities, speechCapabilitiesError, speechCapabilitiesLoading } from "../../stores/speech"
import { getLogger } from "../../lib/logger"
const log = getLogger("actions")
type DraftFields = {
apiKey: string
baseUrl: string
sttModel: string
ttsModel: string
ttsVoice: string
}
function createDraftFields(speech: SpeechSettings): DraftFields {
return {
apiKey: "",
baseUrl: speech.baseUrl ?? "",
sttModel: speech.sttModel,
ttsModel: speech.ttsModel,
ttsVoice: speech.ttsVoice,
}
}
function isDraftEqual(a: DraftFields, b: DraftFields): boolean {
return a.apiKey === b.apiKey && a.baseUrl === b.baseUrl && a.sttModel === b.sttModel && a.ttsModel === b.ttsModel && a.ttsVoice === b.ttsVoice
}
export const SpeechSettingsCard: Component = () => {
const { t } = useI18n()
const { serverSettings, updateSpeechSettings } = useConfig()
const initialDrafts = createDraftFields(serverSettings().speech)
const [isSaving, setIsSaving] = createSignal(false)
const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved")
const [drafts, setDrafts] = createSignal<DraftFields>(initialDrafts)
const [apiKeyTouched, setApiKeyTouched] = createSignal(false)
const [clearStoredApiKey, setClearStoredApiKey] = createSignal(false)
createEffect(() => {
const speech = serverSettings().speech
const nextDrafts = createDraftFields(speech)
if (!isSaving() && !isDirty()) {
if (!isDraftEqual(drafts(), nextDrafts)) {
setDrafts(nextDrafts)
}
if (apiKeyTouched()) {
setApiKeyTouched(false)
}
if (clearStoredApiKey()) {
setClearStoredApiKey(false)
}
}
})
createEffect(() => {
void loadSpeechCapabilities()
})
const capabilityLabel = () => {
if (speechCapabilitiesLoading()) return t("settings.speech.status.loading")
if (speechCapabilitiesError()) return t("settings.speech.status.error")
return speechCapabilities()?.configured ? t("settings.speech.status.configured") : t("settings.speech.status.missing")
}
const updateDraft = (key: keyof DraftFields, value: string) => {
setSaveStatus("idle")
if (key === "apiKey") {
setApiKeyTouched(true)
setClearStoredApiKey(false)
}
setDrafts((current) => ({ ...current, [key]: value }))
}
const apiKeyDirty = createMemo(() => clearStoredApiKey() || drafts().apiKey.trim().length > 0)
const isDirty = createMemo(() => {
const speech = serverSettings().speech
const current = drafts()
return (
apiKeyDirty() ||
(current.baseUrl || "") !== (speech.baseUrl || "") ||
current.sttModel !== speech.sttModel ||
current.ttsModel !== speech.ttsModel ||
current.ttsVoice !== speech.ttsVoice
)
})
const saveStatusLabel = () => {
if (isSaving()) return t("settings.speech.save.saving")
if (saveStatus() === "saved") return t("settings.speech.save.saved")
if (saveStatus() === "error") return t("settings.speech.save.error")
return t("settings.speech.save.unsaved")
}
async function handleSave() {
if (!isDirty() || isSaving()) return
const current = drafts()
setIsSaving(true)
setSaveStatus("idle")
try {
const trimmedApiKey = current.apiKey.trim()
await updateSpeechSettings({
...(clearStoredApiKey() ? { apiKey: null } : trimmedApiKey ? { apiKey: trimmedApiKey } : {}),
baseUrl: current.baseUrl.trim() || undefined,
sttModel: current.sttModel.trim() || undefined,
ttsModel: current.ttsModel.trim() || undefined,
ttsVoice: current.ttsVoice.trim() || undefined,
})
await loadSpeechCapabilities(true)
setDrafts({
apiKey: "",
baseUrl: current.baseUrl.trim(),
sttModel: current.sttModel.trim() || serverSettings().speech.sttModel,
ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel,
ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice,
})
setApiKeyTouched(false)
setClearStoredApiKey(false)
setSaveStatus("saved")
} catch (error) {
log.error("Failed to save speech settings", error)
setSaveStatus("error")
} finally {
setIsSaving(false)
}
}
return (
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Volume2 class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.speech.title")}</h3>
<p class="settings-card-subtitle">{t("settings.speech.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-stack">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("settings.speech.provider.title")}</div>
<div class="settings-toggle-caption">{t("settings.speech.provider.subtitle")}</div>
</div>
<div class="settings-toolbar-inline">
<span class="settings-inline-note">{t("settings.speech.provider.openaiCompatible")}</span>
<span class="settings-inline-note">{capabilityLabel()}</span>
<span class="settings-inline-note">{saveStatusLabel()}</span>
<button
type="button"
class="selector-button selector-button-primary w-auto whitespace-nowrap"
onClick={() => void handleSave()}
disabled={!isDirty() || isSaving()}
>
{isSaving() ? t("settings.speech.save.saving") : t("settings.speech.save.action")}
</button>
</div>
</div>
<Field
label={t("settings.speech.apiKey.title")}
caption={t("settings.speech.apiKey.subtitle")}
value={drafts().apiKey}
onInput={(value) => updateDraft("apiKey", value)}
type="password"
placeholder={serverSettings().speech.hasApiKey ? t("settings.speech.apiKey.placeholder") : undefined}
/>
<Show when={serverSettings().speech.hasApiKey && !apiKeyTouched() && drafts().apiKey.length === 0}>
<div class="settings-inline-note">
{clearStoredApiKey() ? t("settings.speech.apiKey.clearPending") : t("settings.speech.apiKey.storedNote")}{" "}
<Show when={!clearStoredApiKey()}>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
onClick={() => {
setClearStoredApiKey(true)
setSaveStatus("idle")
}}
>
{t("settings.speech.apiKey.clearAction")}
</button>
</Show>
</div>
</Show>
<Field
label={t("settings.speech.baseUrl.title")}
caption={t("settings.speech.baseUrl.subtitle")}
value={drafts().baseUrl}
onInput={(value) => updateDraft("baseUrl", value)}
placeholder={t("settings.speech.baseUrl.placeholder")}
/>
<Field
label={t("settings.speech.sttModel.title")}
caption={t("settings.speech.sttModel.subtitle")}
value={drafts().sttModel}
onInput={(value) => updateDraft("sttModel", value)}
/>
<Field
label={t("settings.speech.ttsModel.title")}
caption={t("settings.speech.ttsModel.subtitle")}
value={drafts().ttsModel}
onInput={(value) => updateDraft("ttsModel", value)}
/>
<Field
label={t("settings.speech.ttsVoice.title")}
caption={t("settings.speech.ttsVoice.subtitle")}
value={drafts().ttsVoice}
onInput={(value) => updateDraft("ttsVoice", value)}
icon={<Mic class="w-3.5 h-3.5 icon-muted flex-shrink-0" />}
/>
<div class="settings-inline-note">{t("settings.speech.help")}</div>
</div>
</div>
)
}
const Field: Component<{
label: string
caption: string
value: string
type?: string
placeholder?: string
onInput: (value: string) => void
icon?: any
}> = (props) => {
return (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div>
</div>
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full">
{props.icon}
<input
type={props.type ?? "text"}
value={props.value}
onInput={(event) => props.onInput(event.currentTarget.value)}
class="selector-input w-full"
placeholder={props.placeholder}
/>
</div>
</div>
)
}
export default SpeechSettingsCard

View File

@@ -0,0 +1,10 @@
import type { Component } from "solid-js"
import SpeechSettingsCard from "./speech-settings-card"
export const SpeechSettingsSection: Component = () => {
return (
<div class="settings-section-stack">
<SpeechSettingsCard />
</div>
)
}

View File

@@ -514,6 +514,7 @@ function ToolCallDetails(props: {
})
const { renderDiffContent } = createDiffContentRenderer({
toolState: props.toolState,
preferences: props.preferences,
setDiffViewMode: props.setDiffViewMode,
isDark: props.isDark,

View File

@@ -1,7 +1,7 @@
import type { Accessor, JSXElement } from "solid-js"
import type { RenderCache } from "../../types/message"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
import { escapeHtml } from "../../lib/markdown"
import { escapeHtml } from "../../lib/text-render-utils"
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
@@ -20,6 +20,14 @@ export function createAnsiContentRenderer(params: {
const runningAnsiRenderer = createAnsiStreamRenderer()
let runningAnsiSource = ""
const registerTracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element)
}
const registerUntracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element, { disableTracking: true })
}
const getMode = () => {
const version = params.partVersion?.()
return typeof version === "number" ? String(version) : undefined
@@ -36,6 +44,8 @@ export function createAnsiContentRenderer(params: {
const cached = cacheHandle.get<AnsiRenderCache>()
const mode = getMode()
const isRunningVariant = options.variant === "running"
const disableScrollTracking = !isRunningVariant
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
let nextCache: AnsiRenderCache
@@ -87,9 +97,9 @@ export function createAnsiContentRenderer(params: {
}
return (
<div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
{params.scrollHelpers.renderSentinel()}
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div>
)
}

View File

@@ -42,7 +42,7 @@ export function renderDiagnosticsSection(
{entry.displayPath}
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
</span>
<span class="tool-call-diagnostic-message">{entry.message}</span>
<span class="tool-call-diagnostic-message" dir="auto">{entry.message}</span>
</div>
)}
</For>

View File

@@ -1,11 +1,27 @@
import type { Accessor, JSXElement } from "solid-js"
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { RenderCache } from "../../types/message"
import type { DiffViewMode } from "../../stores/preferences"
import { ToolCallDiffViewer } from "../diff-viewer"
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
import { getRelativePath } from "./utils"
import { getCacheEntry } from "../../lib/global-cache"
const LazyToolCallDiffViewer = lazy(() =>
import("../diff-viewer").then((module) => ({ default: module.ToolCallDiffViewer })),
)
function CachedDiffMarkup(props: { html: string; onRendered?: () => void }) {
onMount(() => {
props.onRendered?.()
})
return (
<div class="tool-call-diff-viewer">
<div innerHTML={props.html} />
</div>
)
}
type CacheHandle = {
get<T>(): T | undefined
params(): unknown
@@ -16,6 +32,7 @@ type DiffPrefs = {
}
export function createDiffContentRenderer(params: {
toolState: Accessor<ToolState | undefined>
preferences: Accessor<DiffPrefs>
setDiffViewMode: (mode: DiffViewMode) => void
isDark: Accessor<boolean>
@@ -43,7 +60,10 @@ export function createDiffContentRenderer(params: {
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
const themeKey = params.isDark() ? "dark" : "light"
const disableScrollTracking = Boolean(options?.disableScrollTracking)
const state = params.toolState()
const disableScrollTracking = Boolean(
options?.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending"),
)
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
const baseEntryParams = cacheHandle.params() as any
@@ -101,15 +121,20 @@ export function createDiffContentRenderer(params: {
</button>
</div>
</div>
<ToolCallDiffViewer
diffText={payload.diffText}
filePath={payload.filePath}
theme={themeKey}
mode={diffMode()}
cachedHtml={cachedHtml}
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>
{cachedHtml ? (
<CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
) : (
<Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
<LazyToolCallDiffViewer
diffText={payload.diffText}
filePath={payload.filePath}
theme={themeKey}
mode={diffMode()}
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>
</Suspense>
)}
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div>
)

View File

@@ -31,10 +31,9 @@ export function createMarkdownContentRenderer(params: {
const size = options.size || "default"
const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const disableScrollTracking = options.disableScrollTracking || false
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
const state = params.toolState()
const disableScrollTracking = options.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending")
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
if (shouldDeferMarkdown) {
return (
@@ -43,7 +42,7 @@ export function createMarkdownContentRenderer(params: {
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
<pre class="whitespace-pre-wrap break-words text-sm font-mono" dir="auto">{options.content}</pre>
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div>
)

View File

@@ -1,5 +1,5 @@
import { isRenderableDiffText } from "../../lib/diff-utils"
import { getLanguageFromPath } from "../../lib/markdown"
import { getLanguageFromPath } from "../../lib/text-render-utils"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { DiffPayload } from "./types"
import { getLogger } from "../../lib/logger"

View File

@@ -1,5 +1,5 @@
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item"
import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX, on } from "solid-js"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const USER_SCROLL_INTENT_WINDOW_MS = 600
@@ -122,55 +122,28 @@ export interface VirtualFollowListProps<T> {
}
export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const getAnchorId = (key: string) => (props.getAnchorId ? props.getAnchorId(key) : key)
const getKeyFromAnchorId = (anchorId: string) => (props.getKeyFromAnchorId ? props.getKeyFromAnchorId(anchorId) : anchorId)
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
const bottomSentinel = () => bottomSentinelSignal()
const [virtuaHandle, setVirtuaHandle] = createSignal<VirtualizerHandle | undefined>()
const isActive = () => (props.isActive ? props.isActive() : true)
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
const isLoading = () => Boolean(props.loading?.())
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
const [activeKey, setActiveKey] = createSignal<string | null>(null)
const [anchorLock, setAnchorLock] = createSignal<{ key: string; block: ScrollLogicalPosition } | null>(null)
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
let containerRef: HTMLDivElement | undefined
let shellRef: HTMLDivElement | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let pendingAnchorCorrectionFrame: number | null = null
let pendingScrollCompensationScheduled = false
let pendingScrollCompensations = new Map<string, number>()
let scrollCompensationGen = 0
let pendingActiveScroll = false
let userScrollIntentUntil = 0
let lastUserScrollIntentDirection: "up" | "down" | null = null
let detachScrollIntentListeners: (() => void) | undefined
let lastResetKey: string | number | undefined
let suppressAutoScrollOnce = false
let pendingInitialScroll = true
let scrollToBottomFrame: number | null = null
let scrollToBottomDelayedFrame: number | null = null
let lastKnownScrollTop = 0
let lastUserScrollIntentDirection: "up" | "down" | null = null
let userScrollIntentUntil = 0
let detachScrollIntentListeners: (() => void) | undefined
let lastResetKey: string | number | undefined
const state: VirtualFollowListState = {
autoScroll,
@@ -181,7 +154,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
function markUserScrollIntent(direction?: "up" | "down" | null) {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
const now = performance.now()
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
if (direction) {
lastUserScrollIntentDirection = direction
@@ -189,8 +162,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
function hasUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
return performance.now() <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
@@ -231,670 +203,189 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
}
function updateScrollIndicatorsFromVisibility() {
function updateScrollButtons() {
const handle = virtuaHandle()
const element = scrollElement()
if (!handle || !element) return
const offset = handle.scrollOffset
const scrollHeight = handle.scrollSize
const clientHeight = element.clientHeight
const atBottom = scrollHeight - (offset + clientHeight) <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
const atTop = offset <= (props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX)
const hasItems = props.items().length > 0
const bottomVisible = bottomSentinelVisible()
const topVisible = topSentinelVisible()
setShowScrollBottomButton(hasItems && !bottomVisible)
setShowScrollTopButton(hasItems && !topVisible)
}
setShowScrollBottomButton(hasItems && !atBottom)
setShowScrollTopButton(hasItems && !atTop)
function clearScrollToBottomFrames() {
if (scrollToBottomFrame !== null) {
cancelAnimationFrame(scrollToBottomFrame)
scrollToBottomFrame = null
}
if (scrollToBottomDelayedFrame !== null) {
cancelAnimationFrame(scrollToBottomDelayedFrame)
scrollToBottomDelayedFrame = null
// Sync autoScroll state based on scroll position if it was a user scroll
if (hasUserScrollIntent()) {
if (atBottom && !autoScroll()) {
setAutoScroll(true)
} else if (!atBottom && autoScroll()) {
setAutoScroll(false)
}
}
}
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
if (!containerRef) return
if (anchorLock()) {
clearAnchorLock()
}
const sentinel = bottomSentinel()
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
if (suppressAutoAnchor) {
function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
const handle = virtuaHandle()
if (!handle) return
if (options?.suppressAutoAnchor ?? !immediate) {
suppressAutoScrollOnce = true
}
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
handle.scrollToIndex(props.items().length - 1, { align: "end", smooth: !immediate })
setAutoScroll(true)
}
function requestScrollToBottom(immediate = true) {
if (!isActive()) {
pendingActiveScroll = true
return
}
if (!containerRef || !bottomSentinel()) {
pendingActiveScroll = true
return
}
pendingActiveScroll = false
clearScrollToBottomFrames()
scrollToBottomFrame = requestAnimationFrame(() => {
scrollToBottomFrame = null
scrollToBottomDelayedFrame = requestAnimationFrame(() => {
scrollToBottomDelayedFrame = null
scrollToBottom(immediate)
})
})
}
function resolvePendingActiveScroll() {
if (!pendingActiveScroll) return
if (!isActive()) return
requestScrollToBottom(true)
}
function scrollToTop(immediate = false) {
if (!containerRef) return
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
if (anchorLock()) {
clearAnchorLock()
}
function scrollToTop(immediate = true) {
const handle = virtuaHandle()
if (!handle) return
handle.scrollToIndex(0, { align: "start", smooth: !immediate })
setAutoScroll(false)
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
if (!isActive()) {
pendingActiveScroll = true
return
}
const sentinel = bottomSentinel()
if (!sentinel) {
pendingActiveScroll = true
return
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" })
})
}
function clearAnchorLock() {
setAnchorLock(null)
if (pendingAnchorCorrectionFrame !== null) {
cancelAnimationFrame(pendingAnchorCorrectionFrame)
pendingAnchorCorrectionFrame = null
}
}
function computeDesiredOffset(block: ScrollLogicalPosition, container: HTMLElement, anchorRect: DOMRect) {
if (block === "end") {
return Math.max(0, container.clientHeight - anchorRect.height)
}
if (block === "center") {
return Math.max(0, container.clientHeight / 2 - anchorRect.height / 2)
}
// Default to start.
return 0
}
function applyAnchorCorrection() {
const lock = anchorLock()
if (!lock) return
if (autoScroll()) return
if (!containerRef) return
if (typeof document === "undefined") return
const anchorId = getAnchorId(lock.key)
const anchor = document.getElementById(anchorId)
if (!anchor) return
const containerRect = containerRef.getBoundingClientRect()
const anchorRect = anchor.getBoundingClientRect()
const currentOffset = anchorRect.top - containerRect.top
const desiredOffset = computeDesiredOffset(lock.block, containerRef, anchorRect)
const delta = currentOffset - desiredOffset
if (!Number.isFinite(delta) || Math.abs(delta) < 0.5) {
return
}
const nextTop = containerRef.scrollTop + delta
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
containerRef.scrollTop = Math.min(maxScrollTop, Math.max(0, nextTop))
}
function scheduleAnchorCorrection() {
if (pendingAnchorCorrectionFrame !== null) return
pendingAnchorCorrectionFrame = requestAnimationFrame(() => {
pendingAnchorCorrectionFrame = null
applyAnchorCorrection()
})
}
function handleContentRendered() {
if (autoScroll() && !anchorLock()) {
scheduleAutoPinToBottom()
return
}
if (anchorLock() && !autoScroll()) {
scheduleAnchorCorrection()
return
}
}
function handleScroll() {
if (!containerRef) return
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
const isUserScroll = hasUserScrollIntent()
pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null
if (!containerRef) return
const previousScrollTop = lastKnownScrollTop
const currentScrollTop = containerRef.scrollTop
const deltaScrollTop = currentScrollTop - previousScrollTop
if (currentScrollTop !== lastKnownScrollTop) {
lastKnownScrollTop = currentScrollTop
if (isUserScroll) {
if (lastUserScrollIntentDirection === "up" && autoScroll()) {
setAutoScroll(false)
}
const atBottom = bottomSentinelVisible()
}
updateScrollButtons()
props.onScroll?.()
const beforeAutoScroll = autoScroll()
const inferredDirection: "up" | "down" | null =
lastUserScrollIntentDirection ?? (deltaScrollTop < 0 ? "up" : deltaScrollTop > 0 ? "down" : null)
// If the user scrolls manually, exit key-anchored mode.
if (isUserScroll && anchorLock()) {
clearAnchorLock()
}
if (isUserScroll) {
// If the user is actively scrolling upward, exit follow-to-bottom mode
// immediately. The bottom sentinel can remain "visible" for a short
// distance due to its observer margin, which otherwise keeps autoScroll
// enabled and makes the list feel stuck.
if (inferredDirection === "up" && deltaScrollTop < -0.5 && autoScroll()) {
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
setAutoScroll(false)
}
// Do not re-enable follow mode while the user's current scroll intent
// is upward. This prevents transient anchor/pin scrolls from pulling
// the list back into autoScroll(true).
if (inferredDirection !== "up") {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
} else if (!atBottom && autoScroll()) {
// If the user is scrolling up and we are no longer at the bottom,
// ensure follow mode is disabled.
setAutoScroll(false)
// Find active key (roughly the first visible item)
const handle = virtuaHandle()
if (handle) {
const start = handle.findItemIndex(handle.scrollOffset)
const items = props.items()
if (items[start]) {
const key = props.getKey(items[start], start)
if (key !== activeKey()) {
setActiveKey(key)
props.onActiveKeyChange?.(key)
}
}
props.onScroll?.()
})
}
function setContainerRef(element: HTMLDivElement | null) {
containerRef = element || undefined
setScrollElement(containerRef)
props.onScrollElementChange?.(containerRef)
attachScrollIntentListeners(containerRef)
lastKnownScrollTop = containerRef?.scrollTop ?? 0
lastUserScrollIntentDirection = null
if (!containerRef) {
return
}
resolvePendingActiveScroll()
}
function scheduleScrollCompensation(key: string, delta: number) {
if (!containerRef) return
if (!delta || !Number.isFinite(delta)) return
if (typeof document === "undefined") return
// Only compensate while the user scrolls upward (testing default).
if (!hasUserScrollIntent() || lastUserScrollIntentDirection !== "up") return
if (autoScroll() || anchorLock()) return
const anchorId = getAnchorId(key)
const anchor = document.getElementById(anchorId)
if (!anchor) return
const containerRect = containerRef.getBoundingClientRect()
const rect = anchor.getBoundingClientRect()
// Determine whether the item was fully above the viewport *before* the
// height delta applied. Items can expand downward into the viewport; in that
// case we still need to compensate to keep existing visible content stable.
const bottomAfter = rect.bottom
const bottomBefore = bottomAfter - delta
const wasAboveViewport = bottomBefore < containerRect.top
if (!wasAboveViewport) return
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
pendingScrollCompensations.set(key, next)
if (pendingScrollCompensationScheduled) return
pendingScrollCompensationScheduled = true
const gen = scrollCompensationGen
// Flush in a microtask so compensation lands before the next paint.
queueMicrotask(() => {
if (gen !== scrollCompensationGen) return
pendingScrollCompensationScheduled = false
if (!containerRef) return
if (!hasUserScrollIntent() || lastUserScrollIntentDirection !== "up") {
pendingScrollCompensations = new Map()
return
}
if (autoScroll() || anchorLock()) {
pendingScrollCompensations = new Map()
return
}
let applied = 0
let count = 0
for (const pendingDelta of pendingScrollCompensations.values()) {
if (!pendingDelta) continue
applied += pendingDelta
count += 1
}
pendingScrollCompensations = new Map()
if (!applied) return
const before = containerRef.scrollTop
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
const nextTop = Math.min(maxScrollTop, Math.max(0, before + applied))
if (nextTop !== before) {
containerRef.scrollTop = nextTop
lastKnownScrollTop = nextTop
}
})
}
let pendingAutoPin = false
let pendingAutoPinFrame: number | null = null
function clearPendingAutoPinFrame() {
if (pendingAutoPinFrame !== null) {
cancelAnimationFrame(pendingAutoPinFrame)
pendingAutoPinFrame = null
}
}
function applyAutoPinToBottom() {
if (!containerRef) return false
if (!autoScroll()) return false
if (anchorLock()) return false
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
if (containerRef.scrollTop !== maxScrollTop) {
containerRef.scrollTop = maxScrollTop
lastKnownScrollTop = maxScrollTop
}
return true
}
function scheduleAutoPinToBottom() {
if (!containerRef) return
if (pendingAutoPin) return
pendingAutoPin = true
clearPendingAutoPinFrame()
const gen = scrollCompensationGen
// Flush in a microtask so adjustments land before the next paint,
// then re-apply on the next two frames to catch deferred layout.
queueMicrotask(() => {
if (gen !== scrollCompensationGen) return
pendingAutoPin = false
if (!applyAutoPinToBottom()) return
pendingAutoPinFrame = requestAnimationFrame(() => {
pendingAutoPinFrame = null
if (gen !== scrollCompensationGen) return
if (!applyAutoPinToBottom()) return
pendingAutoPinFrame = requestAnimationFrame(() => {
pendingAutoPinFrame = null
if (gen !== scrollCompensationGen) return
applyAutoPinToBottom()
})
})
})
}
function setShellRef(element: HTMLDivElement | null) {
shellRef = element || undefined
setShellElement(shellRef)
props.onShellElementChange?.(shellRef)
}
function setBottomSentinel(element: HTMLDivElement | null) {
setBottomSentinelSignal(element)
resolvePendingActiveScroll()
}
const api: VirtualFollowListApi = {
scrollToTop: (opts) => scrollToTop(Boolean(opts?.immediate)),
scrollToBottom: (opts) => scrollToBottom(Boolean(opts?.immediate), { suppressAutoAnchor: opts?.suppressAutoAnchor }),
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
scrollToKey: (key, opts) => {
if (typeof document === "undefined") return
const anchorId = getAnchorId(key)
const behavior = opts?.behavior ?? "smooth"
const block = opts?.block ?? "start"
const index = props.items().findIndex((item, i) => props.getKey(item, i) === key)
if (index === -1) return
const nextAutoScroll = opts?.setAutoScroll ?? false
setAutoScroll(nextAutoScroll)
if (!nextAutoScroll) {
if (anchorLock()) {
clearAnchorLock()
}
setAnchorLock({ key, block })
} else {
if (anchorLock()) {
clearAnchorLock()
}
}
const first = document.getElementById(anchorId)
first?.scrollIntoView({ block, behavior })
// When using virtualization, the placeholder height can be stale until the
// item mounts/measures. Re-run scrollIntoView() on the next frame to
// stabilize the final position.
requestAnimationFrame(() => {
const second = document.getElementById(anchorId)
second?.scrollIntoView({ block, behavior })
})
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
},
notifyContentRendered: () => {
if (autoScroll()) {
scrollToBottom(true)
}
},
notifyContentRendered: () => handleContentRendered(),
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
getAutoScroll: () => autoScroll(),
getScrollElement: () => scrollElement(),
getShellElement: () => shellElement(),
}
createEffect(() => {
props.registerApi?.(api)
})
createEffect(() => props.registerApi?.(api))
createEffect(() => props.registerState?.(state))
createEffect(() => {
props.registerState?.(state)
})
createEffect(() => {
const nextKey = props.resetKey?.()
if (nextKey === undefined) return
if (lastResetKey === undefined) {
lastResetKey = nextKey
return
// Handle autoScroll (Follow) on items change
createEffect(on(() => props.items().length, (len, prevLen) => {
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
requestAnimationFrame(() => scrollToBottom(true))
}
suppressAutoScrollOnce = false
}, { defer: true }))
// Handle followToken change
createEffect(on(() => props.followToken?.(), () => {
if (autoScroll()) {
scrollToBottom(true)
}
}, { defer: true }))
// Reset state on resetKey change
createEffect(on(() => props.resetKey?.(), (nextKey) => {
if (nextKey === lastResetKey) return
lastResetKey = nextKey
// Reset internal state when consumers swap datasets (e.g. session switch).
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (pendingAnchorCorrectionFrame !== null) {
cancelAnimationFrame(pendingAnchorCorrectionFrame)
pendingAnchorCorrectionFrame = null
}
clearScrollToBottomFrames()
scrollCompensationGen += 1
pendingScrollCompensationScheduled = false
pendingScrollCompensations = new Map()
pendingAutoPin = false
clearPendingAutoPinFrame()
suppressAutoScrollOnce = false
pendingActiveScroll = false
setAutoScroll(initialAutoScroll())
pendingInitialScroll = true
}))
setAnchorLock(null)
setActiveKey(null)
setShowScrollTopButton(false)
setShowScrollBottomButton(false)
setTopSentinelVisible(true)
setBottomSentinelVisible(true)
setAutoScroll(Boolean(initialAutoScroll()))
lastKnownScrollTop = containerRef?.scrollTop ?? 0
lastUserScrollIntentDirection = null
})
let lastActiveState = false
// Initial scroll and session activation
createEffect(() => {
const active = isActive()
if (active) {
resolvePendingActiveScroll()
if (!lastActiveState && autoScroll() && scrollToBottomOnActivate()) {
requestScrollToBottom(true)
// When switching back to a cached session pane, items can mount/measure
// after the initial scroll jump. Re-pin once layout settles so the
// viewport stays at the bottom.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
scheduleAutoPinToBottom()
})
})
if (!active) return
if (pendingInitialScroll && props.items().length > 0) {
pendingInitialScroll = false
if (initialScrollToBottom()) {
scrollToBottom(true)
}
} else if (autoScroll() && scrollToBottomOnActivate()) {
pendingActiveScroll = true
}
lastActiveState = active
})
createEffect(() => {
const loading = isLoading()
if (loading) {
// Keep the initial scroll pending while loading so we can
// anchor to the bottom as soon as items appear.
pendingInitialScroll = true
}
if (!pendingInitialScroll) return
const container = scrollElement()
const sentinel = bottomSentinel()
if (!container || !sentinel || props.items().length === 0) return
if (!initialScrollToBottom()) {
// An outer component is responsible for restoring scroll.
pendingInitialScroll = false
return
}
// Ensure we're in follow-to-bottom mode for the initial position.
if (anchorLock()) {
clearAnchorLock()
}
setAutoScroll(true)
pendingInitialScroll = false
// Scroll synchronously so the first paint prefers bottom content.
scrollToBottom(true)
})
let previousFollowToken: string | number | undefined
createEffect(() => {
const token = props.followToken?.()
if (token === undefined) {
previousFollowToken = token
return
}
if (previousFollowToken === undefined) {
previousFollowToken = token
return
}
if (token === previousFollowToken) {
return
}
previousFollowToken = token
if (suppressAutoScrollOnce) {
suppressAutoScrollOnce = false
return
}
if (autoScroll()) {
scheduleAutoPinToBottom()
return
}
if (anchorLock() && !autoScroll()) {
scheduleAnchorCorrection()
scrollToBottom(true)
}
})
// Drop anchor lock if the anchored key is removed.
createEffect(() => {
const lock = anchorLock()
if (!lock) return
const keys = props.items().map((item, idx) => props.getKey(item, idx))
if (!keys.includes(lock.key)) {
clearAnchorLock()
}
})
return (
<div class="virtual-follow-list-shell" ref={shellElement => {
setShellElement(shellElement)
props.onShellElementChange?.(shellElement)
}}>
<div
class="message-stream"
ref={el => {
setScrollElement(el)
props.onScrollElementChange?.(el)
attachScrollIntentListeners(el)
}}
onMouseUp={props.onMouseUp}
onClick={props.onClick}
>
<Show when={props.renderBeforeItems}>
{props.renderBeforeItems!()}
</Show>
<Virtualizer
ref={setVirtuaHandle}
scrollRef={scrollElement()}
data={props.items()}
bufferSize={props.overscanPx ?? 400}
onScroll={handleScroll}
>
{(item, index) => props.renderItem(item, index())}
</Virtualizer>
</div>
createEffect(() => {
if (props.items().length === 0) {
setShowScrollTopButton(false)
setShowScrollBottomButton(false)
setAutoScroll(true)
return
}
updateScrollIndicatorsFromVisibility()
})
<Show when={props.renderOverlay}>
<div class="virtual-follow-list-overlay">{props.renderOverlay!()}</div>
</Show>
createEffect(() => {
const container = scrollElement()
const topTarget = topSentinel()
const bottomTarget = bottomSentinel()
if (!container || !topTarget || !bottomTarget) return
if (typeof IntersectionObserver === "undefined") return
<Show when={props.renderControls}>
<div class="virtual-follow-list-controls-container">{props.renderControls!(state, api)}</div>
</Show>
const margin = props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX
const observer = new IntersectionObserver(
(entries) => {
let visibilityChanged = false
for (const entry of entries) {
if (entry.target === topTarget) {
setTopSentinelVisible(entry.isIntersecting)
visibilityChanged = true
} else if (entry.target === bottomTarget) {
setBottomSentinelVisible(entry.isIntersecting)
visibilityChanged = true
}
<Show
when={
!props.renderControls &&
(showScrollTopButton() || showScrollBottomButton()) &&
props.scrollToTopAriaLabel &&
props.scrollToBottomAriaLabel
}
if (visibilityChanged) {
updateScrollIndicatorsFromVisibility()
}
},
{ root: container, threshold: 0, rootMargin: `${margin}px 0px ${margin}px 0px` },
)
observer.observe(topTarget)
observer.observe(bottomTarget)
onCleanup(() => observer.disconnect())
})
createEffect(() => {
const container = scrollElement()
const items = props.items()
if (!container || items.length === 0) return
if (typeof document === "undefined") return
if (typeof IntersectionObserver === "undefined") return
const observer = new IntersectionObserver(
(entries) => {
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) {
best = entry
}
}
if (best) {
const anchorId = (best.target as HTMLElement).id
const key = getKeyFromAnchorId(anchorId)
setActiveKey((current) => (current === key ? current : key))
}
},
{ root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 },
)
const anchorIds = items.map((item, idx) => getAnchorId(props.getKey(item, idx)))
anchorIds.forEach((anchorId) => {
const anchor = document.getElementById(anchorId)
if (anchor) observer.observe(anchor)
})
onCleanup(() => observer.disconnect())
})
createEffect(() => {
const key = activeKey()
props.onActiveKeyChange?.(key)
})
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
}
if (pendingAnchorCorrectionFrame !== null) {
cancelAnimationFrame(pendingAnchorCorrectionFrame)
}
scrollCompensationGen += 1
pendingScrollCompensationScheduled = false
pendingScrollCompensations = new Map()
clearPendingAutoPinFrame()
clearScrollToBottomFrames()
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
}
})
const controls = () => {
if (props.renderControls) {
return props.renderControls(state, api)
}
// Avoid hardcoded user-visible strings; require consumers to supply
// localized aria labels when using the default controls.
if (!props.scrollToTopAriaLabel || !props.scrollToBottomAriaLabel) {
return null
}
const labelTop = props.scrollToTopAriaLabel()
const labelBottom = props.scrollToBottomAriaLabel()
return (
<Show when={showScrollTopButton() || showScrollBottomButton()}>
>
<div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}>
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={labelTop}>
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={props.scrollToTopAriaLabel!()}>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
<Show when={showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
aria-label={labelBottom}
>
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label={props.scrollToBottomAriaLabel!()}>
<span class="message-scroll-icon" aria-hidden="true">
</span>
@@ -902,71 +393,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
</Show>
</div>
</Show>
)
}
return (
<div class="message-stream-shell" ref={setShellRef}>
<div
class="message-stream"
ref={setContainerRef}
onScroll={handleScroll}
onMouseUp={(event) => props.onMouseUp?.(event)}
onClick={(event) => props.onClick?.(event)}
>
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
{props.renderBeforeItems?.()}
<Index each={props.items()}>
{(item, index) => {
const key = () => props.getKey(item(), index)
const anchorId = () => getAnchorId(key())
const overscanPx = props.overscanPx ?? 800
const suspendMeasurements = () => measurementsSuspended() || !isActive()
const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll()
return (
<VirtualItem
id={anchorId()}
cacheKey={key()}
scrollContainer={scrollElement}
threshold={overscanPx}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={itemVirtualizationEnabled}
suspendMeasurements={suspendMeasurements}
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
const delta = nextHeight - previousHeight
// Follow mode: keep the viewport pinned to the bottom as
// items mount/measure and change height.
if (delta && autoScroll() && !anchorLock()) {
scheduleAutoPinToBottom()
return
}
// Key-anchored mode: keep the target key in view when
// items above it mount/measure and shift layout.
if (anchorLock() && !autoScroll()) {
scheduleAnchorCorrection()
return
}
// Free-scroll mode: if items above the viewport change height
// while scrolling upward, compensate scrollTop so visible
// content stays stable.
if (delta) {
if (meta.isStaleCacheCorrection) return
scheduleScrollCompensation(key(), delta)
}
}}
>{() => props.renderItem(item(), index)}</VirtualItem>
)
}}
</Index>
<div ref={setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
</div>
{controls()}
{props.renderOverlay?.()}
</div>
)
}

View File

@@ -1,492 +0,0 @@
import { JSX, Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
const sizeCache = new Map<string, number>()
const DEFAULT_MARGIN_PX = 600
const MIN_PLACEHOLDER_HEIGHT = 400
const VISIBILITY_BUFFER_PX = 0
type ObserverRoot = Element | Document | null
type IntersectionCallback = (entry: IntersectionObserverEntry) => void
interface SharedObserver {
observer: IntersectionObserver
listeners: Map<Element, Set<IntersectionCallback>>
}
const NULL_ROOT_KEY = "__null__"
const rootIds = new WeakMap<Element | Document, number>()
let sharedRootId = 0
const sharedObservers = new Map<string, SharedObserver>()
function getRootKey(root: ObserverRoot, margin: number): string {
if (!root) {
return `${NULL_ROOT_KEY}:${margin}`
}
let id = rootIds.get(root)
if (id === undefined) {
id = ++sharedRootId
rootIds.set(root, id)
}
return `${id}:${margin}`
}
function createSharedObserver(root: ObserverRoot, margin: number): SharedObserver {
const listeners = new Map<Element, Set<IntersectionCallback>>()
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const callbacks = listeners.get(entry.target as Element)
if (!callbacks) return
callbacks.forEach((fn) => fn(entry))
})
},
{
root: root ?? undefined,
rootMargin: `${margin}px 0px ${margin}px 0px`,
},
)
return { observer, listeners }
}
function shouldRenderEntry(entry: IntersectionObserverEntry) {
const rootBounds = entry.rootBounds
if (!rootBounds) {
return entry.isIntersecting
}
// Above the root: compare bottom edge to root top.
if (entry.boundingClientRect.bottom < rootBounds.top) {
const distance = rootBounds.top - entry.boundingClientRect.bottom
return distance <= VISIBILITY_BUFFER_PX
}
// Below the root: compare top edge to root bottom.
if (entry.boundingClientRect.top > rootBounds.bottom) {
const distance = entry.boundingClientRect.top - rootBounds.bottom
return distance <= VISIBILITY_BUFFER_PX
}
// Overlapping the root bounds.
return true
}
function getViewportRect(): { top: number; bottom: number } {
if (typeof window === "undefined") {
return { top: 0, bottom: 0 }
}
return { top: 0, bottom: window.innerHeight }
}
function isRenderableRoot(root: ObserverRoot): boolean {
if (!root) return true
if (root instanceof Document) return true
if (typeof window === "undefined") return false
const element = root as Element
const style = window.getComputedStyle(element as Element)
if (style.display === "none" || style.visibility === "hidden") {
return false
}
const rect = (element as Element).getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}
function shouldRenderByRects(params: {
wrapperRect: DOMRect
rootRect: { top: number; bottom: number }
margin: number
}): boolean {
const { wrapperRect, rootRect, margin } = params
const threshold = margin + VISIBILITY_BUFFER_PX
// Above the root: compare bottom edge to root top.
if (wrapperRect.bottom < rootRect.top) {
const distance = rootRect.top - wrapperRect.bottom
return distance <= threshold
}
// Below the root: compare top edge to root bottom.
if (wrapperRect.top > rootRect.bottom) {
const distance = wrapperRect.top - rootRect.bottom
return distance <= threshold
}
return true
}
function subscribeToSharedObserver(
target: Element,
root: ObserverRoot,
margin: number,
callback: IntersectionCallback,
): () => void {
if (typeof IntersectionObserver === "undefined") {
callback({ isIntersecting: true } as IntersectionObserverEntry)
return () => {}
}
const key = getRootKey(root, margin)
let shared = sharedObservers.get(key)
if (!shared) {
shared = createSharedObserver(root, margin)
sharedObservers.set(key, shared)
}
let targetCallbacks = shared.listeners.get(target)
if (!targetCallbacks) {
targetCallbacks = new Set()
shared.listeners.set(target, targetCallbacks)
shared.observer.observe(target)
}
targetCallbacks.add(callback)
return () => {
const current = shared?.listeners.get(target)
if (current) {
current.delete(callback)
if (current.size === 0) {
shared?.listeners.delete(target)
shared?.observer.unobserve(target)
}
}
if (shared && shared.listeners.size === 0) {
shared.observer.disconnect()
sharedObservers.delete(key)
}
}
}
interface VirtualItemProps {
cacheKey: string
children: JSX.Element | (() => JSX.Element)
scrollContainer?: Accessor<HTMLElement | undefined | null>
threshold?: number
minPlaceholderHeight?: number
class?: string
contentClass?: string
placeholderClass?: string
virtualizationEnabled?: Accessor<boolean>
forceVisible?: Accessor<boolean>
suspendMeasurements?: Accessor<boolean>
onMeasured?: () => void
onHeightChange?: (nextHeight: number, previousHeight: number, meta: VirtualItemHeightChangeMeta) => void
id?: string
}
export interface VirtualItemHeightChangeMeta {
source: "initial-visible-measure" | "resize"
previousCachedHeight: number | null
isStaleCacheCorrection: boolean
wasHidden: boolean
}
export default function VirtualItem(props: VirtualItemProps) {
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
const cachedHeight = sizeCache.get(props.cacheKey)
const fallbackPlaceholderHeight = () => props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
// Default to hidden until we can determine visibility.
// This avoids keeping heavy DOM alive when IntersectionObserver
// doesn't fire (common for hidden/zero-sized scroll roots).
const [isIntersecting, setIsIntersecting] = createSignal(false)
// Keep measuredHeight aligned with the *effective layout height* while hidden.
// When content first mounts, onHeightChange deltas should reflect the DOM's
// placeholder height (not 0), otherwise scroll compensation can overshoot.
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
let pendingVisibility: boolean | null = null
let visibilityFrame: number | null = null
let awaitingVisibleMeasurement = true
let lastMeasurementWhileHidden = true
const flushVisibility = () => {
if (visibilityFrame !== null) {
cancelAnimationFrame(visibilityFrame)
visibilityFrame = null
}
if (pendingVisibility !== null) {
setIsIntersecting(pendingVisibility)
pendingVisibility = null
}
}
const queueVisibility = (nextValue: boolean) => {
pendingVisibility = nextValue
if (visibilityFrame !== null) return
visibilityFrame = requestAnimationFrame(() => {
visibilityFrame = null
if (pendingVisibility !== null) {
setIsIntersecting(pendingVisibility)
pendingVisibility = null
}
})
}
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
const forceVisible = () => Boolean(props.forceVisible?.())
const shouldHideContent = createMemo(() => {
if (forceVisible()) return false
if (!virtualizationEnabled()) return false
return !isIntersecting()
})
let wrapperRef: HTMLDivElement | undefined
let contentRef: HTMLDivElement | undefined
let resizeObserver: ResizeObserver | undefined
let intersectionCleanup: (() => void) | undefined
function cleanupResizeObserver() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = undefined
}
}
function scheduleVisibleMeasurements() {
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
queueMicrotask(() => {
if (shouldHideContent() || measurementsSuspended()) return
if (!contentRef) return
updateMeasuredHeight()
setupResizeObserver()
})
}
function cleanupIntersectionObserver() {
if (intersectionCleanup) {
intersectionCleanup()
intersectionCleanup = undefined
}
}
function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) {
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
return
}
const before = measuredHeight()
const normalized = nextHeight
const previousCachedHeight = sizeCache.get(props.cacheKey) ?? null
const previous = previousCachedHeight ?? measuredHeight()
const measurementMeta: VirtualItemHeightChangeMeta = {
source: meta?.source ?? "resize",
previousCachedHeight,
isStaleCacheCorrection:
(meta?.source ?? "resize") === "initial-visible-measure" &&
previousCachedHeight !== null &&
normalized > 0 &&
Math.abs(normalized - previousCachedHeight) > 1,
wasHidden: meta?.wasHidden ?? shouldHideContent(),
}
// Only keep the previous measurement when the element reports 0 height.
// Allow shrinkage so placeholder height matches real content height;
// keeping the max height can cause mount/unmount jitter near the
// virtualization boundary.
const shouldKeepPrevious = previous > 0 && normalized === 0
if (shouldKeepPrevious) {
if (!hasReportedMeasurement) {
hasReportedMeasurement = true
props.onMeasured?.()
}
sizeCache.set(props.cacheKey, previous)
setMeasuredHeight(previous)
if (previous !== before) props.onHeightChange?.(previous, before, measurementMeta)
return
}
if (normalized > 0) {
sizeCache.set(props.cacheKey, normalized)
if (!hasReportedMeasurement) {
hasReportedMeasurement = true
props.onMeasured?.()
}
}
setMeasuredHeight(normalized)
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
}
function updateMeasuredHeight() {
if (!contentRef) return
if (measurementsSuspended()) return
// Prefer subpixel-accurate height for scroll compensation.
// offsetHeight rounds to integers which can accumulate error.
const rect = contentRef.getBoundingClientRect()
const next = Math.max(0, Math.round(rect.height * 2) / 2)
const currentMeasured = measuredHeight()
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
const wasHidden = lastMeasurementWhileHidden
if (measurementSource === "initial-visible-measure") {
awaitingVisibleMeasurement = false
lastMeasurementWhileHidden = false
}
if (next === currentMeasured) return
persistMeasurement(next, { source: measurementSource, wasHidden })
}
function setupResizeObserver() {
if (!contentRef || measurementsSuspended()) return
cleanupResizeObserver()
if (typeof ResizeObserver === "undefined") {
updateMeasuredHeight()
return
}
resizeObserver = new ResizeObserver(() => {
if (measurementsSuspended()) return
updateMeasuredHeight()
})
resizeObserver.observe(contentRef)
}
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
cleanupIntersectionObserver()
if (!wrapperRef) {
setIsIntersecting(false)
return
}
if (typeof IntersectionObserver === "undefined") {
setIsIntersecting(true)
return
}
const margin = props.threshold ?? DEFAULT_MARGIN_PX
// If the scroll root is hidden / 0x0, IntersectionObserver can report
// `isIntersecting` in unexpected ways (often "true" with null rootBounds),
// which keeps heavy DOM alive in background tabs.
//
// In that state, force-hide and skip attaching the observer. When the
// pane becomes visible again, VirtualItem will re-run this setup and
// re-attach the observer.
const renderable = isRenderableRoot(targetRoot)
if (!renderable) {
setIsIntersecting(false)
return
}
// Avoid doing an eager geometry read here.
// During large list hydration / initial layout, wrapper rects can be
// transiently 0/incorrect and cause many offscreen items to mount.
// Rely on the observer callback (which we harden below) to determine
// visibility.
const wrapperEl = wrapperRef
intersectionCleanup = subscribeToSharedObserver(wrapperEl, targetRoot, margin, (entry) => {
// IntersectionObserver can produce transient false-positives during pane
// activation/layout transitions (e.g. `isIntersecting: true` for items far
// outside the scroll root). For element roots, prefer explicit rect math.
if (targetRoot && !(targetRoot instanceof Document)) {
// When rootBounds is null we cannot trust the entry; treat as hidden.
if (entry.rootBounds === null) {
queueVisibility(false)
return
}
try {
const rootRect = (targetRoot as Element).getBoundingClientRect()
const visible = shouldRenderByRects({
wrapperRect: wrapperEl.getBoundingClientRect(),
rootRect: { top: rootRect.top, bottom: rootRect.bottom },
margin,
})
queueVisibility(visible)
return
} catch {
// Fall through to the entry-based heuristic.
}
}
const nextVisible = shouldRenderEntry(entry)
queueVisibility(nextVisible)
})
}
function setWrapperRef(element: HTMLDivElement | null) {
wrapperRef = element ?? undefined
const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null)
}
function setContentRef(element: HTMLDivElement | null) {
contentRef = element ?? undefined
if (contentRef) {
queueMicrotask(() => {
if (shouldHideContent() || measurementsSuspended()) return
updateMeasuredHeight()
setupResizeObserver()
})
} else {
cleanupResizeObserver()
}
}
createEffect(() => {
const hidden = shouldHideContent()
if (hidden) {
awaitingVisibleMeasurement = true
lastMeasurementWhileHidden = true
}
if (hidden || measurementsSuspended()) {
cleanupResizeObserver()
}
if (!hidden && !measurementsSuspended() && contentRef) {
scheduleVisibleMeasurements()
}
})
createEffect(() => {
const key = props.cacheKey
const cached = sizeCache.get(key)
if (cached !== undefined) {
setMeasuredHeight(cached)
} else {
setMeasuredHeight(fallbackPlaceholderHeight())
}
})
createEffect(() => {
measurementsSuspended()
const root = props.scrollContainer ? props.scrollContainer() : null
refreshIntersectionObserver(root ?? null)
})
const placeholderHeight = createMemo(() => {
const seenHeight = measuredHeight()
if (seenHeight > 0) {
return seenHeight
}
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
})
onCleanup(() => {
cleanupResizeObserver()
cleanupIntersectionObserver()
flushVisibility()
})
const wrapperClass = () => ["virtual-item-wrapper", props.class].filter(Boolean).join(" ")
const contentClass = () => {
const classes = ["virtual-item-content", props.contentClass]
if (shouldHideContent()) {
classes.push("virtual-item-content-hidden")
}
return classes.filter(Boolean).join(" ")
}
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
const lazyContent = createMemo<JSX.Element | null>(() => {
if (shouldHideContent()) return null
return resolveContent()
})
return (
<div ref={setWrapperRef} id={props.id} class={wrapperClass()} style={{ width: "100%" }}>
<div
class={placeholderClass()}
style={{
width: "100%",
height: shouldHideContent() ? `${placeholderHeight()}px` : undefined,
}}
>
<div ref={setContentRef} class={contentClass()}>
{lazyContent()}
</div>
</div>
</div>
)
}

View File

@@ -18,6 +18,7 @@ import {
setWorktreeSlugForParentSession,
} from "../stores/worktrees"
import { sessions } from "../stores/sessions"
import { useI18n } from "../lib/i18n"
const log = getLogger("session")
@@ -25,8 +26,6 @@ type WorktreeOption =
| { kind: "action"; key: "__create__"; label: string }
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
const CREATE_OPTION: WorktreeOption = { kind: "action", key: "__create__", label: "+ Create worktree" }
function preventSelectPress(event: PointerEvent | MouseEvent) {
// Prevent Select.Item from treating this as a selection.
// We intentionally prevent default to stop Kobalte's internal press handling.
@@ -71,6 +70,7 @@ interface WorktreeSelectorProps {
}
export default function WorktreeSelector(props: WorktreeSelectorProps) {
const { t } = useI18n()
const [isOpen, setIsOpen] = createSignal(false)
const [createOpen, setCreateOpen] = createSignal(false)
const [createSlug, setCreateSlug] = createSignal("")
@@ -99,7 +99,8 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
directory: wt.directory,
raw: wt,
}))
return [CREATE_OPTION, ...mapped]
const createOption: WorktreeOption = { kind: "action", key: "__create__", label: t("instanceShell.worktree.create") }
return [createOption, ...mapped]
})
const selectedOption = createMemo<WorktreeOption | undefined>(() => {

View File

@@ -7,6 +7,9 @@ import type {
FileSystemCreateFolderResponse,
FileSystemListResponse,
InstanceData,
SpeechCapabilitiesResponse,
SpeechSynthesisResponse,
SpeechTranscriptionResponse,
ServerMeta,
WorkspaceCreateRequest,
WorkspaceDescriptor,
@@ -235,6 +238,27 @@ export const serverApi = {
body: JSON.stringify({ path }),
})
},
fetchSpeechCapabilities(): Promise<SpeechCapabilitiesResponse> {
return request<SpeechCapabilitiesResponse>("/api/speech/capabilities")
},
transcribeAudio(payload: {
audioBase64: string
mimeType: string
filename?: string
language?: string
prompt?: string
}): Promise<SpeechTranscriptionResponse> {
return request<SpeechTranscriptionResponse>("/api/speech/transcribe", {
method: "POST",
body: JSON.stringify(payload),
})
},
synthesizeSpeech(payload: { text: string; format?: "mp3" | "wav" | "opus" }): Promise<SpeechSynthesisResponse> {
return request<SpeechSynthesisResponse>("/api/speech/synthesize", {
method: "POST",
body: JSON.stringify(payload),
})
},
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
const params = new URLSearchParams()
if (path && path !== ".") {

View File

@@ -0,0 +1,23 @@
import { isTauriHost } from "./runtime-env"
export async function openExternalUrl(url: string, context = "ui"): Promise<void> {
if (typeof window === "undefined") {
return
}
if (isTauriHost()) {
try {
const { openUrl } = await import("@tauri-apps/plugin-opener")
await openUrl(url)
return
} catch (error) {
console.warn(`[${context}] unable to open via system opener`, error)
}
}
try {
window.open(url, "_blank", "noopener,noreferrer")
} catch (error) {
console.warn(`[${context}] unable to open external url`, error)
}
}

View File

@@ -34,6 +34,7 @@ export interface UseCommandsOptions {
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
togglePromptSubmitOnEnter: () => void
toggleShowPromptVoiceInput: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
@@ -435,6 +436,7 @@ export function useCommands(options: UseCommandsOptions) {
toggleUsageMetrics: options.toggleUsageMetrics,
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput: options.toggleShowPromptVoiceInput,
setDiffViewMode: options.setDiffViewMode,
setToolOutputExpansion: options.setToolOutputExpansion,
setDiagnosticsExpansion: options.setDiagnosticsExpansion,

View File

@@ -2,27 +2,32 @@ import { createContext, createEffect, createMemo, createSignal, onCleanup, onMou
import type { ParentComponent } from "solid-js"
import { useConfig } from "../../stores/preferences"
import { enMessages } from "./messages/en"
import { esMessages } from "./messages/es"
import { frMessages } from "./messages/fr"
import { ruMessages } from "./messages/ru"
import { jaMessages } from "./messages/ja"
import { zhHansMessages } from "./messages/zh-Hans"
type Messages = Record<string, string>
export type TranslateParams = Record<string, unknown>
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans"
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans" | "he"
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans", "he"] as const
const SUPPORTED_LOCALES_BY_LOWER = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
const RTL_LOCALES = new Set<Locale>(["he"])
const messagesByLocale: Record<Locale, Messages> = {
en: enMessages,
es: esMessages,
fr: frMessages,
ru: ruMessages,
ja: jaMessages,
"zh-Hans": zhHansMessages,
const localeMessagesCache = new Map<Locale, Messages>([["en", enMessages]])
const localeMessagesPromises = new Map<Locale, Promise<Messages>>()
const localeLoaders: Record<Locale, () => Promise<Messages>> = {
en: async () => enMessages,
es: async () => (await import("./messages/es")).esMessages,
fr: async () => (await import("./messages/fr")).frMessages,
ru: async () => (await import("./messages/ru")).ruMessages,
ja: async () => (await import("./messages/ja")).jaMessages,
"zh-Hans": async () => (await import("./messages/zh-Hans")).zhHansMessages,
he: async () => (await import("./messages/he")).heMessages,
}
function getLocaleDirection(locale: Locale): "ltr" | "rtl" {
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
}
function normalizeLocaleTag(value: string): string {
@@ -34,8 +39,7 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
const normalized = normalizeLocaleTag(value)
const lower = normalized.toLowerCase()
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
const exact = supportedLower.get(lower)
const exact = SUPPORTED_LOCALES_BY_LOWER.get(lower)
if (exact) return exact
const parts = lower.split("-")
@@ -43,11 +47,11 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
if (!base) return null
if (base === "zh") {
const zhHans = supportedLower.get("zh-hans")
const zhHans = SUPPORTED_LOCALES_BY_LOWER.get("zh-hans")
return zhHans ?? null
}
const baseMatch = supportedLower.get(base)
const baseMatch = SUPPORTED_LOCALES_BY_LOWER.get(base)
return baseMatch ?? null
}
@@ -84,8 +88,54 @@ function translateFrom(messages: Messages, key: string, params?: TranslateParams
}
const [globalRevision, setGlobalRevision] = createSignal(0)
const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en"
let globalMessages: Messages = messagesByLocale[initialGlobalLocale]
let globalMessages: Messages = enMessages
let globalLocale: Locale = "en"
function getMessagesForLocale(locale: Locale): Messages {
return localeMessagesCache.get(locale) ?? enMessages
}
async function loadLocaleMessages(locale: Locale): Promise<Messages> {
const cached = localeMessagesCache.get(locale)
if (cached) {
return cached
}
const pending = localeMessagesPromises.get(locale)
if (pending) {
return pending
}
const loader = localeLoaders[locale]
const promise = loader()
.then((messages) => {
localeMessagesCache.set(locale, messages)
localeMessagesPromises.delete(locale)
return messages
})
.catch((error) => {
localeMessagesPromises.delete(locale)
throw error
})
localeMessagesPromises.set(locale, promise)
return promise
}
export async function preloadLocaleMessages(preferredLocale?: string | null): Promise<Locale> {
const resolvedLocale = matchSupportedLocale(preferredLocale ?? undefined) ?? detectNavigatorLocale() ?? "en"
try {
globalMessages = await loadLocaleMessages(resolvedLocale)
globalLocale = resolvedLocale
setGlobalRevision((value) => value + 1)
return resolvedLocale
} catch {
globalMessages = enMessages
globalLocale = "en"
setGlobalRevision((value) => value + 1)
return "en"
}
}
export function tGlobal(key: string, params?: TranslateParams): string {
globalRevision()
@@ -101,9 +151,12 @@ const I18nContext = createContext<I18nContextValue>()
export const I18nProvider: ParentComponent = (props) => {
const { preferences } = useConfig()
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en")
const previousMessages = globalMessages
const [detectedLocale, setDetectedLocale] = createSignal<Locale>(globalLocale)
const [resolvedLocale, setResolvedLocale] = createSignal<Locale>(globalLocale)
const previousGlobalMessages = globalMessages
const previousGlobalLocale = globalLocale
const previousDocumentLanguage = typeof document !== "undefined" ? document.documentElement.lang : ""
const previousDocumentDirection = typeof document !== "undefined" ? document.documentElement.dir : ""
onMount(() => {
const detected = detectNavigatorLocale()
@@ -115,20 +168,56 @@ export const I18nProvider: ParentComponent = (props) => {
return configured ?? detectedLocale() ?? "en"
})
const messages = createMemo<Messages>(() => messagesByLocale[locale()])
const messages = createMemo<Messages>(() => getMessagesForLocale(resolvedLocale()))
function t(key: string, params?: TranslateParams): string {
return translateFrom(messages(), key, params)
}
createEffect(() => {
globalMessages = messages()
setGlobalRevision((value) => value + 1)
const nextLocale = locale()
let cancelled = false
void loadLocaleMessages(nextLocale)
.then((loadedMessages) => {
if (cancelled) {
return
}
setResolvedLocale(nextLocale)
globalLocale = nextLocale
globalMessages = loadedMessages
setGlobalRevision((value) => value + 1)
})
.catch(() => {
if (cancelled) {
return
}
setResolvedLocale("en")
globalMessages = enMessages
globalLocale = "en"
setGlobalRevision((value) => value + 1)
})
onCleanup(() => {
cancelled = true
})
})
createEffect(() => {
if (typeof document === "undefined") return
const activeLocale = locale()
document.documentElement.dir = getLocaleDirection(activeLocale)
document.documentElement.lang = activeLocale
})
onCleanup(() => {
globalMessages = previousMessages
globalMessages = previousGlobalMessages
globalLocale = previousGlobalLocale
setGlobalRevision((value) => value + 1)
if (typeof document !== "undefined") {
document.documentElement.lang = previousDocumentLanguage
document.documentElement.dir = previousDocumentDirection
}
})
const value: I18nContextValue = {

View File

@@ -114,12 +114,26 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
"instanceShell.sessionChanges.actions.show": "Show changes",
"instanceShell.gitChanges.noSessionSelected": "Select a session to view git changes.",
"instanceShell.gitChanges.loading": "Loading git changes...",
"instanceShell.gitChanges.empty": "No git changes yet.",
"instanceShell.gitChanges.deleted": "Deleted",
"instanceShell.filesShell.fileListTitle": "File list",
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
"instanceShell.filesShell.viewerTitle": "Change viewer",
"instanceShell.filesShell.viewerPlaceholder": "Detailed change rendering will be added in the next step.",
"instanceShell.filesShell.viewerEmpty": "No file selected.",
"instanceShell.filesShell.hideFiles": "Hide files",
"instanceShell.filesShell.showFiles": "Show files",
"instanceShell.diff.hideUnchanged": "Hide unchanged regions",
"instanceShell.diff.showFull": "Show full file",
"instanceShell.diff.switchToSplit": "Switch to split view",
"instanceShell.diff.switchToUnified": "Switch to unified view",
"instanceShell.diff.enableWordWrap": "Enable word wrap",
"instanceShell.diff.disableWordWrap": "Disable word wrap",
"instanceShell.worktree.create": "+ Create worktree",
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
"instanceShell.plan.empty": "Nothing planned yet.",

View File

@@ -138,4 +138,11 @@ export const messagingMessages = {
"promptInput.send.ariaLabel": "Send message",
"promptInput.send.errorFallback": "Failed to send message",
"promptInput.send.errorTitle": "Send failed",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,38 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Enter to submit",
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
"settings.speech.title": "Speech",
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
"settings.speech.provider.title": "Provider",
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Checking configuration...",
"settings.speech.status.configured": "Configured",
"settings.speech.status.missing": "Missing API key",
"settings.speech.status.error": "Speech service unavailable",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
"settings.speech.apiKey.placeholder": "Enter a new API key",
"settings.speech.apiKey.storedNote": "A saved API key is hidden. Enter a new value to replace it, or leave the field blank to keep it.",
"settings.speech.apiKey.clearAction": "Clear saved key",
"settings.speech.apiKey.clearPending": "The saved API key will be removed when you save.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Transcription model",
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
"settings.speech.ttsModel.title": "Speech model",
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
"settings.speech.ttsVoice.title": "Default voice",
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
"settings.speech.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
"settings.speech.save.action": "Save",
"settings.speech.save.saving": "Saving...",
"settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed",
} as const

View File

@@ -90,6 +90,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Panel de estado",
"instanceShell.rightPanel.tabs.changes": "Cambios",
"instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git",
"instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
@@ -112,6 +113,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
"instanceShell.sessionChanges.actions.show": "Mostrar cambios",
"instanceShell.gitChanges.loading": "Cargando cambios de Git...",
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
"instanceShell.gitChanges.deleted": "Eliminado",
"instanceShell.filesShell.fileListTitle": "Lista de archivos",
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",

View File

@@ -140,4 +140,11 @@ export const messagingMessages = {
"promptInput.send.ariaLabel": "Enviar mensaje",
"promptInput.send.errorFallback": "No se pudo enviar el mensaje",
"promptInput.send.errorTitle": "Error al enviar",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,38 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
"settings.behavior.autoCleanup.title": "Limpieza automatica de sesiones en blanco",
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Enter para enviar",
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
"settings.speech.title": "Voz",
"settings.speech.subtitle": "Configura ahora el reconocimiento de voz y prepara la base de texto a voz para funciones futuras.",
"settings.speech.provider.title": "Proveedor",
"settings.speech.provider.subtitle": "Las solicitudes de voz usan el adaptador de voz del servidor.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Comprobando configuración...",
"settings.speech.status.configured": "Configurado",
"settings.speech.status.missing": "Falta la clave API",
"settings.speech.status.error": "Servicio de voz no disponible",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Se usa para las solicitudes de voz gestionadas por CodeNomad.",
"settings.speech.apiKey.placeholder": "Introduce una nueva clave API",
"settings.speech.apiKey.storedNote": "Hay una clave API guardada y oculta. Introduce un nuevo valor para reemplazarla o deja el campo vacío para conservarla.",
"settings.speech.apiKey.clearAction": "Borrar clave guardada",
"settings.speech.apiKey.clearPending": "La clave API guardada se eliminará al guardar.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Anulación opcional para endpoints de voz compatibles con OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Modelo de transcripción",
"settings.speech.sttModel.subtitle": "Modelo usado para las solicitudes de voz a texto en el prompt.",
"settings.speech.ttsModel.title": "Modelo de voz",
"settings.speech.ttsModel.subtitle": "Modelo predeterminado de texto a voz reservado para futuras funciones de reproducción.",
"settings.speech.ttsVoice.title": "Voz predeterminada",
"settings.speech.ttsVoice.subtitle": "Voz predeterminada de texto a voz reservada para futuras funciones de reproducción.",
"settings.speech.help": "La entrada de voz del prompt solo aparece cuando la transcripción de voz está configurada y este navegador la admite.",
"settings.speech.save.action": "Guardar",
"settings.speech.save.saving": "Guardando...",
"settings.speech.save.saved": "Guardado",
"settings.speech.save.unsaved": "Cambios sin guardar",
"settings.speech.save.error": "Error al guardar",
} as const

View File

@@ -90,6 +90,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Panneau d'état",
"instanceShell.rightPanel.tabs.changes": "Modifications",
"instanceShell.rightPanel.tabs.gitChanges": "Changements Git",
"instanceShell.rightPanel.tabs.files": "Fichiers",
"instanceShell.rightPanel.tabs.status": "Statut",
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
@@ -112,6 +113,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} fichiers modifiés",
"instanceShell.sessionChanges.actions.show": "Afficher les changements",
"instanceShell.gitChanges.loading": "Chargement des changements Git...",
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
"instanceShell.gitChanges.deleted": "Supprimé",
"instanceShell.filesShell.fileListTitle": "Liste des fichiers",
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
"instanceShell.filesShell.mobileSelectorEmpty": "Sélectionnez un fichier",

View File

@@ -140,4 +140,11 @@ export const messagingMessages = {
"promptInput.send.ariaLabel": "Envoyer le message",
"promptInput.send.errorFallback": "Impossible d'envoyer le message",
"promptInput.send.errorTitle": "Échec de l'envoi",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,38 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
"settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides",
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
"settings.speech.title": "Voix",
"settings.speech.subtitle": "Configurez dès maintenant la reconnaissance vocale et préparez la synthèse vocale pour de futures fonctionnalités.",
"settings.speech.provider.title": "Fournisseur",
"settings.speech.provider.subtitle": "Les requêtes vocales utilisent l'adaptateur vocal côté serveur.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Vérification de la configuration...",
"settings.speech.status.configured": "Configuré",
"settings.speech.status.missing": "Clé API manquante",
"settings.speech.status.error": "Service vocal indisponible",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Utilisée pour les requêtes vocales gérées par CodeNomad.",
"settings.speech.apiKey.placeholder": "Saisissez une nouvelle clé API",
"settings.speech.apiKey.storedNote": "Une clé API enregistrée est masquée. Saisissez une nouvelle valeur pour la remplacer ou laissez le champ vide pour la conserver.",
"settings.speech.apiKey.clearAction": "Effacer la clé enregistrée",
"settings.speech.apiKey.clearPending": "La clé API enregistrée sera supprimée lors de l'enregistrement.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Remplacement facultatif des points d'accès vocaux compatibles OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Modèle de transcription",
"settings.speech.sttModel.subtitle": "Modèle utilisé pour les requêtes vocales vers texte du prompt.",
"settings.speech.ttsModel.title": "Modèle vocal",
"settings.speech.ttsModel.subtitle": "Modèle de synthèse vocale par défaut réservé aux futures fonctions de lecture.",
"settings.speech.ttsVoice.title": "Voix par défaut",
"settings.speech.ttsVoice.subtitle": "Voix de synthèse vocale par défaut réservée aux futures fonctions de lecture.",
"settings.speech.help": "La saisie vocale du prompt n'apparaît que lorsque la transcription vocale est configurée et prise en charge par ce navigateur.",
"settings.speech.save.action": "Enregistrer",
"settings.speech.save.saving": "Enregistrement...",
"settings.speech.save.saved": "Enregistré",
"settings.speech.save.unsaved": "Modifications non enregistrées",
"settings.speech.save.error": "Échec de l'enregistrement",
} as const

View File

@@ -0,0 +1,6 @@
export const advancedSettingsMessages = {
"advancedSettings.title": "הגדרות מתקדמות",
"advancedSettings.environmentVariables.title": "משתני סביבה",
"advancedSettings.environmentVariables.subtitle": "מוחלים בכל פעם שמופע OpenCode חדש מופעל",
"advancedSettings.actions.close": "סגור",
} as const

View File

@@ -0,0 +1,42 @@
export const appMessages = {
"app.launchError.title": "לא ניתן להפעיל את OpenCode",
"app.launchError.description": "לא הצלחנו להפעיל את קובץ ה-OpenCode שנבחר. בדוק את פלט השגיאה למטה או בחר קובץ בינארי אחר מהגדרות OpenCode.",
"app.launchError.binaryPathLabel": "נתיב הקובץ הבינארי",
"app.launchError.errorOutputLabel": "פלט שגיאה",
"app.launchError.openAdvancedSettings": "פתח הגדרות OpenCode",
"app.launchError.close": "סגור",
"app.launchError.closeTitle": "סגור (Esc)",
"app.launchError.fallbackMessage": "הפעלת סביבת העבודה נכשלה",
"app.stopInstance.confirmMessage": "לעצור את מופע OpenCode? פעולה זו תעצור את השרת.",
"app.stopInstance.title": "עצור מופע",
"app.stopInstance.confirmLabel": "עצור",
"app.stopInstance.cancelLabel": "המשך להריץ",
"emptyState.logoAlt": "לוגו CodeNomad",
"emptyState.brandTitle": "CodeNomad",
"emptyState.tagline": "בחר תיקייה כדי להתחיל לתכנת עם AI",
"emptyState.actions.selectFolder": "בחר תיקייה",
"emptyState.actions.selecting": "בוחר...",
"emptyState.keyboardShortcut": "קיצור מקלדת: {shortcut}",
"emptyState.examples": "דוגמאות: {example}",
"emptyState.multipleInstances": "ניתן לפתוח מספר מופעים של אותה תיקייה",
"releases.upgradeRequired.title": "נדרש שדרוג",
"releases.upgradeRequired.message.withVersion": "שדרג ל-CodeNomad {version} כדי להשתמש בממשק המעודכן.",
"releases.upgradeRequired.message.noVersion": "שדרג את CodeNomad כדי להשתמש בממשק המעודכן.",
"releases.upgradeRequired.action.getUpdate": "קבל עדכון",
"releases.uiUpdated.title": "הממשק עודכן",
"releases.uiUpdated.message": "הממשק עודכן לגרסה {version}.",
"releases.devUpdateAvailable.title": "גרסת פיתוח זמינה",
"releases.devUpdateAvailable.message": "גרסת פיתוח חדשה זמינה: {version}.",
"releases.devUpdateAvailable.action": "צפה בגרסה",
"theme.mode.system": "מערכת",
"theme.mode.light": "בהיר",
"theme.mode.dark": "כהה",
"theme.toggle.title": "ערכת נושא: {mode}",
"theme.toggle.ariaLabel": "ערכת נושא: {mode}",
} as const

View File

@@ -0,0 +1,176 @@
export const commandMessages = {
"commandPalette.title": "לוח פקודות",
"commandPalette.description": "חיפוש והפעלה של פקודות",
"commandPalette.searchPlaceholder": "הקלד פקודה או חיפוש...",
"commandPalette.empty": "לא נמצאו פקודות עבור \"{query}\"",
"commandPalette.category.customCommands": "פקודות מותאמות אישית",
"commandPalette.category.instance": "מופע",
"commandPalette.category.session": "סשן",
"commandPalette.category.agentModel": "סוכן ומודל",
"commandPalette.category.inputFocus": "קלט ופוקוס",
"commandPalette.category.system": "מערכת",
"commandPalette.category.other": "אחר",
"commands.newInstance.label": "מופע חדש",
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
"commands.closeInstance.label": "סגור מופע",
"commands.closeInstance.description": "עצור את השרת של המופע הנוכחי",
"commands.closeInstance.keywords": "עצור, סגור",
"commands.nextInstance.label": "מופע הבא",
"commands.nextInstance.description": "עבור למופע הבא",
"commands.nextInstance.keywords": "החלף, נווט",
"commands.previousInstance.label": "מופע קודם",
"commands.previousInstance.description": "עבור למופע הקודם",
"commands.previousInstance.keywords": "החלף, נווט",
"commands.newSession.label": "סשן חדש",
"commands.newSession.description": "צור סשן הורה חדש",
"commands.newSession.keywords": "צור, התחל",
"commands.closeSession.label": "סגור סשן",
"commands.closeSession.description": "סגור את סשן ההורה הנוכחי",
"commands.closeSession.keywords": "סגור, עצור",
"commands.scrubSessions.label": "נקה סשנים",
"commands.scrubSessions.description": "הסר סשנים ריקים, סשני תת-סוכן שסיימו את משימתם הראשית, וסשני פיצול מיותרים.",
"commands.scrubSessions.keywords": "ניקוי, ריק, סשנים, הסר, מחק",
"commands.instanceInfo.label": "מידע על מופע",
"commands.instanceInfo.description": "פתח את סקירת המופע ללוגים וסטטוס",
"commands.instanceInfo.keywords": "מידע, לוגים, קונסולה, פלט",
"commands.nextSession.label": "סשן הבא",
"commands.nextSession.description": "עבור לסשן הבא",
"commands.nextSession.keywords": "החלף, נווט",
"commands.previousSession.label": "סשן קודם",
"commands.previousSession.description": "עבור לסשן הקודם",
"commands.previousSession.keywords": "החלף, נווט",
"commands.compactSession.label": "סכם סשן",
"commands.compactSession.description": "סכם ודחוס את הסשן הנוכחי",
"commands.compactSession.keywords": "סיכום, דחיסה",
"commands.compactSession.errorFallback": "סיכום הסשן נכשל",
"commands.compactSession.alert.title": "הסיכום נכשל",
"commands.compactSession.alert.message": "הסיכום נכשל: {message}",
"commands.undoLastMessage.label": "בטל הודעה אחרונה",
"commands.undoLastMessage.description": "בטל את ההודעה האחרונה",
"commands.undoLastMessage.keywords": "חזרה, ביטול",
"commands.undoLastMessage.none.title": "אין פעולות לביטול",
"commands.undoLastMessage.none.message": "אין מה לבטל",
"commands.undoLastMessage.failed.title": "הביטול נכשל",
"commands.undoLastMessage.failed.message": "ביטול ההודעה נכשל",
"commands.openModelSelector.label": "פתח בורר מודלים",
"commands.openModelSelector.description": "בחר מודל אחר",
"commands.openModelSelector.keywords": "מודל, llm, ai",
"commands.selectModelVariant.label": "בחר גרסת מודל",
"commands.selectModelVariant.description": "בחר רמת מאמץ חשיבה למודל הנוכחי",
"commands.selectModelVariant.keywords": "גרסה, חשיבה, מאמץ",
"commands.openAgentSelector.label": "פתח בורר סוכנים",
"commands.openAgentSelector.description": "בחר סוכן אחר",
"commands.openAgentSelector.keywords": "סוכן, מצב",
"commands.clearInput.label": "נקה קלט",
"commands.clearInput.description": "נקה את תיבת הטקסט של הפקודה",
"commands.clearInput.keywords": "נקה, אפס",
"commands.promptSubmitShortcut.label.default": "Enter: שורה חדשה, Cmd/Ctrl+Enter: שלח פקודה",
"commands.promptSubmitShortcut.label.swapped": "Enter: שלח פקודה, Cmd/Ctrl+Enter: שורה חדשה",
"commands.promptSubmitShortcut.description": "החלף את התנהגות Enter ו-Cmd/Ctrl+Enter בקלט הפקודה",
"commands.promptSubmitShortcut.keywords": "enter, cmd, ctrl, שלח, שורה חדשה, קיצור",
"commands.thinkingBlocks.label.show": "הצג חשיבה",
"commands.thinkingBlocks.label.hide": "הסתר חשיבה",
"commands.thinkingBlocks.description": "הצג או הסתר קטעי חשיבה של ה-AI",
"commands.thinkingBlocks.keywords": "חשיבה, הצג, הסתר",
"commands.timelineToolCalls.label.show": "הצג קריאות כלי בציר הזמן",
"commands.timelineToolCalls.label.hide": "הסתר קריאות כלי בציר הזמן",
"commands.timelineToolCalls.description": "הצג/הסתר קריאות כלי בציר הודעות",
"commands.timelineToolCalls.keywords": "ציר זמן, כלי, הצג, הסתר",
"commands.keyboardShortcutHints.label.show": "הצג רמזי קיצורי מקלדת",
"commands.keyboardShortcutHints.label.hide": "הסתר רמזי קיצורי מקלדת",
"commands.keyboardShortcutHints.description": "הצג או הסתר רמזי קיצורי מקלדת בכל הממשק",
"commands.keyboardShortcutHints.description.disabledWeb": "מושבת בממשק Web (רמזי קיצורים תמיד מוסתרים)",
"commands.keyboardShortcutHints.keywords": "קיצור, מקלדת, רמזים",
"commands.common.expanded": "פרוס",
"commands.common.collapsed": "מכווץ",
"commands.common.visible": "גלוי",
"commands.common.hidden": "מוסתר",
"commands.common.enabled": "מופעל",
"commands.common.disabled": "מושבת",
"commands.thinkingBlocksDefault.label": "תצוגת חשיבה: {state}",
"commands.thinkingBlocksDefault.description": "כווץ / פרוס קטעי חשיבה של ה-AI",
"commands.thinkingBlocksDefault.keywords": "חשיבה, פרוס, כווץ, ברירת מחדל",
"commands.diffViewSplit.label": "השתמש בתצוגת diff מפוצלת",
"commands.diffViewSplit.description": "הצג diff של קריאות כלי זה לצד זה",
"commands.diffViewSplit.keywords": "diff, מפוצל, תצוגה",
"commands.diffViewUnified.label": "השתמש בתצוגת diff מאוחדת",
"commands.diffViewUnified.description": "הצג diff של קריאות כלי בשורה אחת",
"commands.diffViewUnified.keywords": "diff, מאוחד, תצוגה",
"commands.toolOutputsDefault.label": "ברירת מחדל לפלטי כלים · {state}",
"commands.toolOutputsDefault.description": "החלף ברירת מחדל לפריסת פלטי כלים",
"commands.toolOutputsDefault.keywords": "כלי, פלט, פרוס, כווץ",
"commands.diagnosticsDefault.label": "ברירת מחדל לאבחון · {state}",
"commands.diagnosticsDefault.description": "החלף ברירת מחדל לפריסת פלט אבחון",
"commands.diagnosticsDefault.keywords": "אבחון, פרוס, כווץ",
"commands.toolInputsVisibility.label": "נראות קלטי כלים · {state}",
"commands.toolInputsVisibility.description": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי",
"commands.toolInputsVisibility.keywords": "כלי, קלטים, ארגומנטים, נראות, הסתר, הצג",
"commands.tokenUsageDisplay.label": "תצוגת שימוש בטוקנים · {state}",
"commands.tokenUsageDisplay.description": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן",
"commands.tokenUsageDisplay.keywords": "טוקן, שימוש, עלות, נתונים",
"commands.autoCleanupBlankSessions.label": "ניקוי אוטומטי של סשנים ריקים · {state}",
"commands.autoCleanupBlankSessions.description": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים",
"commands.autoCleanupBlankSessions.keywords": "אוטומטי, ניקוי, ריק, סשנים",
"commands.showHelp.label": "הצג עזרה",
"commands.showHelp.description": "הצג קיצורי מקלדת ועזרה",
"commands.showHelp.keywords": "קיצורים, עזרה",
"commands.custom.argumentsPrompt.message": "ארגומנטים עבור /{name}",
"commands.custom.argumentsPrompt.title": "פקודה מותאמת אישית",
"commands.custom.argumentsPrompt.inputLabel": "ארגומנטים",
"commands.custom.argumentsPrompt.inputPlaceholder": "למשל: foo bar",
"commands.custom.argumentsPrompt.confirmLabel": "הפעל",
"commands.custom.argumentsPrompt.cancelLabel": "ביטול",
"commands.custom.argumentsPrompt.openFailed.message": "פתיחת תיבת ארגומנטים נכשלה.",
"commands.custom.argumentsPrompt.openFailed.title": "ארגומנטים לפקודה",
"commands.custom.entries.descriptionFallback": "פקודה מותאמת אישית",
"commands.custom.sessionRequired.message": "בחר סשן לפני הפעלת פקודה מותאמת אישית.",
"commands.custom.sessionRequired.title": "נדרש סשן",
"commands.custom.runFailed.message": "הפעלת הפקודה המותאמת אישית נכשלה. בדוק את הקונסולה לפרטים.",
"commands.custom.runFailed.title": "הפקודה נכשלה",
"unifiedPicker.loading.searching": "מחפש...",
"unifiedPicker.loading.loadingWorkspace": "טוען סביבת עבודה...",
"unifiedPicker.title.command": "בחר פקודה",
"unifiedPicker.title.mention": "בחר סוכן או קובץ",
"unifiedPicker.empty": "לא נמצאו תוצאות",
"unifiedPicker.sections.commands": "פקודות",
"unifiedPicker.sections.agents": "סוכנים",
"unifiedPicker.sections.files": "קבצים",
"unifiedPicker.sections.workspaceRoot": "שורש סביבת העבודה",
"unifiedPicker.badge.subagent": "תת-סוכן",
"unifiedPicker.footer.navigate": "ניווט",
"unifiedPicker.footer.select": "בחירה",
"unifiedPicker.footer.close": "סגירה",
} as const

View File

@@ -0,0 +1,16 @@
export const dialogMessages = {
"alertDialog.fallbackTitle.info": "לתשומת לבך",
"alertDialog.fallbackTitle.warning": "נא לבדוק",
"alertDialog.fallbackTitle.error": "משהו השתבש",
"alertDialog.actions.confirm": "אישור",
"alertDialog.actions.run": "הפעל",
"alertDialog.actions.ok": "אישור",
"alertDialog.actions.cancel": "ביטול",
"alertDialog.prompt.inputLabel": "קלט",
"backgroundProcessOutputDialog.title": "פלט תהליך רקע",
"backgroundProcessOutputDialog.actions.close": "סגור",
"backgroundProcessOutputDialog.loading": "טוען פלט...",
"backgroundProcessOutputDialog.truncatedNotice": "הפלט קוצר לצורך התצוגה.",
"backgroundProcessOutputDialog.loadErrorFallback": "טעינת הפלט נכשלה.",
} as const

View File

@@ -0,0 +1,43 @@
export const filesystemMessages = {
"directoryBrowser.defaultDescription": "עיון בתיקיות תחת שורש סביבת העבודה המוגדר.",
"directoryBrowser.close": "סגור",
"directoryBrowser.currentFolder": "תיקייה נוכחית",
"directoryBrowser.selectCurrent": "בחר נוכחית",
"directoryBrowser.newFolder": "תיקייה חדשה",
"directoryBrowser.creating": "יוצר…",
"directoryBrowser.loadingFolders": "טוען תיקיות…",
"directoryBrowser.noFolders": "אין תיקיות זמינות.",
"directoryBrowser.upOneLevel": "עלה רמה אחת",
"directoryBrowser.select": "בחר",
"directoryBrowser.load.errorFallback": "לא ניתן לטעון את מערכת הקבצים",
"directoryBrowser.createFolder.promptMessage": "צור תיקייה חדשה בספרייה הנוכחית.",
"directoryBrowser.createFolder.title": "תיקייה חדשה",
"directoryBrowser.createFolder.inputLabel": "שם תיקייה",
"directoryBrowser.createFolder.inputPlaceholder": "למשל: my-new-project",
"directoryBrowser.createFolder.confirmLabel": "צור",
"directoryBrowser.createFolder.cancelLabel": "ביטול",
"directoryBrowser.createFolder.invalidNameMessage": "נא להזין שם תיקייה יחיד.",
"directoryBrowser.createFolder.invalidNameDetail": "שמות תיקיות אינם יכולים לכלול נטויות, '..', או '~'.",
"directoryBrowser.createFolder.errorFallback": "יצירת התיקייה נכשלה",
"filesystemBrowser.descriptionFallback": "חפש נתיב תחת שורש סביבת העבודה המוגדר.",
"filesystemBrowser.rootLabel": "שורש: {root}",
"filesystemBrowser.actions.close": "סגור",
"filesystemBrowser.actions.retry": "נסה שוב",
"filesystemBrowser.actions.select": "בחר",
"filesystemBrowser.filterLabel": "סינון",
"filesystemBrowser.search.placeholder.directories": "חפש תיקיות",
"filesystemBrowser.search.placeholder.files": "חפש קבצים",
"filesystemBrowser.currentFolder.label": "תיקייה נוכחית",
"filesystemBrowser.currentFolder.selectCurrent": "בחר נוכחית",
"filesystemBrowser.loading.filesystem": "מערכת קבצים",
"filesystemBrowser.loading.workspaceRoot": "שורש סביבת עבודה",
"filesystemBrowser.loading.loadingWithPath": "טוען {path}…",
"filesystemBrowser.empty.noEntries": "לא נמצאו רשומות.",
"filesystemBrowser.navigation.upOneLevel": "עלה רמה אחת",
"filesystemBrowser.hints.navigate": "ניווט",
"filesystemBrowser.hints.select": "בחירה",
"filesystemBrowser.hints.close": "סגירה",
"filesystemBrowser.errors.loadFilesystemFallback": "לא ניתן לטעון את מערכת הקבצים",
"filesystemBrowser.errors.openDirectoryFallback": "לא ניתן לפתוח את הספרייה",
} as const

View File

@@ -0,0 +1,42 @@
export const folderSelectionMessages = {
"folderSelection.language.ariaLabel": "שפה",
"folderSelection.logoAlt": "לוגו CodeNomad",
"folderSelection.tagline": "בחר תיקייה כדי להתחיל לתכנת עם AI",
"folderSelection.links.github": "CodeNomad GitHub",
"folderSelection.links.githubStars": "כוכבי CodeNomad ב-GitHub",
"folderSelection.links.discord": "CodeNomad Discord",
"folderSelection.empty.title": "אין תיקיות אחרונות",
"folderSelection.empty.description": "עיין בתיקייה כדי להתחיל",
"folderSelection.recent.title": "תיקיות אחרונות",
"folderSelection.recent.subtitle.one": "תיקייה אחת זמינה",
"folderSelection.recent.subtitle.other": "{count} תיקיות זמינות",
"folderSelection.recent.remove": "הסר מהרשימה האחרונה",
"folderSelection.browse.title": "עיון בתיקייה",
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
"folderSelection.browse.button": "עיון בתיקיות",
"folderSelection.browse.buttonOpening": "פותח...",
"folderSelection.advancedSettings": "הגדרות מתקדמות",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "ניווט",
"folderSelection.hints.select": "בחירה",
"folderSelection.hints.remove": "הסרה",
"folderSelection.hints.browse": "עיון",
"folderSelection.loading.title": "מפעיל מופע...",
"folderSelection.loading.subtitle": "המתן בזמן שאנו מכינים את סביבת העבודה שלך.",
"folderSelection.drop.title": "שחרר תיקייה כדי לפתוח אותה",
"folderSelection.drop.subtitle": "התחל מופע חדש בתיקייה שנשחררה.",
"folderSelection.drop.invalidTitle": "לא ניתן לפתוח את הפריט שנשחרר",
"folderSelection.drop.invalidMessage": "שחרר תיקייה כדי להתחיל מופע חדש.",
"folderSelection.dialog.title": "בחר סביבת עבודה",
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
} as const

View File

@@ -0,0 +1,36 @@
import { advancedSettingsMessages } from "./advancedSettings"
import { appMessages } from "./app"
import { commandMessages } from "./commands"
import { dialogMessages } from "./dialogs"
import { filesystemMessages } from "./filesystem"
import { folderSelectionMessages } from "./folderSelection"
import { instanceMessages } from "./instance"
import { loadingScreenMessages } from "./loadingScreen"
import { logMessages } from "./logs"
import { markdownMessages } from "./markdown"
import { messagingMessages } from "./messaging"
import { remoteAccessMessages } from "./remoteAccess"
import { sessionMessages } from "./session"
import { settingsMessages } from "./settings"
import { timeMessages } from "./time"
import { toolCallMessages } from "./toolCall"
import { mergeMessageParts } from "../merge"
export const heMessages = mergeMessageParts(
folderSelectionMessages,
advancedSettingsMessages,
loadingScreenMessages,
timeMessages,
appMessages,
dialogMessages,
filesystemMessages,
instanceMessages,
logMessages,
sessionMessages,
messagingMessages,
toolCallMessages,
markdownMessages,
settingsMessages,
remoteAccessMessages,
commandMessages,
)

View File

@@ -0,0 +1,166 @@
export const instanceMessages = {
"instanceTabs.new.title": "מופע חדש (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "מופע חדש",
"instanceTabs.remote.title": "חיבור מרוחק",
"instanceTabs.remote.ariaLabel": "חיבור מרוחק",
"instanceInfo.title": "מידע על המופע",
"instanceInfo.labels.folder": "תיקייה",
"instanceInfo.labels.project": "פרויקט",
"instanceInfo.labels.versionControl": "בקרת גרסאות",
"instanceInfo.labels.opencodeVersion": "גרסת OpenCode",
"instanceInfo.labels.binaryPath": "נתיב קובץ בינארי",
"instanceInfo.labels.environmentVariables": "משתני סביבה ({count})",
"instanceInfo.loading": "טוען...",
"instanceInfo.server.title": "שרת",
"instanceInfo.server.port": "פורט:",
"instanceInfo.server.pid": "PID:",
"instanceInfo.server.status": "סטטוס:",
"instanceTab.status.permission": "ממתין לאישור",
"instanceTab.status.compacting": "מסכם",
"instanceTab.status.working": "עובד",
"instanceTab.status.idle": "מוכן",
"instanceTab.status.ariaLabel": "סטטוס מופע: {status}",
"instanceTab.actions.close.ariaLabel": "סגור מופע",
"instanceShell.leftPanel.sessionsTitle": "סשנים",
"instanceShell.leftPanel.instanceInfo": "מידע על המופע",
"instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית",
"instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית",
"instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה",
"instanceShell.leftDrawer.toggle.open": "פתח מגירה שמאלית",
"instanceShell.leftDrawer.toggle.close": "סגור מגירה שמאלית",
"instanceShell.rightDrawer.pin": "נעץ מגירה ימנית",
"instanceShell.rightDrawer.unpin": "שחרר נעיצת מגירה ימנית",
"instanceShell.rightDrawer.toggle.pinned": "המגירה הימנית נעוצה",
"instanceShell.rightDrawer.toggle.open": "פתח מגירה ימנית",
"instanceShell.rightDrawer.toggle.close": "סגור מגירה ימנית",
"instanceShell.fullscreen.enter": "מסך מלא",
"instanceShell.fullscreen.exit": "יציאה ממסך מלא",
"instanceShell.metrics.usedLabel": "בשימוש",
"instanceShell.metrics.availableLabel": "זמין",
"instanceShell.commandPalette.openAriaLabel": "פתח לוח פקודות",
"instanceShell.commandPalette.button": "לוח פקודות",
"instanceShell.connection.ariaLabel": "חיבור {status}",
"instanceShell.connection.connected": "מחובר",
"instanceShell.connection.connecting": "מתחבר...",
"instanceShell.connection.disconnected": "מנותק",
"instanceShell.connection.unknown": "לא ידוע",
"instanceWelcome.shortcuts.newSession": "סשן חדש",
"instanceWelcome.empty.title": "אין סשנים קודמים",
"instanceWelcome.empty.description": "צור סשן חדש למטה כדי להתחיל",
"instanceWelcome.loading.title": "טוען סשנים",
"instanceWelcome.loading.description": "מאחזר את הסשנים הקודמים שלך...",
"instanceWelcome.resume.title": "המשך סשן",
"instanceWelcome.resume.subtitle.one": "סשן אחד זמין",
"instanceWelcome.resume.subtitle.other": "{count} סשנים זמינים",
"instanceWelcome.session.untitled": "סשן ללא שם",
"instanceWelcome.new.title": "התחל סשן חדש",
"instanceWelcome.new.subtitle": "ישתמש אוטומטית בסוכן/מודל האחרון שלך",
"instanceWelcome.new.createButton": "צור סשן",
"instanceWelcome.overlay.close": "סגור",
"instanceWelcome.actions.viewInstanceInfo": "צפה במידע על המופע",
"instanceWelcome.actions.renameTitle": "שנה שם סשן",
"instanceWelcome.actions.deleteTitle": "מחק סשן",
"instanceWelcome.hints.navigate": "ניווט",
"instanceWelcome.hints.jump": "קפיצה",
"instanceWelcome.hints.firstLast": "ראשון/אחרון",
"instanceWelcome.hints.resume": "המשך",
"instanceWelcome.hints.delete": "מחיקה",
"instanceWelcome.toasts.renameError": "לא ניתן לשנות שם הסשן",
"instanceDisconnected.title": "המופע התנתק",
"instanceDisconnected.folderFallback": "סביבת עבודה זו",
"instanceDisconnected.reasonFallback": "השרת הפסיק להגיב",
"instanceDisconnected.description": "לא ניתן עוד להגיע ל-{folder}. סגור את הלשונית כדי להמשיך לעבוד.",
"instanceDisconnected.details.title": "פרטים",
"instanceDisconnected.details.folderLabel": "תיקייה:",
"instanceDisconnected.actions.closeInstance": "סגור מופע",
"instanceShell.empty.title": "לא נבחר סשן",
"instanceShell.empty.description": "בחר סשן לצפייה בהודעות",
"instanceShell.rightPanel.title": "לוח סטטוס",
"instanceShell.rightPanel.tabs.changes": "שינויי סשן",
"instanceShell.rightPanel.tabs.gitChanges": "שינויי Git",
"instanceShell.rightPanel.tabs.files": "קבצים",
"instanceShell.rightPanel.tabs.status": "סטטוס",
"instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני",
"instanceShell.rightPanel.actions.refresh": "רענן",
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
"instanceShell.rightPanel.sections.plan": "תוכנית",
"instanceShell.rightPanel.sections.plan.tooltip": "מפת הדרכים של הסוכן לסשן זה. עוקב אחר משימות, תת-משימות וסטטוס השלמתן.",
"instanceShell.rightPanel.sections.backgroundProcesses": "מעטפות רקע",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "תהליכים ממושכים שהופעלו על ידי הסוכן. ניתן לעקוב אחר פלטם, לעצור אותם או לסיים אותם.",
"instanceShell.rightPanel.sections.mcp": "שרתי MCP",
"instanceShell.rightPanel.sections.mcp.tooltip": "שרתי Model Context Protocol המרחיבים את יכולות הסוכן עם כלים ושירותים חיצוניים.",
"instanceShell.rightPanel.sections.lsp": "שרתי LSP",
"instanceShell.rightPanel.sections.lsp.tooltip": "שרתי Language Server Protocol המספקים בינת קוד, אבחון ותכונות ספציפיות לשפה.",
"instanceShell.rightPanel.sections.plugins": "תוספים",
"instanceShell.rightPanel.sections.plugins.tooltip": "תוספים המתאימים אישית את הממשק ואת התנהגות השרת, ומוסיפים תכונות מעבר ל-MCP ו-LSP.",
"instanceShell.sessionChanges.noSessionSelected": "בחר סשן לצפייה בשינויים.",
"instanceShell.sessionChanges.loading": "מאחזר שינויי סשן...",
"instanceShell.sessionChanges.empty": "אין שינויי סשן עדיין.",
"instanceShell.sessionChanges.filesChanged": "{count} קבצים שונו",
"instanceShell.sessionChanges.actions.show": "הצג שינויים",
"instanceShell.filesShell.fileListTitle": "רשימת קבצים",
"instanceShell.filesShell.mobileSelectorLabel": "בחר קובץ",
"instanceShell.filesShell.mobileSelectorEmpty": "בחר קובץ",
"instanceShell.filesShell.viewerTitle": "מציג שינויים",
"instanceShell.filesShell.viewerPlaceholder": "תצוגת שינויים מפורטת תתווסף בשלב הבא.",
"instanceShell.filesShell.viewerEmpty": "לא נבחר קובץ.",
"instanceShell.filesShell.hideFiles": "הסתר קבצים",
"instanceShell.filesShell.showFiles": "הצג קבצים",
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",
"instanceShell.diff.hideUnchanged": "הסתר אזורים ללא שינוי",
"instanceShell.diff.showFull": "הצג קובץ מלא",
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
"instanceShell.diff.switchToUnified": "עבור לתצוגה מאוחדת",
"instanceShell.diff.enableWordWrap": "הפעל גלישת מילים",
"instanceShell.diff.disableWordWrap": "כבה גלישת מילים",
"instanceShell.worktree.create": "+ צור worktree",
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
"instanceShell.backgroundProcesses.actions.output": "פלט",
"instanceShell.backgroundProcesses.actions.stop": "עצור",
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
"versionPill.appWithVersion": "אפליקציה {version}",
"versionPill.ui": "ממשק",
"versionPill.uiWithVersion": "ממשק {version}",
"versionPill.source": " ({source})",
"opencodeBinarySelector.title": "קובץ בינארי של OpenCode",
"opencodeBinarySelector.subtitle": "בחר איזה קובץ הרצה OpenCode ישתמש",
"opencodeBinarySelector.customPath.placeholder": "הזן נתיב לקובץ בינארי של opencode…",
"opencodeBinarySelector.actions.add": "הוסף",
"opencodeBinarySelector.actions.browse": "עיין אחר קובץ בינארי…",
"opencodeBinarySelector.actions.removeTitle": "הסר קובץ בינארי",
"opencodeBinarySelector.badge.systemPath": "השתמש בקובץ בינארי מנתיב המערכת",
"opencodeBinarySelector.status.checkingVersions": "בודק גרסאות…",
"opencodeBinarySelector.status.checking": "בודק…",
"opencodeBinarySelector.dialog.title": "בחר קובץ בינארי של OpenCode",
"opencodeBinarySelector.dialog.description": "עיין בקבצים החשופים על ידי שרת ה-CLI.",
"opencodeBinarySelector.validation.invalidBinary": "קובץ בינארי לא תקין של OpenCode",
"opencodeBinarySelector.validation.alreadyValidating": "כבר מאמת",
"opencodeBinarySelector.display.systemPath": "{name} (נתיב מערכת)",
"opencodeBinarySelector.versionLabel": "v{version}",
} as const

View File

@@ -0,0 +1,17 @@
export const loadingScreenMessages = {
"loadingScreen.logoAlt": "לוגו CodeNomad",
"loadingScreen.status.issue": "נתקלנו בבעיה",
"loadingScreen.actions.showAnother": "הצג עוד",
"loadingScreen.errors.missingRoot": "אלמנט השורש לטעינה לא נמצא",
"loadingScreen.phrases.neurons": "מחמם את הנוירונים של ה-AI…",
"loadingScreen.phrases.daydreaming": "משכנע את ה-AI להפסיק לחלום בהקיץ…",
"loadingScreen.phrases.goggles": "מצחצח את משקפי הקוד של ה-AI…",
"loadingScreen.phrases.reorganizingFiles": "מבקש מה-AI להפסיק לארגן מחדש את הקבצים שלך…",
"loadingScreen.phrases.coffee": "מאכיל את ה-AI עוד קפה…",
"loadingScreen.phrases.nodeModules": "מלמד את ה-AI לא למחוק node_modules (שוב)…",
"loadingScreen.phrases.actNatural": "אומר ל-AI להיראות טבעי לפני שתגיע…",
"loadingScreen.phrases.rewritingHistory": "מבקש מה-AI בבקשה להפסיק לשכתב היסטוריה…",
"loadingScreen.phrases.stretch": "מאפשר ל-AI להתמתח לפני ספרינט הקוד שלו…",
"loadingScreen.phrases.keyboardControl": "משכנע את ה-AI לתת לך שליטה על המקלדת…",
} as const

View File

@@ -0,0 +1,27 @@
export const logMessages = {
"logsView.title": "לוגי שרת",
"logsView.actions.show": "הצג לוגי שרת",
"logsView.actions.hide": "הסתר לוגי שרת",
"logsView.envVars.title": "משתני סביבה ({count})",
"logsView.paused.title": "לוגי השרת מושהים",
"logsView.paused.description": "הפעל זרימה לצפייה בפעילות שרת OpenCode שלך.",
"logsView.empty.waiting": "ממתין לפלט שרת...",
"logsView.scrollToBottom": "גלול למטה",
"infoView.logs.title": "לוגי שרת",
"infoView.logs.actions.show": "הצג לוגי שרת",
"infoView.logs.actions.hide": "הסתר לוגי שרת",
"infoView.logs.paused.title": "לוגי השרת מושהים",
"infoView.logs.paused.description": "הפעל זרימה לצפייה בפעילות שרת OpenCode שלך.",
"infoView.logs.empty.waiting": "ממתין לפלט שרת...",
"infoView.logs.scrollToBottom": "גלול למטה",
"infoView.dispose.actions.dispose": "בטל מופע",
"infoView.dispose.actions.disposing": "מבטל...",
"infoView.dispose.confirm.title": "לבטל את המופע?",
"infoView.dispose.confirm.message": "פעולה זו מנקה את המצב השמור לפי פרויקט עבור ספרייה זו ומטעינה מחדש את המופע.",
"infoView.dispose.confirm.confirmLabel": "בטל",
"infoView.dispose.confirm.cancelLabel": "ביטול",
"infoView.dispose.toast.success": "המופע בוטל. מטעין מחדש...",
"infoView.dispose.toast.error": "ביטול המופע נכשל.",
} as const

View File

@@ -0,0 +1,7 @@
export const markdownMessages = {
"markdown.codeBlock.copy.label": "העתק",
"markdown.codeBlock.copy.copied": "הועתק!",
"markdown.codeBlock.copy.failed": "נכשל",
"markdown.copy": "העתק",
} as const

View File

@@ -0,0 +1,141 @@
export const messagingMessages = {
"messageListHeader.sidebar.openSessionListAriaLabel": "פתח רשימת סשנים",
"messageListHeader.metrics.usedLabel": "בשימוש",
"messageListHeader.metrics.availableLabel": "זמין",
"messageListHeader.commandPalette.ariaLabel": "פתח לוח פקודות",
"messageListHeader.commandPalette.button": "לוח פקודות",
"messageListHeader.connection.connected": "מחובר",
"messageListHeader.connection.connecting": "מתחבר...",
"messageListHeader.connection.disconnected": "מנותק",
"messageSection.empty.logoAlt": "לוגו CodeNomad",
"messageSection.empty.brandTitle": "CodeNomad",
"messageSection.empty.title": "התחל שיחה",
"messageSection.empty.description": "הקלד הודעה למטה או פתח את לוח הפקודות:",
"messageSection.empty.tips.commandPalette": "לוח פקודות",
"messageSection.empty.tips.askAboutCodebase": "שאל על בסיס הקוד שלך",
"messageSection.empty.tips.attachFilesPrefix": "צרף קבצים עם",
"messageSection.loading.messages": "טוען הודעות...",
"messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה",
"messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה",
"messageSection.quote.addAsQuote": "הוסף כציטוט",
"messageSection.quote.addAsCode": "הוסף כקוד",
"messageSection.quote.copy": "העתק",
"messageSection.quote.copied": "הועתק!",
"messageSection.quote.copyFailed": "ההעתקה נכשלה",
"messageTimeline.ariaLabel": "ציר זמן הודעות",
"messageTimeline.segment.user.label": "אתה",
"messageTimeline.segment.assistant.label": "סוכן",
"messageTimeline.segment.compaction.label": "סיכום",
"messageTimeline.tool.fallbackLabel": "קריאת כלי",
"messageTimeline.tooltip.userFallback": "הודעת משתמש",
"messageTimeline.tooltip.assistantFallback": "תגובת הסוכן",
"messageTimeline.tooltip.compaction.auto": "סיכום אוטומטי",
"messageTimeline.tooltip.compaction.manual": "סיכום ידני",
"messageTimeline.text.filePrefix": "[קובץ] {filename}",
"messageTimeline.text.attachment": "קובץ מצורף",
"messageBlock.tool.header": "קריאת כלי",
"messageBlock.tool.unknown": "לא ידוע",
"messageBlock.tool.goToSession.label": "עבור לסשן",
"messageBlock.tool.goToSession.title": "עבור לסשן",
"messageBlock.tool.goToSession.unavailableTitle": "הסשן עדיין אינו זמין",
"messageBlock.tool.deletePart.label": "מחק חלק",
"messageBlock.tool.deletePart.deleting": "מוחק...",
"messageBlock.tool.deletePart.title": "מחק את פלט קריאת הכלי הזו",
"messageBlock.tool.deletePart.failed.title": "המחיקה נכשלה",
"messageBlock.tool.deletePart.failed.message": "מחיקת פלט קריאת הכלי נכשלה",
"messageBlock.compaction.ariaLabel": "סיכום סשן",
"messageBlock.compaction.autoLabel": "הסשן סוכם אוטומטית",
"messageBlock.compaction.manualLabel": "הסשן סוכם על ידך",
"messageBlock.usage.input": "קלט",
"messageBlock.usage.output": "פלט",
"messageBlock.usage.reasoning": "חשיבה",
"messageBlock.usage.cacheRead": "קריאת מטמון",
"messageBlock.usage.cacheWrite": "כתיבת מטמון",
"messageBlock.usage.cost": "עלות",
"messageBlock.step.agentLabel": "סוכן: {agent}",
"messageBlock.step.modelLabel": "מודל: {model}",
"messageBlock.reasoning.thinkingLabel": "חשיבה",
"messageBlock.reasoning.expandAriaLabel": "פרוס חשיבה",
"messageBlock.reasoning.collapseAriaLabel": "כווץ חשיבה",
"messageBlock.reasoning.indicator.hide": "הסתר",
"messageBlock.reasoning.indicator.view": "צפה",
"messageBlock.reasoning.detailsAriaLabel": "פרטי חשיבה",
"codeBlockInline.actions.copy": "העתק",
"codeBlockInline.actions.copied": "הועתק!",
"messageItem.speaker.you": "אתה",
"messageItem.speaker.assistant": "סוכן",
"messageItem.actions.revert": "בטל שינויים",
"messageItem.actions.revertTitle": "בטל שינויים עד כאן (מוחק הודעות)",
"messageItem.actions.fork": "פצל",
"messageItem.actions.forkTitle": "פצל מהודעה זו",
"messageItem.actions.copy": "העתק",
"messageItem.actions.copyTitle": "העתק הודעה",
"messageItem.actions.copied": "הועתק!",
"messageItem.actions.deleteMessage": "מחק הודעה (לא מבטל שינויים)",
"messageItem.actions.deleteMessagesUpTo": "מחק הודעות עד כאן (לא מבטל שינויים)",
"messageItem.actions.deletingMessage": "מוחק...",
"messageItem.actions.deleteMessageFailedTitle": "המחיקה נכשלה",
"messageItem.actions.deleteMessageFailedMessage": "מחיקת ההודעה נכשלה",
"messageItem.selection.checkboxAriaLabel": "בחר הודעה למחיקה",
"messageSection.bulkDelete.toolbarAriaLabel": "פריטים נבחרים ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "מחק פריטים נבחרים",
"messageSection.bulkDelete.selectAllTitle": "בחר את כל ההודעות",
"messageSection.bulkDelete.moreOptionsTitle": "אפשרויות נוספות",
"messageSection.bulkDelete.selectionModeLabel": "בחירה",
"messageSection.bulkDelete.selectionModeAll": "הכל",
"messageSection.bulkDelete.selectionModeTools": "כלים בלבד",
"messageSection.bulkDelete.selectionHint.toggle": "בחר פריט",
"messageSection.bulkDelete.selectionHint.range": "בחר טווח",
"messageSection.bulkDelete.selectionHint.clear": "נקה בחירה",
"messageSection.bulkDelete.cancelTitle": "בטל בחירה",
"messageSection.bulkDelete.failedTitle": "המחיקה נכשלה",
"messageSection.bulkDelete.failedMessage": "מחיקת הפריטים הנבחרים נכשלה",
"messageItem.status.queued": "בתור",
"messageItem.status.generating": "מייצר...",
"messageItem.status.sending": "שולח...",
"messageItem.status.failedToSend": "שליחת ההודעה נכשלה",
"messagePart.actions.delete": "מחק חלק",
"messagePart.actions.deleting": "מוחק...",
"messagePart.actions.deleteTitle": "מחק פריט זה",
"messagePart.actions.deleteFailedTitle": "המחיקה נכשלה",
"messagePart.actions.deleteFailedMessage": "מחיקת הפריט נכשלה",
"messageItem.attachment.defaultName": "קובץ מצורף",
"messageItem.attachment.downloadAriaLabel": "הורד {name}",
"messageItem.agentMeta.agentLabel": "סוכן: {agent}",
"messageItem.agentMeta.modelLabel": "מודל: {model}",
"messageItem.errors.authenticationFallback": "שגיאת אימות",
"messageItem.errors.outputLengthExceeded": "אורך פלט ההודעה חרג מהמגבלה",
"messageItem.errors.requestAborted": "הבקשה בוטלה",
"messageItem.errors.unknownFallback": "אירעה שגיאה לא ידועה",
"attachmentChip.removeAriaLabel": "הסר קובץ מצורף",
"expandButton.toggleAriaLabel": "שנה גובה תיבת הקלט",
"promptInput.placeholder.shell": "הפעל פקודת מעטפת (Esc ליציאה)...",
"promptInput.placeholder.default": "הקלד הודעה, @file, @agent, או הדבק תמונות וטקסט...",
"promptInput.hints.shell.exit": "לצאת ממצב מעטפת",
"promptInput.hints.shell.enable": "מצב מעטפת",
"promptInput.hints.commands": "פקודות",
"promptInput.history.previousAriaLabel": "פקודה קודמת",
"promptInput.history.nextAriaLabel": "פקודה הבאה",
"promptInput.overlay.newLine": "שורה חדשה",
"promptInput.overlay.send": "שלח",
"promptInput.overlay.filesAgents": "קבצים/סוכנים",
"promptInput.overlay.history": "היסטוריה",
"promptInput.overlay.attachments": "• {count} קובץ/ים מצורף/ים",
"promptInput.overlay.shellModeActive": "מצב מעטפת פעיל",
"promptInput.overlay.press": "לחץ",
"promptInput.overlay.againToAbort": "שוב כדי לבטל את הסשן",
"promptInput.stopSession.ariaLabel": "עצור סשן",
"promptInput.stopSession.title": "עצור סשן",
"promptInput.send.ariaLabel": "שלח הודעה",
"promptInput.send.errorFallback": "שליחת ההודעה נכשלה",
"promptInput.send.errorTitle": "השליחה נכשלה",
} as const

View File

@@ -0,0 +1,51 @@
export const remoteAccessMessages = {
"remoteAccess.eyebrow": "גישה מרוחקת",
"remoteAccess.title": "התחבר ל-CodeNomad מרחוק",
"remoteAccess.subtitle": "השתמש בכתובות למטה כדי לפתוח את CodeNomad ממכשיר אחר.",
"remoteAccess.close": "סגור גישה מרוחקת",
"remoteAccess.refresh": "רענן",
"remoteAccess.sections.listeningMode.label": "מצב האזנה",
"remoteAccess.sections.listeningMode.help": "אפשר או הגבל גישה מרוחקת על ידי קישור לכל הממשקים או רק ל-localhost.",
"remoteAccess.toggle.on": "פועל",
"remoteAccess.toggle.off": "כבוי",
"remoteAccess.toggle.title": "אפשר חיבורים מכתובות IP אחרות",
"remoteAccess.toggle.caption.all": "מקושר ל-0.0.0.0",
"remoteAccess.toggle.caption.local": "מקושר ל-127.0.0.1",
"remoteAccess.toggle.note": "שינוי זה דורש הפעלה מחדש ועוצר זמנית את כל המופעים הפעילים. שתף את הכתובות למטה לאחר שהשרת יופעל מחדש.",
"remoteAccess.listeningMode.restartConfirm.message": "להפעיל מחדש כדי להחיל מצב האזנה? פעולה זו תעצור את כל המופעים הפעילים.",
"remoteAccess.listeningMode.restartConfirm.title.all": "פתוח למכשירים אחרים",
"remoteAccess.listeningMode.restartConfirm.title.local": "מוגבל למכשיר זה",
"remoteAccess.listeningMode.restartConfirm.confirmLabel": "הפעל מחדש עכשיו",
"remoteAccess.listeningMode.restartConfirm.cancelLabel": "ביטול",
"remoteAccess.restart.errorManual": "לא ניתן להפעיל מחדש אוטומטית. אנא הפעל מחדש את האפליקציה כדי להחיל את השינוי.",
"remoteAccess.sections.serverPassword.label": "סיסמת שרת",
"remoteAccess.sections.serverPassword.help": "גישה מרוחקת דורשת סיסמה. הגדר סיסמה קלה לזכירה כדי לאפשר כניסות ממכשירים אחרים.",
"remoteAccess.authStatus.unavailable": "סטטוס האימות אינו זמין.",
"remoteAccess.username": "שם משתמש: {username}",
"remoteAccess.password.status.set": "סיסמה מוגדרת לגישה מרוחקת.",
"remoteAccess.password.status.unset": "לא הוגדרה סיסמה קלה לזכירה. הגדר סיסמה כדי לאפשר כניסות גישה מרוחקת.",
"remoteAccess.password.actions.cancel": "ביטול",
"remoteAccess.password.actions.change": "שנה סיסמה",
"remoteAccess.password.actions.set": "הגדר סיסמה",
"remoteAccess.password.form.newPassword": "סיסמה חדשה",
"remoteAccess.password.form.confirmPassword": "אשר סיסמה",
"remoteAccess.password.form.placeholder": "לפחות 8 תווים",
"remoteAccess.password.error.tooShort": "הסיסמה חייבת להכיל לפחות 8 תווים.",
"remoteAccess.password.error.mismatch": "הסיסמאות אינן תואמות.",
"remoteAccess.password.save.saving": "שומר…",
"remoteAccess.password.save.label": "שמור סיסמה",
"remoteAccess.sections.addresses.label": "כתובות נגישות",
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
"remoteAccess.addresses.loading": "טוען כתובות…",
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
"remoteAccess.address.scope.network": "רשת",
"remoteAccess.address.scope.loopback": "לולאה מקומית",
"remoteAccess.address.scope.internal": "פנימי",
"remoteAccess.address.open": "פתח",
"remoteAccess.address.showQr": "הצג QR",
"remoteAccess.address.hideQr": "הסתר QR",
"remoteAccess.address.qrAlt": "QR עבור {url}",
} as const

View File

@@ -0,0 +1,90 @@
export const sessionMessages = {
"sessionPicker.title": "OpenCode • {folder}",
"sessionPicker.empty.noPrevious": "אין סשנים קודמים",
"sessionPicker.resume.title": "המשך סשן ({count}):",
"sessionPicker.session.untitled": "ללא שם",
"sessionPicker.divider.or": "או",
"sessionPicker.new.title": "התחל סשן חדש:",
"sessionPicker.agents.loading": "טוען סוכנים...",
"sessionPicker.actions.creating": "יוצר...",
"sessionPicker.actions.createSession": "צור סשן",
"sessionPicker.actions.cancel": "ביטול",
"sessionList.header.title": "סשנים",
"sessionList.session.untitled": "ללא שם",
"sessionList.status.working": "עובד",
"sessionList.status.compacting": "מסכם",
"sessionList.status.idle": "מוכן",
"sessionList.status.needsPermission": "נדרש אישור",
"sessionList.status.needsInput": "נדרש קלט",
"sessionList.expand.collapseAriaLabel": "כווץ סשן",
"sessionList.expand.expandAriaLabel": "פרוס סשן",
"sessionList.expand.collapseTitle": "כווץ",
"sessionList.expand.expandTitle": "פרוס",
"sessionList.actions.newSession.ariaLabel": "סשן חדש",
"sessionList.actions.newSession.title": "סשן חדש",
"sessionList.actions.copyId.ariaLabel": "העתק מזהה סשן",
"sessionList.actions.copyId.title": "העתק מזהה סשן",
"sessionList.actions.rename.ariaLabel": "שנה שם סשן",
"sessionList.actions.rename.title": "שנה שם סשן",
"sessionList.actions.delete.ariaLabel": "מחק סשן",
"sessionList.actions.delete.title": "מחק סשן",
"sessionList.copyId.success": "מזהה סשן הועתק",
"sessionList.copyId.error": "לא ניתן להעתיק מזהה סשן",
"sessionList.delete.error": "לא ניתן למחוק סשן",
"sessionList.delete.title": "מחק סשן",
"sessionList.delete.confirmMessage": "למחוק את \"{label}\"? לא ניתן לבטל פעולה זו.",
"sessionList.delete.confirmLabel": "מחק",
"sessionList.delete.cancelLabel": "ביטול",
"sessionList.rename.error": "לא ניתן לשנות שם הסשן",
"sessionList.filter.placeholder": "חפש סשנים…",
"sessionList.filter.ariaLabel": "חפש סשנים",
"sessionList.selection.selectAllLabel": "בחר הכל",
"sessionList.selection.selectAllAriaLabel": "בחר את כל הסשנים",
"sessionList.selection.clearLabel": "נקה",
"sessionList.selection.clearAriaLabel": "נקה בחירה",
"sessionList.selection.checkboxAriaLabel": "בחר סשן",
"sessionList.bulkDelete.button": "מחק {count}",
"sessionList.bulkDelete.ariaLabel": "מחק {count} סשנים נבחרים",
"sessionList.bulkDelete.title": "מחק סשנים",
"sessionList.bulkDelete.confirmMessage": "למחוק {count} סשנים נבחרים? לא ניתן לבטל פעולה זו.",
"sessionList.bulkDelete.confirmLabel": "מחק",
"sessionList.bulkDelete.cancelLabel": "ביטול",
"sessionList.bulkDelete.error": "לא ניתן למחוק {count} סשנים",
"sessionRenameDialog.title": "שנה שם סשן",
"sessionRenameDialog.description.withLabel": "עדכן את הכותרת עבור \"{label}\".",
"sessionRenameDialog.description.default": "הגדר כותרת חדשה לסשן זה.",
"sessionRenameDialog.input.label": "שם סשן",
"sessionRenameDialog.input.placeholder": "הזן שם סשן",
"sessionRenameDialog.actions.cancel": "ביטול",
"sessionRenameDialog.actions.rename": "שנה שם",
"sessionRenameDialog.actions.renaming": "משנה שם…",
"sessionView.fallback.sessionNotFound": "הסשן לא נמצא",
"sessionView.alerts.abortFailed.message": "עצירת הסשן נכשלה",
"sessionView.alerts.abortFailed.title": "העצירה נכשלה",
"sessionView.alerts.revertFailed.message": "החזרה להודעה נכשלה",
"sessionView.alerts.revertFailed.title": "החזרה נכשלה",
"sessionView.alerts.deleteUpToFailed.message": "מחיקת הודעות נכשלה",
"sessionView.alerts.deleteUpToFailed.title": "המחיקה נכשלה",
"sessionView.alerts.forkFailed.message": "פיצול הסשן נכשל",
"sessionView.alerts.forkFailed.title": "הפיצול נכשל",
"sessionView.attachments.expandPastedTextAriaLabel": "פרוס טקסט שהודבק",
"sessionView.attachments.insertPastedTextTitle": "הכנס טקסט שהודבק",
"sessionView.attachments.removeAriaLabel": "הסר קובץ מצורף",
"sessionEvents.sessionCompactedToast": "הסשן {label} סוכם",
"sessionEvents.sessionError.unknown": "שגיאה לא ידועה",
"sessionEvents.sessionError.title": "שגיאת סשן",
"sessionEvents.sessionError.message": "שגיאה: {message}",
"sessionState.cleanup.deepConfirm.message": "ניקוי עמוק זה עשוי להיות איטי, ועלול למחוק סשנים שלא התכוונת למחוק. האם אתה בטוח?",
"sessionState.cleanup.deepConfirm.title": "ניקוי עמוק של סשנים",
"sessionState.cleanup.deepConfirm.detail": "ניקוי עמוק של סשנים ימחק את כל הסשנים ללא הודעות, יסיר סשני תת-סוכן שסיימו, וינקה פיצולים לא בשימוש של סשן.",
"sessionState.cleanup.deepConfirm.confirmLabel": "המשך",
"sessionState.cleanup.deepConfirm.cancelLabel": "ביטול",
"sessionState.cleanup.toast.one": "נוקה {count} סשן ריק",
"sessionState.cleanup.toast.other": "נוקו {count} סשנים ריקים",
} as const

View File

@@ -0,0 +1,142 @@
export const settingsMessages = {
"instanceServiceStatus.sections.lsp": "שרתי LSP",
"instanceServiceStatus.sections.mcp": "שרתי MCP",
"instanceServiceStatus.sections.plugins": "תוספים",
"instanceServiceStatus.lsp.loading": "טוען שרתי LSP...",
"instanceServiceStatus.lsp.empty": "לא זוהו שרתי LSP.",
"instanceServiceStatus.lsp.status.connected": "מחובר",
"instanceServiceStatus.lsp.status.error": "שגיאה",
"instanceServiceStatus.mcp.loading": "טוען שרתי MCP...",
"instanceServiceStatus.mcp.empty": "לא זוהו שרתי MCP.",
"instanceServiceStatus.mcp.toggleAriaLabel": "הפעל/כבה שרת MCP {name}",
"instanceServiceStatus.plugins.loading": "טוען תוספים...",
"instanceServiceStatus.plugins.empty": "לא הוגדרו תוספים.",
"permissionBanner.pendingRequests.one": "בקשה אחת ממתינה",
"permissionBanner.pendingRequests.other": "{count} בקשות ממתינות",
"permissionBanner.detail.permission.one": "אישור אחד",
"permissionBanner.detail.permission.other": "{count} אישורים",
"permissionBanner.detail.question.one": "שאלה אחת",
"permissionBanner.detail.question.other": "{count} שאלות",
"permissionBanner.detail.wrapper": " ({detail})",
"agentSelector.placeholder": "בחר סוכן...",
"agentSelector.badge.subagent": "תת-סוכן",
"agentSelector.none": "ללא",
"agentSelector.trigger.primary": "סוכן: {agent}",
"modelSelector.placeholder.search": "חפש מודלים...",
"modelSelector.none": "ללא",
"modelSelector.trigger.primary": "מודל: {model}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "הצג מועדפים בלבד",
"modelSelector.favoritesOnly.showAll": "הצג את כל המודלים",
"modelSelector.favorite.add": "הוסף למועדפים",
"modelSelector.favorite.remove": "הסר ממועדפים",
"thinkingSelector.variant.default": "ברירת מחדל",
"thinkingSelector.label": "חשיבה: {variant}",
"envEditor.title": "משתני סביבה",
"envEditor.count.one": "(משתנה אחד)",
"envEditor.count.other": "({count} משתנים)",
"envEditor.fields.name.placeholder": "שם משתנה",
"envEditor.fields.name.readOnlyTitle": "שם משתנה (לקריאה בלבד)",
"envEditor.fields.value.placeholder": "ערך משתנה",
"envEditor.actions.remove.title": "הסר משתנה",
"envEditor.actions.add.title": "הוסף משתנה",
"envEditor.empty": "לא הוגדרו משתני סביבה. הוסף משתנים למעלה להתאמת סביבת OpenCode.",
"envEditor.help": "משתנים אלו יהיו זמינים בסביבת OpenCode בעת הפעלת מופעים.",
"contextUsagePanel.headings.tokens": "טוקנים",
"contextUsagePanel.headings.context": "הקשר",
"contextUsagePanel.labels.input": "קלט",
"contextUsagePanel.labels.output": "פלט",
"contextUsagePanel.labels.cost": "עלות",
"contextUsagePanel.labels.used": "בשימוש",
"contextUsagePanel.labels.available": "זמין",
"contextUsagePanel.unavailable": "--",
"settings.title": "הגדרות",
"settings.navigationAriaLabel": "קטגוריות הגדרות",
"settings.close": "סגור הגדרות",
"settings.content.eyebrow": "העדפות סביבת עבודה",
"settings.open.title": "פתח הגדרות",
"settings.open.ariaLabel": "פתח הגדרות",
"settings.nav.appearance": "מראה",
"settings.nav.notifications": "התראות",
"settings.nav.remote": "גישה מרוחקת",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "מכשיר זה",
"settings.scope.server": "הגדרת שרת",
"settings.common.enabled": "מופעל",
"settings.common.disabled": "מושבת",
"settings.section.appearance.title": "מראה",
"settings.section.appearance.subtitle": "שנה כיצד האפליקציה נראית במכשיר זה.",
"settings.appearance.theme.title": "ערכת נושא",
"settings.appearance.theme.subtitle": "בחר את מצב הצבע שישמש בכל האפליקציה.",
"settings.appearance.theme.option.system": "התאם להגדרת מערכת ההפעלה",
"settings.appearance.theme.option.light": "השתמש במראה בהיר",
"settings.appearance.theme.option.dark": "השתמש במראה כהה",
"settings.section.notifications.title": "התראות",
"settings.section.notifications.subtitle": "שלוט בהתראות ברמת מערכת ההפעלה עבור פעילות סשן.",
"settings.notifications.permission.granted": "ניתן",
"settings.notifications.permission.denied": "נדחה",
"settings.notifications.permission.default": "לא ניתן",
"settings.notifications.permission.unsupported": "לא נתמך",
"settings.notifications.messages.unsupportedEnvironment": "התראות מערכת ההפעלה אינן נתמכות בסביבה זו.",
"settings.notifications.messages.permissionDenied": "הרשאת התראות נדחתה. הפעל התראות בהגדרות המערכת או הדפדפן.",
"settings.notifications.messages.permissionNotGranted": "הרשאת התראות לא ניתנה.",
"settings.notifications.messages.unsupportedGeneral": "התראות אינן נתמכות בסביבה זו.",
"settings.notifications.messages.permissionGranted": "ההרשאה ניתנה. כעת ניתן להפעיל התראות.",
"settings.notifications.messages.permissionRequestDenied": "ההרשאה נדחתה. ייתכן שתצטרך להפעיל התראות בהגדרות המערכת או הדפדפן.",
"settings.notifications.sessionStatus.title": "התראות סטטוס סשן",
"settings.notifications.sessionStatus.subtitle": "קבל התראות כאשר סשנים דורשים את תשומת לבך.",
"settings.notifications.enable.title": "הפעל התראות",
"settings.notifications.enable.permission": "הרשאה: {permission}",
"settings.notifications.requestPermission.title": "בקש הרשאה",
"settings.notifications.requestPermission.subtitle": "אפשר לאפליקציה לשלוח התראות במכשיר זה.",
"settings.notifications.requestPermission.action": "בקש",
"settings.notifications.allowVisible.title": "התרע כאשר האפליקציה ממוקדת",
"settings.notifications.allowVisible.subtitle": "שמור על התראות פעילות גם כאשר חלון זה גלוי.",
"settings.notifications.unsupportedNote": "התראות אינן נתמכות בסביבה זו. פקד ההתראות נשאר מושבת.",
"settings.notifications.events.title": "התרע אותי כאשר",
"settings.notifications.events.subtitle": "בחר אילו אירועי סשן ישלחו התראות.",
"settings.notifications.events.needsInput": "הסשן דורש קלט",
"settings.notifications.events.idle": "הסשן עובר למצב סרלה",
"settings.notifications.status.enabled": "התראות מופעלות",
"settings.notifications.status.disabled": "התראות מושבתות",
"settings.notifications.status.unsupported": "התראות לא נתמכות",
"settings.section.remote.title": "גישה מרוחקת",
"settings.section.remote.subtitle": "בדוק כיצד שרת זה חשוף ברשת שלך ואבטח אישורי גישה.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.",
"settings.opencode.runtime.title": "סביבת ריצה",
"settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.",
"settings.appearance.behavior.title": "אינטראקציה",
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
"settings.behavior.keyboardHints.title": "רמזי קיצורי מקלדת",
"settings.behavior.keyboardHints.subtitle": "הצג רמזי קיצורי מקלדת בכל הממשק.",
"settings.behavior.thinking.title": "קטעי חשיבה",
"settings.behavior.thinking.subtitle": "הצג או הסתר קטעי חשיבה של ה-AI בהודעות.",
"settings.behavior.thinkingDefault.title": "ברירת מחדל לחשיבה",
"settings.behavior.thinkingDefault.subtitle": "בחר האם קטעי חשיבה מתחילים פרוסים או מכווצים.",
"settings.behavior.timelineTools.title": "קריאות כלי בציר הזמן",
"settings.behavior.timelineTools.subtitle": "הצג או הסתר קריאות כלי בציר הודעות.",
"settings.behavior.diffView.title": "תצוגת diff",
"settings.behavior.diffView.subtitle": "בחר כיצד מוצגים diff של קריאות כלי.",
"settings.behavior.diffView.option.split": "מפוצל",
"settings.behavior.diffView.option.unified": "מאוחד",
"settings.behavior.toolOutputsDefault.title": "ברירת מחדל לפלטי כלים",
"settings.behavior.toolOutputsDefault.subtitle": "בחר האם פלטי כלים מתחילים פרוסים או מכווצים.",
"settings.behavior.diagnosticsDefault.title": "ברירת מחדל לאבחון",
"settings.behavior.diagnosticsDefault.subtitle": "בחר האם פלט אבחון מתחיל פרוס או מכווץ.",
"settings.behavior.toolInputsVisibility.title": "נראות קלטי כלים",
"settings.behavior.toolInputsVisibility.subtitle": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי.",
"settings.behavior.usageMetrics.title": "מדדי שימוש בטוקנים",
"settings.behavior.usageMetrics.subtitle": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן.",
"settings.behavior.autoCleanup.title": "ניקוי אוטומטי של סשנים ריקים",
"settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.",
"settings.behavior.promptSubmit.title": "Enter לשליחה",
"settings.behavior.promptSubmit.subtitle": "השתמש ב-Enter לשליחת פקודות; Cmd/Ctrl+Enter מוסיף שורה חדשה.",
} as const

View File

@@ -0,0 +1,6 @@
export const timeMessages = {
"time.relative.justNow": "עכשיו",
"time.relative.daysAgoShort": "לפני {count} ימים",
"time.relative.hoursAgoShort": "לפני {count} שעות",
"time.relative.minutesAgoShort": "לפני {count} דקות",
} as const

View File

@@ -0,0 +1,132 @@
export const toolCallMessages = {
"toolCall.pending.waitingToRun": "ממתין להרצה...",
"toolCall.error.label": "שגיאה:",
"toolCall.header.copyTitle": "העתק כותרת קריאת כלי",
"toolCall.header.copyAriaLabel": "העתק כותרת קריאת כלי",
"toolCall.header.showInputTitle": "הצג ארגומנטי כלי",
"toolCall.header.showInputAriaLabel": "הצג ארגומנטי כלי",
"toolCall.header.hideInputTitle": "הסתר ארגומנטי כלי",
"toolCall.header.hideInputAriaLabel": "הסתר ארגומנטי כלי",
"toolCall.io.input": "קלט כלי",
"toolCall.io.output": "פלט כלי",
"toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "מצב תצוגת diff",
"toolCall.diff.viewMode.split": "מפוצל",
"toolCall.diff.viewMode.unified": "מאוחד",
"toolCall.diagnostics.title": "אבחון",
"toolCall.diagnostics.ariaLabel": "אבחון",
"toolCall.diagnostics.ariaLabel.withLabel": "אבחון {label}",
"toolCall.diagnostics.severity.error.short": "שגיאה",
"toolCall.diagnostics.severity.warning.short": "אזהרה",
"toolCall.diagnostics.severity.info.short": "מידע",
"toolCall.renderer.toolName.shell": "מעטפת",
"toolCall.renderer.toolName.fetch": "Fetch",
"toolCall.renderer.toolName.invalid": "לא תקין",
"toolCall.renderer.toolName.plan": "תוכנית",
"toolCall.renderer.toolName.applyPatch": "החל תיקון",
"toolCall.renderer.action.working": "עובד...",
"toolCall.renderer.action.writingCommand": "כותב פקודה...",
"toolCall.renderer.action.preparingEdit": "מכין עריכה...",
"toolCall.renderer.action.readingFile": "קורא קובץ...",
"toolCall.renderer.action.preparingWrite": "מכין כתיבה...",
"toolCall.renderer.action.preparingPatch": "מכין תיקון...",
"toolCall.renderer.action.planning": "מתכנן...",
"toolCall.renderer.action.fetchingFromWeb": "מאחזר מהאינטרנט...",
"toolCall.renderer.action.findingFiles": "מחפש קבצים...",
"toolCall.renderer.action.searchingContent": "מחפש תוכן...",
"toolCall.renderer.action.listingDirectory": "מפרט ספרייה...",
"toolCall.renderer.bash.title.timeout": "פסק זמן: {timeout}",
"toolCall.renderer.read.detail.offset": "היסט: {offset}",
"toolCall.renderer.read.detail.limit": "מגבלה: {limit}",
"toolCall.renderer.todo.empty": "אין פריטי תוכנית עדיין.",
"toolCall.renderer.todo.status.pending": "ממתין",
"toolCall.renderer.todo.status.inProgress": "בביצוע",
"toolCall.renderer.todo.status.completed": "הושלם",
"toolCall.renderer.todo.status.cancelled": "בוטל",
"toolCall.renderer.todo.title.plan": "תוכנית",
"toolCall.renderer.todo.title.creating": "יוצר תוכנית",
"toolCall.renderer.todo.title.completing": "משלים תוכנית",
"toolCall.renderer.todo.title.updating": "מעדכן תוכנית",
"toolCall.permission.status.required": "נדרש אישור",
"toolCall.permission.status.queued": "אישור בתור",
"toolCall.permission.requestedDiff.label": "diff מבוקש",
"toolCall.permission.requestedDiff.withPath": "diff מבוקש · {path}",
"toolCall.permission.queuedText": "ממתין לתגובות אישור קודמות.",
"toolCall.permission.actions.allowOnce": "אפשר פעם אחת",
"toolCall.permission.actions.alwaysAllow": "אפשר תמיד",
"toolCall.permission.actions.deny": "דחה",
"toolCall.permission.shortcuts.allowOnce": "אפשר פעם אחת",
"toolCall.permission.shortcuts.alwaysAllow": "אפשר תמיד",
"toolCall.permission.shortcuts.deny": "דחה",
"toolCall.permission.errors.unableToUpdate": "לא ניתן לעדכן אישור",
"permissionApproval.title": "בקשות",
"permissionApproval.empty": "אין בקשות ממתינות.",
"permissionApproval.kind.permission": "אישור",
"permissionApproval.kind.question": "שאלה",
"permissionApproval.questionCount.one": "שאלה אחת",
"permissionApproval.questionCount.other": "{count} שאלות",
"permissionApproval.status.active": "פעיל",
"permissionApproval.actions.closeAriaLabel": "סגור",
"permissionApproval.actions.goToSession": "עבור לסשן",
"permissionApproval.actions.loadingSession": "טוען…",
"permissionApproval.actions.loadSession": "טען סשן",
"permissionApproval.actions.allowOnce": "אפשר פעם אחת",
"permissionApproval.actions.alwaysAllow": "אפשר תמיד",
"permissionApproval.actions.deny": "דחה",
"permissionApproval.fallbackHint": "טען סשן לקבלת מידע נוסף.",
"permissionApproval.errors.unableToUpdatePermission": "לא ניתן לעדכן אישור",
"toolCall.question.status.required": "נדרשת תשובה",
"toolCall.question.status.queued": "שאלה בתור",
"toolCall.question.status.questions": "שאלות",
"toolCall.question.action.awaitingAnswers": "ממתין לתשובות...",
"toolCall.question.title.questions": "שאלות",
"toolCall.question.title.askingQuestions": "שואל שאלות",
"toolCall.question.type.one": "שאלה",
"toolCall.question.type.other": "שאלות",
"toolCall.question.number": "ש{number}:",
"toolCall.question.multiple": "מרובות",
"toolCall.question.custom.title": "הקלד תשובה מותאמת אישית",
"toolCall.question.custom.label": "תשובה מותאמת אישית",
"toolCall.question.custom.placeholder": "הקלד תשובה משלך",
"toolCall.question.actions.submit": "שלח",
"toolCall.question.actions.dismiss": "סגור",
"toolCall.question.shortcuts.submit": "שלח",
"toolCall.question.shortcuts.dismiss": "סגור",
"toolCall.question.queuedText": "ממתין לתגובות קודמות.",
"toolCall.question.validation.answerAll": "אנא ענה על כל השאלות לפני השליחה.",
"toolCall.question.errors.unableToReply": "לא ניתן לשלוח תשובה",
"toolCall.question.errors.unableToDismiss": "לא ניתן לסגור",
"toolCall.task.action.delegating": "מאציל...",
"toolCall.task.sections.prompt": "פקודה",
"toolCall.task.sections.steps": "שלבים",
"toolCall.task.sections.output": "פלט",
"toolCall.task.steps.count": "{count} שלבים",
"toolCall.task.meta.agentModel": "סוכן: {agent} • מודל: {model}",
"toolCall.task.meta.agent": "סוכן: {agent}",
"toolCall.task.meta.model": "מודל: {model}",
"toolCall.status.pending": "ממתין",
"toolCall.status.running": "רץ",
"toolCall.status.completed": "הושלם",
"toolCall.status.error": "שגיאה",
"toolCall.status.unknown": "לא ידוע",
"toolCall.applyPatch.action.preparing": "מכין apply_patch...",
"toolCall.applyPatch.title.withFileCount.one": "{tool} (קובץ אחד)",
"toolCall.applyPatch.title.withFileCount.other": "{tool} ({count} קבצים)",
"toolCall.applyPatch.fileFallback": "קובץ {number}",
} as const

View File

@@ -90,6 +90,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "ステータスパネル",
"instanceShell.rightPanel.tabs.changes": "変更",
"instanceShell.rightPanel.tabs.gitChanges": "Git 変更",
"instanceShell.rightPanel.tabs.files": "ファイル",
"instanceShell.rightPanel.tabs.status": "ステータス",
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
@@ -112,6 +113,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} 個のファイルが変更されました",
"instanceShell.sessionChanges.actions.show": "変更を表示",
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
"instanceShell.gitChanges.empty": "Git の変更はまだありません。",
"instanceShell.gitChanges.deleted": "削除済み",
"instanceShell.filesShell.fileListTitle": "ファイル一覧",
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
"instanceShell.filesShell.mobileSelectorEmpty": "ファイルを選択してください",

View File

@@ -140,4 +140,11 @@ export const messagingMessages = {
"promptInput.send.ariaLabel": "メッセージを送信",
"promptInput.send.errorFallback": "メッセージの送信に失敗しました",
"promptInput.send.errorTitle": "送信に失敗",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,38 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "アシスタントのメッセージにトークン数とコストの統計を表示/非表示にします。",
"settings.behavior.autoCleanup.title": "空のセッションを自動クリーンアップ",
"settings.behavior.autoCleanup.subtitle": "新しいセッション作成時に空のセッションを自動的にクリーンアップします。",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Enterで送信",
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
"settings.speech.title": "音声",
"settings.speech.subtitle": "今すぐ音声入力を設定し、今後の機能のために音声合成の基盤も準備します。",
"settings.speech.provider.title": "プロバイダー",
"settings.speech.provider.subtitle": "音声リクエストはサーバー側の音声アダプターを使用します。",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "設定を確認しています...",
"settings.speech.status.configured": "設定済み",
"settings.speech.status.missing": "APIキーがありません",
"settings.speech.status.error": "音声サービスを利用できません",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "CodeNomadが管理する音声リクエストに使用されます。",
"settings.speech.apiKey.placeholder": "新しいAPIキーを入力",
"settings.speech.apiKey.storedNote": "保存済みのAPIキーは非表示になっています。置き換えるには新しい値を入力し、そのまま使うには空欄のままにしてください。",
"settings.speech.apiKey.clearAction": "保存済みキーを削除",
"settings.speech.apiKey.clearPending": "保存すると、保存済みのAPIキーは削除されます。",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "OpenAI互換の音声エンドポイント用の任意の上書き設定です。",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "文字起こしモデル",
"settings.speech.sttModel.subtitle": "プロンプトの音声入力を文字起こしする際に使用するモデルです。",
"settings.speech.ttsModel.title": "音声モデル",
"settings.speech.ttsModel.subtitle": "将来の再生機能のために予約されている既定の音声合成モデルです。",
"settings.speech.ttsVoice.title": "既定の音声",
"settings.speech.ttsVoice.subtitle": "将来の再生機能のために予約されている既定の音声合成ボイスです。",
"settings.speech.help": "プロンプト音声入力は、音声文字起こしが設定され、このブラウザーでサポートされている場合にのみ表示されます。",
"settings.speech.save.action": "保存",
"settings.speech.save.saving": "保存中...",
"settings.speech.save.saved": "保存済み",
"settings.speech.save.unsaved": "未保存の変更",
"settings.speech.save.error": "保存に失敗しました",
} as const

View File

@@ -90,6 +90,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Панель состояния",
"instanceShell.rightPanel.tabs.changes": "Изменения",
"instanceShell.rightPanel.tabs.gitChanges": "Изменения Git",
"instanceShell.rightPanel.tabs.files": "Файлы",
"instanceShell.rightPanel.tabs.status": "Статус",
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
@@ -112,6 +113,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "Изменено файлов: {count}",
"instanceShell.sessionChanges.actions.show": "Показать изменения",
"instanceShell.gitChanges.loading": "Загрузка изменений Git...",
"instanceShell.gitChanges.empty": "Изменений Git пока нет.",
"instanceShell.gitChanges.deleted": "Удалено",
"instanceShell.filesShell.fileListTitle": "Список файлов",
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
"instanceShell.filesShell.mobileSelectorEmpty": "Выберите файл",

View File

@@ -140,4 +140,11 @@ export const messagingMessages = {
"promptInput.send.ariaLabel": "Отправить сообщение",
"promptInput.send.errorFallback": "Не удалось отправить сообщение",
"promptInput.send.errorTitle": "Не удалось отправить",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,38 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Показывать или скрывать статистику токенов и стоимости в сообщениях ассистента.",
"settings.behavior.autoCleanup.title": "Автоочистка пустых сессий",
"settings.behavior.autoCleanup.subtitle": "Автоматически очищать пустые сессии при создании новых.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Enter для отправки",
"settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.",
"settings.speech.title": "Речь",
"settings.speech.subtitle": "Настройте преобразование речи в текст сейчас и подготовьте основу для синтеза речи в будущих функциях.",
"settings.speech.provider.title": "Провайдер",
"settings.speech.provider.subtitle": "Речевые запросы используют серверный речевой адаптер.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Проверка конфигурации...",
"settings.speech.status.configured": "Настроено",
"settings.speech.status.missing": "Отсутствует API-ключ",
"settings.speech.status.error": "Речевой сервис недоступен",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Используется для речевых запросов, управляемых CodeNomad.",
"settings.speech.apiKey.placeholder": "Введите новый API-ключ",
"settings.speech.apiKey.storedNote": "Сохранённый API-ключ скрыт. Введите новое значение, чтобы заменить его, или оставьте поле пустым, чтобы сохранить текущий ключ.",
"settings.speech.apiKey.clearAction": "Удалить сохранённый ключ",
"settings.speech.apiKey.clearPending": "Сохранённый API-ключ будет удалён после сохранения.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Необязательная переопределяющая ссылка для речевых endpoint'ов, совместимых с OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Модель распознавания",
"settings.speech.sttModel.subtitle": "Модель, используемая для преобразования голосового ввода в тексте запроса.",
"settings.speech.ttsModel.title": "Речевая модель",
"settings.speech.ttsModel.subtitle": "Модель синтеза речи по умолчанию, зарезервированная для будущих функций воспроизведения.",
"settings.speech.ttsVoice.title": "Голос по умолчанию",
"settings.speech.ttsVoice.subtitle": "Голос синтеза речи по умолчанию, зарезервированный для будущих функций воспроизведения.",
"settings.speech.help": "Голосовой ввод в поле запроса появляется только если распознавание речи настроено и поддерживается этим браузером.",
"settings.speech.save.action": "Сохранить",
"settings.speech.save.saving": "Сохранение...",
"settings.speech.save.saved": "Сохранено",
"settings.speech.save.unsaved": "Есть несохранённые изменения",
"settings.speech.save.error": "Не удалось сохранить",
} as const

View File

@@ -90,6 +90,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "状态面板",
"instanceShell.rightPanel.tabs.changes": "更改",
"instanceShell.rightPanel.tabs.gitChanges": "Git 更改",
"instanceShell.rightPanel.tabs.files": "文件",
"instanceShell.rightPanel.tabs.status": "状态",
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
@@ -112,6 +113,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "已更改 {count} 个文件",
"instanceShell.sessionChanges.actions.show": "显示更改",
"instanceShell.gitChanges.loading": "正在加载 Git 更改...",
"instanceShell.gitChanges.empty": "暂无 Git 更改。",
"instanceShell.gitChanges.deleted": "已删除",
"instanceShell.filesShell.fileListTitle": "文件列表",
"instanceShell.filesShell.mobileSelectorLabel": "选择文件",
"instanceShell.filesShell.mobileSelectorEmpty": "请选择文件",

Some files were not shown because too many files have changed in this diff Show More