Compare commits

...

45 Commits

Author SHA1 Message Date
Shantur Rathore
afc554ef98 fix(i18n): tighten RTL locale follow-up 2026-03-24 21:05:12 +00:00
MusiCode
46150cda5e fix(rtl): auto-detect text direction in reasoning block 2026-03-24 21:04:46 +00:00
MusiCode
0874f78ccf fix(rtl): fix file viewer Monaco direction + remove unrelated files
- Add direction: ltr to .monaco-viewer so the Monaco editor renders
  correctly when the document inherits dir="rtl" from Hebrew locale
- Replace physical margin-left with logical margin-inline-start on
  the refresh button in FilesTab
- Remove manifest.json (unrelated to RTL work, flagged in PR #229)
- Remove docs/rtl-hebrew-deployment.md (no longer needed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
88da377795 chore(release): update manifest for v0.12.5-rtl-he
Auto-generated by release.sh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
3533dabda0 chore(release): update manifest for v0.12.5-rtl-he
Auto-generated by release.sh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
25555ed42c chore(release): update manifest for v0.12.3-rtl-he
Auto-generated by release.sh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
df6c96453f fix(rtl): fix code block direction, selector alignment, and narrow-screen padding
- Add direction: ltr to pre elements so code always displays LTR in RTL UI
- Fix selector secondary text: text-left → text-start, add w-full to
  prevent RTL flex cross-axis drift
- Add dir="ltr" to model path span (opencode/model-id is always LTR)
- Restore padding-inline-start: 2.5rem in narrow-screen media query
  where padding shorthand was overriding it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
db3a786b48 fix(rtl): replace physical left/right CSS properties with logical equivalents
- border-l-[3px] → border-s-[3px] on .message-error-block (both CSS files)
- ml-auto → ms-auto on .message-step-time
- text-left → text-start on buttons and list items across panels and tool-call styles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
1e47389df3 fix(rtl): use logical ms-auto instead of ml-auto for connection status positioning
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
d7ae575042 fix(manifest): update sha256 for corrected RTL-he zip (329 files, no dist/ prefix)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
8346b7b631 chore: update sha256 in manifest for new RTL+Hebrew build
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:46 +00:00
MusiCode
c441d7d3ce fix(rtl): place textarea nav buttons at inline-start, away from scrollbar
Buttons were originally at right:0.25rem (physical), same side as the scrollbar
in LTR — a pre-existing overlap bug masked by overlay scrollbars on macOS.

Fix: move buttons to inset-inline-start so they are always opposite the scrollbar
in both LTR (buttons left, scrollbar right) and RTL (buttons right, scrollbar left).
Flip padding accordingly: inline-start gets 2.5rem, inline-end gets 0.75rem.

Also add direction:rtl override for RTL to fix dir="auto" defaulting to LTR
on an empty textarea.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:45 +00:00
MusiCode
be8fcc98c5 fix(rtl): force scrollbar to right in RTL textarea, buttons at inline-end
Use direction:ltr on the textarea in RTL mode to keep scrollbar on the right
(start side). Nav buttons remain at inset-inline-end (left/end in RTL).
Swap padding so left gets 2.5rem (for buttons) and right 0.75rem (for scrollbar gap).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:45 +00:00
MusiCode
658253a3fd fix(rtl): keep textarea nav buttons at physical right to avoid scrollbar overlap
In RTL, browser places textarea scrollbar on the left. Using inset-inline-end
put nav buttons also on the left, causing overlap. Keep physical right/padding-right
so buttons are always away from the scrollbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:45 +00:00
MusiCode
0e96662a07 fix(rtl): fix textarea padding direction in RTL
Replace physical pl-3/pr-10 with logical padding-inline-start/end
so the large padding (for nav buttons) is on the correct side in RTL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:45 +00:00
MusiCode
eb77c06571 fix(rtl): fix resize direction, path alignment, and i18n gaps
- Invert resize delta in RTL for drawer (useDrawerResize) and split panel (RightPanel)
- Add dir="ltr" to path/code value elements in instance-info panel
- Replace hardcoded English strings with i18n: Hide/Show files, No git changes yet,
  Hide unchanged regions / Show full file, diff toolbar titles, + Create worktree
- Fix sessionList.status.idle: "בסרלה" → "מוכן" in session.ts
- Fix text-align: left → start in message-reasoning-toggle and tool-call-io-toggle
- Fix left: 0 → inset-inline-start: 0 in attachment-chip-preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:45 +00:00
MusiCode
a6cb70ed41 fix(i18n): correct Hebrew translation for idle status
Replace nonsensical "בסרלה" with "מוכן" (ready) for instanceTab.status.idle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
13596e8082 feat(ui): add dynamic RTL layout support
- Sync document.documentElement.dir dynamically from I18nProvider
  based on selected locale (RTL for 'he', LTR for all others)
- Flip MUI Drawer anchor props (left/right) reactively via isRTL()
- Convert ~60 physical CSS directional properties to logical equivalents
  (border-inline-start/end, inset-inline-start/end, margin-inline-*, etc.)
- Add [dir="rtl"] overrides for translateX animations (sidebar slide,
  resize handle hit-area extensions, settings nav selection nudge)
- Preserve intentional direction:rtl + text-align:left truncation tricks
  (file path display in .truncate-start and .files-tab-selected-path)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
d9d56d77bc docs: add note about ui-dir path if zip has dist/ prefix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
c886344e2f fix: remove dist/ prefix from zip so ui-dir extraction works correctly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
69cb049a39 fix: correct sha256 in manifest (zip was built from wrong dist path)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
38cdb4ddb1 docs: add unzip as alternative extraction method
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
b11a9e3ec8 docs: add RTL+Hebrew deployment guide
Step-by-step guide covering npm global install (node only, not bun),
UI download and extraction, systemd user service setup, and update flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
268d23e9f6 chore: add UI manifest for RTL+Hebrew release v0.12.3-rtl-he
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +00:00
MusiCode
f266577c75 feat(i18n): add full Hebrew (he) locale translation
Translates all ~400 UI strings to Hebrew across 16 message modules.
Registers the 'he' locale in the i18n system and adds עברית to the
language picker. Built on top of the rtl-support branch so RTL layout
applies immediately when Hebrew is selected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:32 +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
95 changed files with 2956 additions and 1947 deletions

View File

@@ -1,88 +1,105 @@
name: Comment PR Artifacts name: Comment PR Artifacts
on: on:
workflow_run: pull_request_target:
workflows:
- PR Build Validation
types: types:
- completed - opened
- synchronize
- reopened
- ready_for_review
permissions: permissions:
actions: read actions: read
pull-requests: write contents: read
issues: write issues: write
pull-requests: write
jobs: jobs:
comment: comment:
# Only runs for PR Build Validation runs triggered by PRs.
if: ${{ github.event.workflow_run.event == 'pull_request' }}
runs-on: ubuntu-latest 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: steps:
- name: Comment with artifact download link - name: Check PR authorization
uses: actions/github-script@v7 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: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
const run = context.payload.workflow_run;
const owner = context.repo.owner; const owner = context.repo.owner;
const repo = context.repo.repo; 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 prs = run.pull_requests || []; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let prNumber = prs[0]?.number;
// `workflow_run` payload does not reliably include pull request numbers. let matchedRun = null;
// Resolve PR number(s) by asking GitHub for PRs associated with the head SHA. for (let attempt = 1; attempt <= 30; attempt += 1) {
if (!prNumber) { const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
const headSha = run.head_sha;
if (!headSha) {
core.info('No PR number and no head_sha found for this workflow_run; skipping.');
return;
}
const associated = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner, owner,
repo, repo,
commit_sha: headSha, workflow_id: 'pr-build.yml',
event: 'pull_request',
per_page: 100,
}); });
const open = (associated.data || []).find((p) => p.state === 'open'); const matchingRuns = runs
prNumber = open?.number; .filter((run) => run.head_sha === headSha)
if (!prNumber) { .sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
core.info(`No open PR found associated with commit ${headSha}; skipping.`);
return; 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);
} }
// Only comment when the PR build job actually ran (i.e. authorization passed). if (!matchedRun) {
// Unauthorized PRs targeting non-dev will skip the `build` job. core.setFailed(`Could not find PR Build Validation run for ${headSha}.`);
const jobs = await github.paginate( return;
github.rest.actions.listJobsForWorkflowRun, }
{ owner, repo, run_id: run.id, per_page: 100 }
); if (matchedRun.status !== 'completed') {
const buildJob = jobs.find((j) => j.name === 'build'); core.setFailed(`PR Build Validation run ${matchedRun.id} did not complete in time.`);
if (!buildJob) {
core.info('No `build` job found on this run; skipping.');
return;
}
if (buildJob.conclusion === 'skipped') {
core.info('`build` job was skipped; skipping comment.');
return; return;
} }
// List artifacts from the run. If none exist (e.g. build failed before packaging),
// still comment with the run link so testers can see logs.
const artifacts = await github.paginate( const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts, github.rest.actions.listWorkflowRunArtifacts,
{ owner, repo, run_id: run.id, per_page: 100 } { owner, repo, run_id: matchedRun.id, per_page: 100 }
); );
const active = artifacts.filter((a) => !a.expired); const active = artifacts.filter((artifact) => !artifact.expired);
const marker = '<!-- codenomad-pr-artifacts -->';
const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${run.id}`;
const retentionDays = 7;
const runUrl = matchedRun.html_url;
const artifactsBlock = active.length const artifactsBlock = active.length
? ['Artifacts:', ...active.map((a) => `- ${a.name}`)].join('\n') ? ['Artifacts:', ...active.map((artifact) => `- ${artifact.name}`)].join('\n')
: 'Artifacts: (none found on this run)'; : 'Artifacts: (none found on this run)';
const body = [ const body = [
@@ -95,26 +112,10 @@ jobs:
artifactsBlock, artifactsBlock,
].join('\n'); ].join('\n');
const comments = await github.paginate( const created = await github.rest.issues.createComment({
github.rest.issues.listComments, owner,
{ owner, repo, issue_number: prNumber, per_page: 100 } repo,
); issue_number: prNumber,
const existing = comments.find((c) => (c.body || '').includes(marker)); body,
});
if (existing) { core.info(`Created artifacts comment: ${created.data.html_url}`);
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
core.info(`Updated existing artifacts comment: ${existing.html_url}`);
} else {
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,6 +6,7 @@ on:
- opened - opened
- synchronize - synchronize
- reopened - reopened
- ready_for_review
permissions: permissions:
contents: read contents: read
@@ -45,7 +46,7 @@ jobs:
build: build:
needs: authorize 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 uses: ./.github/workflows/build-and-upload.yml
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}

31
package-lock.json generated
View File

@@ -10984,6 +10984,36 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"dev": true, "dev": true,
@@ -12113,6 +12143,7 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"virtua": "^0.48.8",
"yaml": "^2.4.2" "yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {

View File

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

View File

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

View File

@@ -46,4 +46,4 @@
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.6.3" "typescript": "^5.6.3"
} }
} }

View File

@@ -473,6 +473,7 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -1350,6 +1351,16 @@ dependencies = [
"version_check", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.1.16" version = "0.1.16"
@@ -1482,6 +1493,24 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" 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]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.18.0" version = "0.18.0"
@@ -4055,6 +4084,21 @@ dependencies = [
"url", "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]] [[package]]
name = "tauri-plugin-notification" name = "tauri-plugin-notification"
version = "2.3.3" version = "2.3.3"
@@ -5735,6 +5779,29 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.1" version = "0.8.1"

View File

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

View File

@@ -11,6 +11,7 @@
"core:menu:default", "core:menu:default",
"dialog:allow-open", "dialog:allow-open",
"opener:allow-default-urls", "opener:allow-default-urls",
"opener:allow-open-url",
"notification:allow-is-permission-granted", "notification:allow-is-permission-granted",
"notification:allow-request-permission", "notification:allow-request-permission",
"notification:allow-notify", "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", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "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`", "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", "type": "string",

View File

@@ -8,10 +8,14 @@ use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview; 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 tauri_plugin_opener::OpenerExt;
use url::Url; use url::Url;
@@ -25,6 +29,10 @@ use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); 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)] #[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; 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 struct AppState {
pub manager: CliProcessManager, pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>, pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
} }
#[derive(Debug, Default, Deserialize)] #[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)] #[cfg(windows)]
fn set_windows_app_user_model_id() { fn set_windows_app_user_model_id() {
let app_id: Vec<u16> = OsStr::new(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() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::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(tauri_plugin_notification::init())
.plugin(navigation_guard) .plugin(navigation_guard)
.manage(AppState { .manage(AppState {
manager: CliProcessManager::new(), manager: CliProcessManager::new(),
wake_lock: Mutex::new(None), wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
}) })
.setup(|app| { .setup(|app| {
set_windows_app_user_model_id(); set_windows_app_user_model_id();
build_menu(&app.handle())?; 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 dev_mode = is_dev_mode();
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone(); let manager = app.state::<AppState>().manager.clone();
@@ -214,36 +333,42 @@ fn main() {
let _ = window.emit("menu:newInstance", ()); let _ = window.emit("menu:newInstance", ());
} }
} }
"close" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
"quit" => { "quit" => {
app_handle.exit(0); app_handle.exit(0);
} }
// View menu // View menu
"reload" => { "reload" => {
if let Some(window) = app_handle.get_webview_window("main") { reload_main_window(app_handle);
let _ = window.eval("window.location.reload()");
}
} }
"force_reload" => { "force_reload" => {
if let Some(window) = app_handle.get_webview_window("main") { force_reload_main_window(app_handle);
let _ = window.eval("window.location.reload(true)");
}
} }
"toggle_devtools" => { "toggle_devtools" => {
if let Some(window) = app_handle.get_webview_window("main") { 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" => { "toggle_fullscreen" => {
if let Some(window) = app_handle.get_webview_window("main") { toggle_fullscreen_window(app_handle);
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
}
} }
// Window menu // Window menu
@@ -257,6 +382,11 @@ fn main() {
let _ = window.maximize(); let _ = window.maximize();
} }
} }
"close_window" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
// App menu (macOS) // App menu (macOS)
"about" => { "about" => {
@@ -344,6 +474,7 @@ fn main() {
fn build_menu(app: &AppHandle) -> tauri::Result<()> { fn build_menu(app: &AppHandle) -> tauri::Result<()> {
let is_mac = cfg!(target_os = "macos"); let is_mac = cfg!(target_os = "macos");
let is_linux = cfg!(target_os = "linux");
// Create submenus // Create submenus
let mut submenus = Vec::new(); let mut submenus = Vec::new();
@@ -371,16 +502,74 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
Some("CmdOrCtrl+N"), Some("CmdOrCtrl+N"),
)?; )?;
let file_menu = SubmenuBuilder::new(app, "File") let file_menu = if is_mac {
.item(&new_instance_item) SubmenuBuilder::new(app, "File")
.separator() .item(&new_instance_item)
.text( .separator()
if is_mac { "close" } else { "quit" }, .close_window()
if is_mac { "Close" } else { "Quit" }, .build()?
) } else {
.build()?; SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text("quit", "Quit")
.build()?
};
submenus.push(file_menu); 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 // Edit menu with predefined items for standard functionality
let edit_menu = SubmenuBuilder::new(app, "Edit") let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo() .undo()
@@ -396,20 +585,39 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
// View menu // View menu
let view_menu = SubmenuBuilder::new(app, "View") let view_menu = SubmenuBuilder::new(app, "View")
.text("reload", "Reload") .item(&reload_item)
.text("force_reload", "Force Reload") .item(&force_reload_item)
.text("toggle_devtools", "Toggle Developer Tools") .item(&toggle_devtools_item)
.separator() .separator()
.item(&reset_zoom_item)
.item(&zoom_in_item)
.item(&zoom_out_item)
.separator() .separator()
.text("toggle_fullscreen", "Toggle Full Screen") .item(&toggle_fullscreen_item)
.build()?; .build()?;
submenus.push(view_menu); submenus.push(view_menu);
// Window menu // Window menu
let window_menu = SubmenuBuilder::new(app, "Window") let window_menu = if is_linux {
.text("minimize", "Minimize") SubmenuBuilder::new(app, "Window")
.text("zoom", "Zoom") .text("minimize", "Minimize")
.build()?; .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); submenus.push(window_menu);
// Build the main menu with all submenus // Build the main menu with all submenus

View File

@@ -32,6 +32,7 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"virtua": "^0.48.8",
"yaml": "^2.4.2" "yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
@@ -44,4 +45,4 @@
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^1.2.0",
"vite-plugin-solid": "^2.10.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 InstanceShell from "./components/instance/instance-shell2"
import { SettingsScreen } from "./components/settings-screen" import { SettingsScreen } from "./components/settings-screen"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars" import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger" import { getLogger } from "./lib/logger"
@@ -59,7 +57,6 @@ import { openSettings } from "./stores/settings-screen"
const log = getLogger("actions") const log = getLogger("actions")
const App: Component = () => { const App: Component = () => {
const { isDark } = useTheme()
const { t } = useI18n() const { t } = useI18n()
const { const {
preferences, preferences,
@@ -183,10 +180,6 @@ const App: Component = () => {
} }
}) })
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => { createEffect(() => {
initReleaseNotifications() initReleaseNotifications()
}) })

View File

@@ -1,7 +1,8 @@
import { createSignal, onMount, Show, createEffect } from "solid-js" import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full" import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme" 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 { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"

View File

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

View File

@@ -13,8 +13,11 @@ import { formatCompactCount } from "../lib/formatters"
import { useI18n, type Locale } from "../lib/i18n" import { useI18n, type Locale } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen" 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 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 { interface FolderSelectionViewProps {
@@ -42,6 +45,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
{ value: "ru", label: "Русский" }, { value: "ru", label: "Русский" },
{ value: "ja", label: "日本語" }, { value: "ja", label: "日本語" },
{ value: "zh-Hans", label: "简体中文" }, { value: "zh-Hans", label: "简体中文" },
{ value: "he", label: "עברית" },
] ]
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0] const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
@@ -232,11 +236,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
props.onSelectFolder(path, selectedBinary()) props.onSelectFolder(path, selectedBinary())
} }
const openExternalLink = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
async function handleBrowse() { async function handleBrowse() {
if (isLoading()) return if (isLoading()) return
setFocusMode("new") 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" 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"} 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> <Select<LanguageOption>
value={selectedLanguageOption()} value={selectedLanguageOption()}
onChange={(value) => { onChange={(value) => {
@@ -387,7 +386,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Select.Portal> </Select.Portal>
</Select> </Select>
</div> </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 <button
type="button" type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" 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> <h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<div class="mt-3 flex justify-center gap-2"> <div class="mt-3 flex justify-center gap-2">
<a <a
href="https://github.com/NeuralNomadsAI/CodeNomad" href={GITHUB_URL}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" 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")} title={t("folderSelection.links.github")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad") void openExternalUrl(GITHUB_URL, "folder-selection")
}} }}
> >
<GitHubMarkIcon class="w-4 h-4" /> <GitHubMarkIcon class="w-4 h-4" />
</a> </a>
<a <a
href="https://github.com/NeuralNomadsAI/CodeNomad" href={GITHUB_URL}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5" 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")} title={t("folderSelection.links.githubStars")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad") void openExternalUrl(GITHUB_URL, "folder-selection")
}} }}
> >
<Star class="w-4 h-4" /> <Star class="w-4 h-4" />
@@ -456,7 +455,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show> </Show>
</a> </a>
<a <a
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945" href={DISCORD_URL}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" 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")} title={t("folderSelection.links.discord")}
onClick={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
openExternalLink( void openExternalUrl(DISCORD_URL, "folder-selection")
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
)
}} }}
> >
<DiscordSymbolIcon class="w-4 h-4" /> <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 class="panel-body space-y-3">
<div> <div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</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} {currentInstance().folder}
</div> </div>
</div> </div>
@@ -94,7 +94,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
{t("instanceInfo.labels.project")} {t("instanceInfo.labels.project")}
</div> </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} {project().id}
</div> </div>
</div> </div>
@@ -137,7 +137,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1"> <div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
{t("instanceInfo.labels.binaryPath")} {t("instanceInfo.labels.binaryPath")}
</div> </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} {currentInstance().binaryPath}
</div> </div>
</div> </div>
@@ -151,7 +151,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="space-y-1"> <div class="space-y-1">
<For each={environmentEntries()}> <For each={environmentEntries()}>
{([key, value]) => ( {([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}> <span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
{key} {key}
</span> </span>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
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 { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface ChangesTabProps { interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -113,15 +115,23 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<MonacoDiffViewer <Suspense
scopeKey={scopeKey()} fallback={
path={String(file().file || "")} <div class="file-viewer-empty">
before={String((file() as any).before || "")} <span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
after={String((file() as any).after || "")} </div>
viewMode={props.diffViewMode()} }
contextMode={props.diffContextMode()} >
wordWrap={props.diffWordWrapMode()} <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> </Show>
</div> </div>
@@ -220,7 +230,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} 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 type { FileNode } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
const LazyMonacoFileViewer = lazy(() =>
import("../../../../file-viewer/monaco-file-viewer").then((module) => ({ default: module.MonacoFileViewer })),
)
interface FilesTabProps { interface FilesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -51,8 +53,8 @@ const FilesTab: Component<FilesTabProps> = (props) => {
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath() const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
const emptyViewerMessage = () => { const emptyViewerMessage = () => {
if (props.browserLoading() && entriesValue === null) return "Loading files..." if (props.browserLoading() && entriesValue === null) return props.t("instanceInfo.loading")
return "Select a file to preview" return props.t("instanceShell.filesShell.viewerEmpty")
} }
const renderViewer = () => ( const renderViewer = () => (
@@ -77,7 +79,15 @@ const FilesTab: Component<FilesTabProps> = (props) => {
} }
> >
{(payload) => ( {(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> </Show>
} }
@@ -91,7 +101,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
} }
> >
<div class="file-viewer-empty"> <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> </div>
</Show> </Show>
</div> </div>
@@ -113,7 +123,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</Show> </Show>
<Show when={props.browserLoading() && entriesValue === null}> <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> </Show>
<For each={sorted}> <For each={sorted}>
@@ -154,7 +164,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</span> </span>
</span> </span>
<Show when={props.browserLoading()}> <Show when={props.browserLoading()}>
<span>Loading</span> <span>{props.t("instanceInfo.loading")}</span>
</Show> </Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show> <Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div> </div>
@@ -165,7 +175,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
title={props.t("instanceShell.rightPanel.actions.refresh")} title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")} aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={props.browserLoading()} disabled={props.browserLoading()}
style={{ "margin-left": "auto" }} style={{ "margin-inline-start": "auto" }}
onClick={() => props.onRefresh()} onClick={() => props.onRefresh()}
> >
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} /> <RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
@@ -180,7 +190,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} 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 type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface GitChangesTabProps { interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -80,11 +82,11 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
}) })
const emptyViewerMessage = createMemo(() => { const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return "Select a session to view changes." if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
const currentEntries = entries() const currentEntries = entries()
if (currentEntries === null) return "Loading git changes…" if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
if (nonDeleted().length === 0) return "No git changes yet." if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
return "No file selected." return props.t("instanceShell.filesShell.viewerEmpty")
}) })
const renderContent = (): JSX.Element => { const renderContent = (): JSX.Element => {
@@ -122,7 +124,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(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()} scopeKey={props.scopeKey()}
path={String(file().path || "")} path={String(file().path || "")}
before={String((file() as any).before || "")} before={String((file() as any).before || "")}
@@ -131,7 +140,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()} wordWrap={props.diffWordWrapMode()}
/> />
)} </Suspense>
)}
</Show> </Show>
} }
> >
@@ -144,7 +154,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
<div class="file-viewer-empty"> <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> </div>
</Show> </Show>
</div> </div>
@@ -169,7 +179,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div> </div>
<div class="file-list-item-stats"> <div class="file-list-item-stats">
<Show when={item.status === "deleted"}> <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>
<Show when={item.status !== "deleted"}> <Show when={item.status !== "deleted"}>
<> <>
@@ -200,7 +210,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div> </div>
<div class="file-list-item-stats"> <div class="file-list-item-stats">
<Show when={item.status === "deleted"}> <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>
<Show when={item.status !== "deleted"}> <Show when={item.status !== "deleted"}>
<> <>
@@ -220,8 +230,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
<SplitFilePanel <SplitFilePanel
header={ header={
<> <>
<span class="files-tab-selected-path" title={selected?.path || "Git Changes"}> <span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
<span class="file-path-text">{selected?.path || "Git Changes"}</span> <span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
</span> </span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}> <div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
@@ -264,7 +274,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} 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 if (!side) return
const startWidth = resizeStartWidth() const startWidth = resizeStartWidth()
const clamp = side === "left" ? options.clampLeft : options.clampRight 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) const nextWidth = clamp(startWidth + delta)
applyDrawerWidth(side, nextWidth) applyDrawerWidth(side, nextWidth)
} }

View File

@@ -1,5 +1,4 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" 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 { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message" import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
@@ -8,6 +7,20 @@ import { useI18n } from "../lib/i18n"
const log = getLogger("session") 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 { function hashText(value: string): string {
let hash = 2166136261 let hash = 2166136261
for (let index = 0; index < value.length; index++) { for (let index = 0; index < value.length; index++) {
@@ -24,6 +37,45 @@ function resolvePartVersion(part: TextPart, text: string): string {
return `text-${hashText(text)}` 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 { interface MarkdownProps {
part: TextPart part: TextPart
instanceId?: string instanceId?: string
@@ -38,7 +90,8 @@ export function Markdown(props: MarkdownProps) {
const { t } = useI18n() const { t } = useI18n()
const [html, setHtml] = createSignal("") const [html, setHtml] = createSignal("")
let containerRef: HTMLDivElement | undefined let containerRef: HTMLDivElement | undefined
let latestRequestedText = "" let latestRequestKey = ""
let cleanupLanguageListener: (() => void) | undefined
const notifyRendered = () => { const notifyRendered = () => {
Promise.resolve().then(() => props.onRendered?.()) Promise.resolve().then(() => props.onRendered?.())
@@ -47,15 +100,14 @@ export function Markdown(props: MarkdownProps) {
const resolved = createMemo(() => { const resolved = createMemo(() => {
const part = props.part const part = props.part
const rawText = typeof part.text === "string" ? part.text : "" const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText) const text = decodeHtmlEntitiesLocally(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light" const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight const highlightEnabled = !props.disableHighlight
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : "" const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
if (!partId) { const cacheId = resolvePartCacheId(part, text)
throw new Error("Markdown rendering requires a part id")
}
const version = resolvePartVersion(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({ const cacheHandle = useGlobalCache({
@@ -63,26 +115,46 @@ export function Markdown(props: MarkdownProps) {
sessionId: () => props.sessionId, sessionId: () => props.sessionId,
scope: "markdown", scope: "markdown",
cacheId: () => { cacheId: () => {
const { partId, themeKey, highlightEnabled } = resolved() const { cacheId, themeKey, highlightEnabled } = resolved()
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}` return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
}, },
version: () => resolved().version, version: () => resolved().version,
}) })
createEffect(async () => { const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
const { part, text, themeKey, highlightEnabled, version } = resolved() 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. const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
setMarkdownTheme(themeKey === "dark") 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) => { const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false 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)) { if (localCache && cacheMatches(localCache)) {
setHtml(localCache.html) setHtml(localCache.html)
notifyRendered() notifyRendered()
@@ -96,111 +168,83 @@ export function Markdown(props: MarkdownProps) {
return return
} }
const commitCacheEntry = (renderedHtml: string) => { setHtml(renderFallbackHtml(snapshot.text))
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version } notifyRendered()
setHtml(renderedHtml)
cacheHandle.set(cacheEntry)
notifyRendered()
}
if (!highlightEnabled) { void renderSnapshot(snapshot).catch((error) => {
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) {
log.error("Failed to render markdown:", error) log.error("Failed to render markdown:", error)
if (latestRequestedText === text) { if (latestRequestKey === snapshot.requestKey) {
commitCacheEntry(text) commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
} }
} })
}) })
onMount(() => { onMount(() => {
const handleClick = async (e: Event) => { const handleClick = async (event: Event) => {
const target = e.target as HTMLElement const target = event.target as HTMLElement
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
if (copyButton) { if (!copyButton) {
e.preventDefault() return
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)
}
}
}
} }
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) containerRef?.addEventListener("click", handleClick)
const cleanupLanguageListener = onLanguagesLoaded(async () => { let disposed = false
if (props.disableHighlight) { void loadMarkdownModule()
return .then((markdown) => {
} if (disposed) {
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()
} }
} 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(() => { onCleanup(() => {
disposed = true
containerRef?.removeEventListener("click", handleClick) containerRef?.removeEventListener("click", handleClick)
cleanupLanguageListener() cleanupLanguageListener?.()
cleanupLanguageListener = undefined
}) })
}) })
const proseClass = () => "markdown-body"
return ( return (
<div <div
ref={containerRef} ref={containerRef}
class={proseClass()} class="markdown-body"
dir="auto"
data-view="markdown" data-view="markdown"
data-part-id={resolved().partId} data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey} 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 { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message" import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } 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 ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-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" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -500,16 +505,18 @@ function ToolCallItem(props: ToolCallItemProps) {
</div> </div>
</div> </div>
<ToolCall <Suspense fallback={<ToolCallFallback />}>
toolCall={resolvedToolPart()} <LazyToolCall
toolCallId={props.partId} toolCall={resolvedToolPart()}
messageId={props.messageId} toolCallId={props.partId}
messageVersion={messageVersion()} messageId={props.messageId}
partVersion={partVersion()} messageVersion={messageVersion()}
instanceId={props.instanceId} partVersion={partVersion()}
sessionId={props.sessionId} instanceId={props.instanceId}
onContentRendered={props.onContentRendered} sessionId={props.sessionId}
/> onContentRendered={props.onContentRendered}
/>
</Suspense>
</div> </div>
)} )}
</Show> </Show>
@@ -902,6 +909,7 @@ export default function MessageBlock(props: MessageBlockProps) {
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds} selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage} onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/> />
</Match> </Match>
</Switch> </Switch>
@@ -1280,6 +1288,7 @@ interface ReasoningCardProps {
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void> onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string> selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onContentRendered?: () => void
} }
function ReasoningCard(props: ReasoningCardProps) { function ReasoningCard(props: ReasoningCardProps) {
@@ -1288,6 +1297,25 @@ function ReasoningCard(props: ReasoningCardProps) {
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) 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(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
@@ -1356,6 +1384,12 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () => const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view") expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
createEffect(() => {
if (!expanded()) return
reasoningText()
notifyContentRendered()
})
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => { const handleDeleteMessage = async (event: MouseEvent) => {
@@ -1497,7 +1531,7 @@ function ReasoningCard(props: ReasoningCardProps) {
<div class="message-reasoning-expanded"> <div class="message-reasoning-expanded">
<div class="message-reasoning-body"> <div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}> <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> </div>
</div> </div>

View File

@@ -542,7 +542,7 @@ export default function MessageItem(props: MessageItemProps) {
</header> </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()}> <Show when={props.isQueued && isUser()}>
@@ -550,7 +550,7 @@ export default function MessageItem(props: MessageItemProps) {
</Show> </Show>
<Show when={errorMessage()}> <Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div> <div class="message-error-block" dir="auto"> {errorMessage()}</div>
</Show> </Show>
<Show when={isGenerating()}> <Show when={isGenerating()}>

View File

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

View File

@@ -19,7 +19,7 @@ import type { DeleteHoverState } from "../types/delete-hover"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils" 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 MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
const QUOTE_SELECTION_MAX_LENGTH = 2000 const QUOTE_SELECTION_MAX_LENGTH = 2000
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href 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") })} {t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
</span> </span>
{currentModelValue() && ( {currentModelValue() && (
<span class="selector-trigger-secondary"> <span class="selector-trigger-secondary" dir="ltr">
{currentModelValue()!.providerId}/{currentModelValue()!.id} {currentModelValue()!.providerId}/{currentModelValue()!.id}
</span> </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 type { PermissionRequestLike } from "../types/permission"
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question" import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
@@ -12,7 +12,8 @@ import {
} from "../stores/instances" } from "../stores/instances"
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import ToolCall from "./tool-call"
const LazyToolCall = lazy(() => import("./tool-call"))
interface PermissionApprovalModalProps { interface PermissionApprovalModalProps {
instanceId: string instanceId: string
@@ -408,15 +409,17 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
} }
> >
{(data) => ( {(data) => (
<ToolCall <Suspense fallback={<div class="tool-call tool-call-loading" />}>
toolCall={data().toolPart} <LazyToolCall
toolCallId={data().toolPart.id} toolCall={data().toolPart}
messageId={data().messageId} toolCallId={data().toolPart.id}
messageVersion={data().messageVersion} messageId={data().messageId}
partVersion={data().partVersion} messageVersion={data().messageVersion}
instanceId={props.instanceId} partVersion={data().partVersion}
sessionId={data().sessionId} instanceId={props.instanceId}
/> sessionId={data().sessionId}
/>
</Suspense>
)} )}
</Show> </Show>
</div> </div>

View File

@@ -1,9 +1,9 @@
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js" import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button" import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments" import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
import Kbd from "./kbd" import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances" import { getActiveInstance } from "../stores/instances"
import { agents, executeCustomCommand } from "../stores/sessions" import { agents, executeCustomCommand } from "../stores/sessions"
@@ -13,11 +13,41 @@ import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { preferences } from "../stores/preferences" import { preferences } from "../stores/preferences"
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types" import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
import type { Attachment } from "../types/attachment"
import { usePromptState } from "./prompt-input/usePromptState" import { usePromptState } from "./prompt-input/usePromptState"
import { usePromptAttachments } from "./prompt-input/usePromptAttachments" import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
import { usePromptPicker } from "./prompt-input/usePromptPicker" import { usePromptPicker } from "./prompt-input/usePromptPicker"
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
const log = getLogger("actions") 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) { export default function PromptInput(props: PromptInputProps) {
const { t } = useI18n() const { t } = useI18n()
@@ -246,7 +276,12 @@ export default function PromptInput(props: PromptInputProps) {
commandName.length > 0 && commandName.length > 0 &&
getCommands(props.instanceId).some((cmd) => cmd.name === commandName) 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 historyEntry = resolvedPrompt
const refreshHistory = () => recordHistoryEntry(historyEntry) const refreshHistory = () => recordHistoryEntry(historyEntry)
@@ -262,6 +297,10 @@ export default function PromptInput(props: PromptInputProps) {
syncAttachmentCounters("") syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} else { } else {
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
for (const attachmentId of consumedIds) {
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}
syncAttachmentCounters("") syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} }
@@ -281,7 +320,7 @@ export default function PromptInput(props: PromptInputProps) {
await props.onSend(resolvedPrompt, []) await props.onSend(resolvedPrompt, [])
} }
} else if (isKnownSlashCommand) { } else if (isKnownSlashCommand) {
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs) await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
} else { } else {
await props.onSend(resolvedPrompt, currentAttachments) await props.onSend(resolvedPrompt, currentAttachments)
} }
@@ -428,18 +467,20 @@ export default function PromptInput(props: PromptInputProps) {
onDrop={handleDrop} onDrop={handleDrop}
> >
<Show when={showPicker() && instance()}> <Show when={showPicker() && instance()}>
<UnifiedPicker <Suspense fallback={null}>
open={showPicker()} <LazyUnifiedPicker
mode={pickerMode()} open={showPicker()}
onClose={handlePickerClose} mode={pickerMode()}
onSelect={handlePickerSelect} onClose={handlePickerClose}
agents={instanceAgents()} onSelect={handlePickerSelect}
commands={getCommands(props.instanceId)} agents={instanceAgents()}
instanceClient={instance()!.client} commands={getCommands(props.instanceId)}
searchQuery={searchQuery()} instanceClient={instance()!.client}
textareaRef={textareaRef} searchQuery={searchQuery()}
workspaceId={props.instanceId} textareaRef={textareaRef}
/> workspaceId={props.instanceId}
/>
</Suspense>
</Show> </Show>
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col">
@@ -449,6 +490,7 @@ export default function PromptInput(props: PromptInputProps) {
<textarea <textarea
ref={textareaRef} ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`} class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
dir="auto"
placeholder={getPlaceholder()} placeholder={getPlaceholder()}
value={prompt()} value={prompt()}
onInput={handleInput} onInput={handleInput}

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ export function renderDiagnosticsSection(
{entry.displayPath} {entry.displayPath}
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span> <span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
</span> </span>
<span class="tool-call-diagnostic-message">{entry.message}</span> <span class="tool-call-diagnostic-message" dir="auto">{entry.message}</span>
</div> </div>
)} )}
</For> </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 { RenderCache } from "../../types/message"
import type { DiffViewMode } from "../../stores/preferences" import type { DiffViewMode } from "../../stores/preferences"
import { ToolCallDiffViewer } from "../diff-viewer"
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
import { getRelativePath } from "./utils" import { getRelativePath } from "./utils"
import { getCacheEntry } from "../../lib/global-cache" 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 = { type CacheHandle = {
get<T>(): T | undefined get<T>(): T | undefined
params(): unknown params(): unknown
@@ -16,6 +32,7 @@ type DiffPrefs = {
} }
export function createDiffContentRenderer(params: { export function createDiffContentRenderer(params: {
toolState: Accessor<ToolState | undefined>
preferences: Accessor<DiffPrefs> preferences: Accessor<DiffPrefs>
setDiffViewMode: (mode: DiffViewMode) => void setDiffViewMode: (mode: DiffViewMode) => void
isDark: Accessor<boolean> isDark: Accessor<boolean>
@@ -43,7 +60,10 @@ export function createDiffContentRenderer(params: {
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
const themeKey = params.isDark() ? "dark" : "light" 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 registerRef = disableScrollTracking ? registerUntracked : registerTracked
const baseEntryParams = cacheHandle.params() as any const baseEntryParams = cacheHandle.params() as any
@@ -101,15 +121,20 @@ export function createDiffContentRenderer(params: {
</button> </button>
</div> </div>
</div> </div>
<ToolCallDiffViewer {cachedHtml ? (
diffText={payload.diffText} <CachedDiffMarkup html={cachedHtml} onRendered={handleDiffRendered} />
filePath={payload.filePath} ) : (
theme={themeKey} <Suspense fallback={<pre class="tool-call-diff-fallback">{payload.diffText}</pre>}>
mode={diffMode()} <LazyToolCallDiffViewer
cachedHtml={cachedHtml} diffText={payload.diffText}
cacheEntryParams={cacheEntryParams as any} filePath={payload.filePath}
onRendered={handleDiffRendered} theme={themeKey}
/> mode={diffMode()}
cacheEntryParams={cacheEntryParams as any}
onRendered={handleDiffRendered}
/>
</Suspense>
)}
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )

View File

@@ -31,10 +31,9 @@ export function createMarkdownContentRenderer(params: {
const size = options.size || "default" const size = options.size || "default"
const disableHighlight = options.disableHighlight || false const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` 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 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) const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
if (shouldDeferMarkdown) { if (shouldDeferMarkdown) {
return ( return (
@@ -43,7 +42,7 @@ export function createMarkdownContentRenderer(params: {
ref={registerRef} ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll} 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 })} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )

View File

@@ -1,5 +1,5 @@
import { isRenderableDiffText } from "../../lib/diff-utils" 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 { ToolState } from "@opencode-ai/sdk/v2"
import type { DiffPayload } from "./types" import type { DiffPayload } from "./types"
import { getLogger } from "../../lib/logger" 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 { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX, on } from "solid-js"
import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item" import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48 const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const USER_SCROLL_INTENT_WINDOW_MS = 600 const USER_SCROLL_INTENT_WINDOW_MS = 600
@@ -122,55 +122,28 @@ export interface VirtualFollowListProps<T> {
} }
export default function VirtualFollowList<T>(props: 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 [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>() const [shellElement, setShellElement] = createSignal<HTMLDivElement | undefined>()
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null) const [virtuaHandle, setVirtuaHandle] = createSignal<VirtualizerHandle | undefined>()
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
const bottomSentinel = () => bottomSentinelSignal()
const isActive = () => (props.isActive ? props.isActive() : true) const isActive = () => (props.isActive ? props.isActive() : true)
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true) const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true) const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : 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 [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = 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 [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)) const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
let containerRef: HTMLDivElement | undefined let userScrollIntentUntil = 0
let shellRef: HTMLDivElement | undefined let lastUserScrollIntentDirection: "up" | "down" | null = null
let pendingScrollFrame: number | null = null let detachScrollIntentListeners: (() => void) | undefined
let pendingAnchorScroll: number | null = null let lastResetKey: string | number | undefined
let pendingAnchorCorrectionFrame: number | null = null
let pendingScrollCompensationScheduled = false
let pendingScrollCompensations = new Map<string, number>()
let scrollCompensationGen = 0
let pendingActiveScroll = false
let suppressAutoScrollOnce = false let suppressAutoScrollOnce = false
let pendingInitialScroll = true 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 = { const state: VirtualFollowListState = {
autoScroll, autoScroll,
@@ -181,7 +154,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
function markUserScrollIntent(direction?: "up" | "down" | null) { 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 userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
if (direction) { if (direction) {
lastUserScrollIntentDirection = direction lastUserScrollIntentDirection = direction
@@ -189,8 +162,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
function hasUserScrollIntent() { function hasUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now() return performance.now() <= userScrollIntentUntil
return now <= userScrollIntentUntil
} }
function attachScrollIntentListeners(element: HTMLDivElement | undefined) { 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 hasItems = props.items().length > 0
const bottomVisible = bottomSentinelVisible() setShowScrollBottomButton(hasItems && !atBottom)
const topVisible = topSentinelVisible() setShowScrollTopButton(hasItems && !atTop)
setShowScrollBottomButton(hasItems && !bottomVisible)
setShowScrollTopButton(hasItems && !topVisible)
}
function clearScrollToBottomFrames() { // Sync autoScroll state based on scroll position if it was a user scroll
if (scrollToBottomFrame !== null) { if (hasUserScrollIntent()) {
cancelAnimationFrame(scrollToBottomFrame) if (atBottom && !autoScroll()) {
scrollToBottomFrame = null setAutoScroll(true)
} } else if (!atBottom && autoScroll()) {
if (scrollToBottomDelayedFrame !== null) { setAutoScroll(false)
cancelAnimationFrame(scrollToBottomDelayedFrame) }
scrollToBottomDelayedFrame = null
} }
} }
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) { function scrollToBottom(immediate = true, options?: { suppressAutoAnchor?: boolean }) {
if (!containerRef) return const handle = virtuaHandle()
if (anchorLock()) { if (!handle) return
clearAnchorLock() if (options?.suppressAutoAnchor ?? !immediate) {
}
const sentinel = bottomSentinel()
const behavior: ScrollBehavior = immediate ? "auto" : "smooth"
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
if (suppressAutoAnchor) {
suppressAutoScrollOnce = true suppressAutoScrollOnce = true
} }
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior }) handle.scrollToIndex(props.items().length - 1, { align: "end", smooth: !immediate })
setAutoScroll(true) setAutoScroll(true)
} }
function requestScrollToBottom(immediate = true) { function scrollToTop(immediate = true) {
if (!isActive()) { const handle = virtuaHandle()
pendingActiveScroll = true if (!handle) return
return handle.scrollToIndex(0, { align: "start", smooth: !immediate })
}
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()
}
setAutoScroll(false) 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() { function handleScroll() {
if (!containerRef) return
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
const isUserScroll = hasUserScrollIntent() const isUserScroll = hasUserScrollIntent()
pendingScrollFrame = requestAnimationFrame(() => { if (isUserScroll) {
pendingScrollFrame = null if (lastUserScrollIntentDirection === "up" && autoScroll()) {
if (!containerRef) return setAutoScroll(false)
const previousScrollTop = lastKnownScrollTop
const currentScrollTop = containerRef.scrollTop
const deltaScrollTop = currentScrollTop - previousScrollTop
if (currentScrollTop !== lastKnownScrollTop) {
lastKnownScrollTop = currentScrollTop
} }
const atBottom = bottomSentinelVisible() }
updateScrollButtons()
props.onScroll?.()
const beforeAutoScroll = autoScroll() // Find active key (roughly the first visible item)
const handle = virtuaHandle()
const inferredDirection: "up" | "down" | null = if (handle) {
lastUserScrollIntentDirection ?? (deltaScrollTop < 0 ? "up" : deltaScrollTop > 0 ? "down" : null) const start = handle.findItemIndex(handle.scrollOffset)
const items = props.items()
// If the user scrolls manually, exit key-anchored mode. if (items[start]) {
if (isUserScroll && anchorLock()) { const key = props.getKey(items[start], start)
clearAnchorLock() if (key !== activeKey()) {
} setActiveKey(key)
props.onActiveKeyChange?.(key)
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)
} }
} }
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 = { const api: VirtualFollowListApi = {
scrollToTop: (opts) => scrollToTop(Boolean(opts?.immediate)), scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
scrollToBottom: (opts) => scrollToBottom(Boolean(opts?.immediate), { suppressAutoAnchor: opts?.suppressAutoAnchor }), scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
scrollToKey: (key, opts) => { scrollToKey: (key, opts) => {
if (typeof document === "undefined") return const index = props.items().findIndex((item, i) => props.getKey(item, i) === key)
const anchorId = getAnchorId(key) if (index === -1) return
const behavior = opts?.behavior ?? "smooth"
const block = opts?.block ?? "start"
const nextAutoScroll = opts?.setAutoScroll ?? false const nextAutoScroll = opts?.setAutoScroll ?? false
setAutoScroll(nextAutoScroll) setAutoScroll(nextAutoScroll)
if (!nextAutoScroll) { virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
if (anchorLock()) { },
clearAnchorLock() notifyContentRendered: () => {
} if (autoScroll()) {
setAnchorLock({ key, block }) scrollToBottom(true)
} 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 })
})
}, },
notifyContentRendered: () => handleContentRendered(),
setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)), setAutoScroll: (enabled) => setAutoScroll(Boolean(enabled)),
getAutoScroll: () => autoScroll(), getAutoScroll: () => autoScroll(),
getScrollElement: () => scrollElement(), getScrollElement: () => scrollElement(),
getShellElement: () => shellElement(), getShellElement: () => shellElement(),
} }
createEffect(() => { createEffect(() => props.registerApi?.(api))
props.registerApi?.(api) createEffect(() => props.registerState?.(state))
})
createEffect(() => { // Handle autoScroll (Follow) on items change
props.registerState?.(state) createEffect(on(() => props.items().length, (len, prevLen) => {
}) if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
requestAnimationFrame(() => scrollToBottom(true))
createEffect(() => {
const nextKey = props.resetKey?.()
if (nextKey === undefined) return
if (lastResetKey === undefined) {
lastResetKey = nextKey
return
} }
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 if (nextKey === lastResetKey) return
lastResetKey = nextKey lastResetKey = nextKey
setAutoScroll(initialAutoScroll())
// 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
pendingInitialScroll = true pendingInitialScroll = true
}))
setAnchorLock(null) // Initial scroll and session activation
setActiveKey(null)
setShowScrollTopButton(false)
setShowScrollBottomButton(false)
setTopSentinelVisible(true)
setBottomSentinelVisible(true)
setAutoScroll(Boolean(initialAutoScroll()))
lastKnownScrollTop = containerRef?.scrollTop ?? 0
lastUserScrollIntentDirection = null
})
let lastActiveState = false
createEffect(() => { createEffect(() => {
const active = isActive() const active = isActive()
if (active) { if (!active) return
resolvePendingActiveScroll() if (pendingInitialScroll && props.items().length > 0) {
if (!lastActiveState && autoScroll() && scrollToBottomOnActivate()) { pendingInitialScroll = false
requestScrollToBottom(true) if (initialScrollToBottom()) {
scrollToBottom(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()
})
})
} }
} else if (autoScroll() && scrollToBottomOnActivate()) { } else if (autoScroll() && scrollToBottomOnActivate()) {
pendingActiveScroll = true scrollToBottom(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()
} }
}) })
// Drop anchor lock if the anchored key is removed. return (
createEffect(() => { <div class="virtual-follow-list-shell" ref={shellElement => {
const lock = anchorLock() setShellElement(shellElement)
if (!lock) return props.onShellElementChange?.(shellElement)
const keys = props.items().map((item, idx) => props.getKey(item, idx)) }}>
if (!keys.includes(lock.key)) { <div
clearAnchorLock() 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(() => { <Show when={props.renderOverlay}>
if (props.items().length === 0) { <div class="virtual-follow-list-overlay">{props.renderOverlay!()}</div>
setShowScrollTopButton(false) </Show>
setShowScrollBottomButton(false)
setAutoScroll(true)
return
}
updateScrollIndicatorsFromVisibility()
})
createEffect(() => { <Show when={props.renderControls}>
const container = scrollElement() <div class="virtual-follow-list-controls-container">{props.renderControls!(state, api)}</div>
const topTarget = topSentinel() </Show>
const bottomTarget = bottomSentinel()
if (!container || !topTarget || !bottomTarget) return
if (typeof IntersectionObserver === "undefined") return
const margin = props.scrollSentinelMarginPx ?? DEFAULT_SCROLL_SENTINEL_MARGIN_PX <Show
when={
const observer = new IntersectionObserver( !props.renderControls &&
(entries) => { (showScrollTopButton() || showScrollBottomButton()) &&
let visibilityChanged = false props.scrollToTopAriaLabel &&
for (const entry of entries) { props.scrollToBottomAriaLabel
if (entry.target === topTarget) {
setTopSentinelVisible(entry.isIntersecting)
visibilityChanged = true
} else if (entry.target === bottomTarget) {
setBottomSentinelVisible(entry.isIntersecting)
visibilityChanged = true
}
} }
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"> <div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}> <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 class="message-scroll-icon" aria-hidden="true">
</span> </span>
</button> </button>
</Show> </Show>
<Show when={showScrollBottomButton()}> <Show when={showScrollBottomButton()}>
<button <button type="button" class="message-scroll-button" onClick={() => scrollToBottom()} aria-label={props.scrollToBottomAriaLabel!()}>
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
aria-label={labelBottom}
>
<span class="message-scroll-icon" aria-hidden="true"> <span class="message-scroll-icon" aria-hidden="true">
</span> </span>
@@ -902,71 +393,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
</Show> </Show>
</div> </div>
</Show> </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> </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, setWorktreeSlugForParentSession,
} from "../stores/worktrees" } from "../stores/worktrees"
import { sessions } from "../stores/sessions" import { sessions } from "../stores/sessions"
import { useI18n } from "../lib/i18n"
const log = getLogger("session") const log = getLogger("session")
@@ -25,8 +26,6 @@ type WorktreeOption =
| { kind: "action"; key: "__create__"; label: string } | { kind: "action"; key: "__create__"; label: string }
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor } | { 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) { function preventSelectPress(event: PointerEvent | MouseEvent) {
// Prevent Select.Item from treating this as a selection. // Prevent Select.Item from treating this as a selection.
// We intentionally prevent default to stop Kobalte's internal press handling. // We intentionally prevent default to stop Kobalte's internal press handling.
@@ -71,6 +70,7 @@ interface WorktreeSelectorProps {
} }
export default function WorktreeSelector(props: WorktreeSelectorProps) { export default function WorktreeSelector(props: WorktreeSelectorProps) {
const { t } = useI18n()
const [isOpen, setIsOpen] = createSignal(false) const [isOpen, setIsOpen] = createSignal(false)
const [createOpen, setCreateOpen] = createSignal(false) const [createOpen, setCreateOpen] = createSignal(false)
const [createSlug, setCreateSlug] = createSignal("") const [createSlug, setCreateSlug] = createSignal("")
@@ -99,7 +99,8 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
directory: wt.directory, directory: wt.directory,
raw: wt, 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>(() => { const selectedOption = createMemo<WorktreeOption | undefined>(() => {

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

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

View File

@@ -114,12 +114,26 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} files changed", "instanceShell.sessionChanges.filesChanged": "{count} files changed",
"instanceShell.sessionChanges.actions.show": "Show changes", "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.fileListTitle": "File list",
"instanceShell.filesShell.mobileSelectorLabel": "Select file", "instanceShell.filesShell.mobileSelectorLabel": "Select file",
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file", "instanceShell.filesShell.mobileSelectorEmpty": "Select a file",
"instanceShell.filesShell.viewerTitle": "Change viewer", "instanceShell.filesShell.viewerTitle": "Change viewer",
"instanceShell.filesShell.viewerPlaceholder": "Detailed change rendering will be added in the next step.", "instanceShell.filesShell.viewerPlaceholder": "Detailed change rendering will be added in the next step.",
"instanceShell.filesShell.viewerEmpty": "No file selected.", "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.noSessionSelected": "Select a session to view plan.",
"instanceShell.plan.empty": "Nothing planned yet.", "instanceShell.plan.empty": "Nothing planned yet.",

View File

@@ -90,6 +90,7 @@ export const instanceMessages = {
"instanceShell.rightPanel.title": "Panel de estado", "instanceShell.rightPanel.title": "Panel de estado",
"instanceShell.rightPanel.tabs.changes": "Cambios", "instanceShell.rightPanel.tabs.changes": "Cambios",
"instanceShell.rightPanel.tabs.gitChanges": "Cambios de Git",
"instanceShell.rightPanel.tabs.files": "Archivos", "instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado", "instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho", "instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
@@ -112,6 +113,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados", "instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
"instanceShell.sessionChanges.actions.show": "Mostrar cambios", "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.fileListTitle": "Lista de archivos",
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo", "instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
"instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo", "instanceShell.filesShell.mobileSelectorEmpty": "Selecciona un archivo",

View File

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

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { marked } from "marked" import { marked } from "marked"
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
import { getLogger } from "./logger" import { getLogger } from "./logger"
import { tGlobal } from "./i18n" import { tGlobal } from "./i18n"
import type { Highlighter } from "shiki/bundle/full"
import { decodeHtmlEntities, escapeHtml } from "./text-render-utils"
const log = getLogger("actions") const log = getLogger("actions")
@@ -11,43 +12,8 @@ let currentTheme: "light" | "dark" = "light"
let isInitialized = false let isInitialized = false
let highlightSuppressed = false let highlightSuppressed = false
let rendererSetup = false let rendererSetup = false
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
const extensionToLanguage: Record<string, string> = { let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
sh: "bash",
bash: "bash",
json: "json",
html: "html",
css: "css",
md: "markdown",
yaml: "yaml",
yml: "yaml",
sql: "sql",
rs: "rust",
go: "go",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
h: "cpp",
c: "c",
java: "java",
cs: "csharp",
php: "php",
rb: "ruby",
swift: "swift",
kt: "kotlin",
}
export function getLanguageFromPath(path?: string | null): string | undefined {
if (!path) return undefined
const ext = path.split(".").pop()?.toLowerCase()
return ext ? extensionToLanguage[ext] : undefined
}
// Track loaded languages and queue for on-demand loading // Track loaded languages and queue for on-demand loading
const loadedLanguages = new Set<string>() const loadedLanguages = new Set<string>()
@@ -89,10 +55,15 @@ async function getOrCreateHighlighter() {
return highlighterPromise return highlighterPromise
} }
// Create highlighter with no preloaded languages highlighterPromise = (async () => {
highlighterPromise = createHighlighter({ const shiki = await loadShikiModule()
themes: ["github-light", "github-light-high-contrast", "github-dark"], return shiki.createHighlighter({
langs: [], themes: ["github-light", "github-light-high-contrast", "github-dark"],
langs: [],
})
})().catch((error) => {
highlighterPromise = null
throw error
}) })
highlighter = await highlighterPromise highlighter = await highlighterPromise
@@ -100,12 +71,37 @@ async function getOrCreateHighlighter() {
return highlighter return highlighter
} }
async function loadShikiModule() {
if (!shikiModulePromise) {
shikiModulePromise = import("shiki/bundle/full").then((module) => {
bundledLanguagesCache = module.bundledLanguages
return module
})
}
return shikiModulePromise
}
function queueHighlighterWarmup() {
if (highlighter || highlighterPromise) {
return
}
void getOrCreateHighlighter().catch((error) => {
log.warn("Failed to initialize markdown highlighter", error)
})
}
function normalizeLanguageToken(token: string): string { function normalizeLanguageToken(token: string): string {
return token.trim().toLowerCase() return token.trim().toLowerCase()
} }
function resolveLanguage(token: string): { canonical: string | null; raw: string } { function resolveLanguage(token: string): { canonical: string | null; raw: string } {
const normalized = normalizeLanguageToken(token) const normalized = normalizeLanguageToken(token)
const bundledLanguages = bundledLanguagesCache
if (!bundledLanguages) {
return { canonical: null, raw: normalized }
}
// Check if it's a direct key match // Check if it's a direct key match
if (normalized in bundledLanguages) { if (normalized in bundledLanguages) {
@@ -148,32 +144,43 @@ async function ensureLanguages(content: string) {
// Queue language loading tasks // Queue language loading tasks
for (const token of foundLanguages) { for (const token of foundLanguages) {
const { canonical, raw } = resolveLanguage(token) const rawToken = normalizeLanguageToken(token)
const langKey = canonical || raw if (!rawToken) {
continue
}
// Skip "text" and aliases since Shiki handles plain text already // Skip "text" and aliases since Shiki handles plain text already
if (langKey === "text" || raw === "text") { if (rawToken === "text") {
continue continue
} }
// Skip if already loaded or queued // Skip if already loaded or queued
if (loadedLanguages.has(langKey) || queuedLanguages.has(langKey)) { if (loadedLanguages.has(rawToken) || queuedLanguages.has(rawToken)) {
continue continue
} }
queuedLanguages.add(langKey) queuedLanguages.add(rawToken)
// Queue the language loading task // Queue the language loading task
languageLoadQueue.push(async () => { languageLoadQueue.push(async () => {
try { try {
await loadShikiModule()
const { canonical, raw } = resolveLanguage(token)
const langKey = canonical || raw
if (langKey === "text" || raw === "text") {
return
}
const h = await getOrCreateHighlighter() const h = await getOrCreateHighlighter()
await h.loadLanguage(langKey as never) await h.loadLanguage(langKey as never)
loadedLanguages.add(langKey) loadedLanguages.add(langKey)
loadedLanguages.add(raw)
triggerLanguageListeners() triggerLanguageListeners()
} catch { } catch {
// Quietly ignore errors // Quietly ignore errors
} finally { } finally {
queuedLanguages.delete(langKey) queuedLanguages.delete(rawToken)
} }
}) })
} }
@@ -184,52 +191,6 @@ async function ensureLanguages(content: string) {
} }
} }
export function decodeHtmlEntities(content: string): string {
if (!content.includes("&")) {
return content
}
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
const namedEntities: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
}
let result = content
let previous = ""
while (result.includes("&") && result !== previous) {
previous = result
result = result.replace(entityPattern, (match, entity) => {
if (!entity) {
return match
}
if (entity[0] === "#") {
const isHex = entity[1]?.toLowerCase() === "x"
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
if (!Number.isNaN(value)) {
try {
return String.fromCodePoint(value)
} catch {
return match
}
}
return match
}
const decoded = namedEntities[entity.toLowerCase()]
return decoded !== undefined ? decoded : match
})
}
return result
}
async function runLanguageLoadQueue() { async function runLanguageLoadQueue() {
if (isQueueRunning || languageLoadQueue.length === 0) { if (isQueueRunning || languageLoadQueue.length === 0) {
return return
@@ -249,7 +210,6 @@ async function runLanguageLoadQueue() {
function setupRenderer(isDark: boolean) { function setupRenderer(isDark: boolean) {
currentTheme = isDark ? "dark" : "light" currentTheme = isDark ? "dark" : "light"
if (!highlighter) return
if (rendererSetup) return if (rendererSetup) return
marked.setOptions({ marked.setOptions({
@@ -330,8 +290,9 @@ function setupRenderer(isDark: boolean) {
} }
export async function initMarkdown(isDark: boolean) { export async function initMarkdown(isDark: boolean) {
await getOrCreateHighlighter()
setupRenderer(isDark) setupRenderer(isDark)
queueHighlighterWarmup()
await getOrCreateHighlighter()
isInitialized = true isInitialized = true
} }
@@ -350,15 +311,16 @@ export async function renderMarkdown(
}, },
): Promise<string> { ): Promise<string> {
if (!isInitialized) { if (!isInitialized) {
await initMarkdown(currentTheme === "dark") setupRenderer(currentTheme === "dark")
isInitialized = true
} }
const suppressHighlight = options?.suppressHighlight ?? false const suppressHighlight = options?.suppressHighlight ?? false
const decoded = decodeHtmlEntities(content) const decoded = decodeHtmlEntities(content)
if (!suppressHighlight) { if (!suppressHighlight) {
// Queue language loading but don't wait for it to complete queueHighlighterWarmup()
await ensureLanguages(decoded) void ensureLanguages(decoded)
} }
const previousSuppressed = highlightSuppressed const previousSuppressed = highlightSuppressed
@@ -375,13 +337,3 @@ export async function renderMarkdown(
export async function getSharedHighlighter(): Promise<Highlighter> { export async function getSharedHighlighter(): Promise<Highlighter> {
return getOrCreateHighlighter() return getOrCreateHighlighter()
} }
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<"']/g, (m) => map[m])
}

View File

@@ -0,0 +1,92 @@
const extensionToLanguage: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
py: "python",
sh: "bash",
bash: "bash",
json: "json",
html: "html",
css: "css",
md: "markdown",
yaml: "yaml",
yml: "yaml",
sql: "sql",
rs: "rust",
go: "go",
cpp: "cpp",
cc: "cpp",
cxx: "cpp",
hpp: "cpp",
h: "cpp",
c: "c",
java: "java",
cs: "csharp",
php: "php",
rb: "ruby",
swift: "swift",
kt: "kotlin",
}
export function getLanguageFromPath(path?: string | null): string | undefined {
if (!path) return undefined
const ext = path.split(".").pop()?.toLowerCase()
return ext ? extensionToLanguage[ext] : undefined
}
export function decodeHtmlEntities(content: string): string {
if (!content.includes("&")) {
return content
}
const entityPattern = /&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g
const namedEntities: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
}
let result = content
let previous = ""
while (result.includes("&") && result !== previous) {
previous = result
result = result.replace(entityPattern, (match, entity) => {
if (!entity) {
return match
}
if (entity[0] === "#") {
const isHex = entity[1]?.toLowerCase() === "x"
const value = isHex ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10)
if (!Number.isNaN(value)) {
try {
return String.fromCodePoint(value)
} catch {
return match
}
}
return match
}
const decoded = namedEntities[entity.toLowerCase()]
return decoded !== undefined ? decoded : match
})
}
return result
}
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<"']/g, (match) => map[match])
}

View File

@@ -4,7 +4,7 @@ import { ThemeProvider } from "./lib/theme"
import { ConfigProvider } from "./stores/preferences" import { ConfigProvider } from "./stores/preferences"
import { InstanceConfigProvider } from "./stores/instance-config" import { InstanceConfigProvider } from "./stores/instance-config"
import { runtimeEnv } from "./lib/runtime-env" import { runtimeEnv } from "./lib/runtime-env"
import { I18nProvider } from "./lib/i18n" import { I18nProvider, preloadLocaleMessages } from "./lib/i18n"
import { storage } from "./lib/storage" import { storage } from "./lib/storage"
import "./index.css" import "./index.css"
import "@git-diff-view/solid/styles/diff-view-pure.css" import "@git-diff-view/solid/styles/diff-view-pure.css"
@@ -31,15 +31,19 @@ async function bootstrap() {
try { try {
const uiConfig = await storage.loadConfigOwner("ui") const uiConfig = await storage.loadConfigOwner("ui")
const theme = (uiConfig as any)?.theme ?? "system" const theme = (uiConfig as any)?.theme
const locale = typeof (uiConfig as any)?.settings?.locale === "string" ? (uiConfig as any).settings.locale : undefined
if (theme === "system") { if (theme === "light" || theme === "dark") {
document.documentElement.removeAttribute("data-theme")
} else {
document.documentElement.setAttribute("data-theme", theme) document.documentElement.setAttribute("data-theme", theme)
} else {
document.documentElement.removeAttribute("data-theme")
} }
await preloadLocaleMessages(locale)
} catch { } catch {
// If config fails to load, fall back to CSS defaults. // If config fails to load, fall back to CSS defaults.
await preloadLocaleMessages()
} }
} }

View File

@@ -1,4 +1,4 @@
import { decodeHtmlEntities } from "../../lib/markdown" import { decodeHtmlEntities } from "../../lib/text-render-utils"
function decodeTextSegment(segment: any): any { function decodeTextSegment(segment: any): any {
if (typeof segment === "string") { if (typeof segment === "string") {

View File

@@ -77,6 +77,29 @@ function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean {
return false return false
} }
function isChildSession(instanceId: string, sessionId: string): boolean | null {
const session = sessions().get(instanceId)?.get(sessionId)
if (!session) return null
return session.parentId !== null && session.parentId !== undefined
}
function shouldSendOsNotificationForSession(
kind: "needsInput" | "idle",
instanceId: string,
sessionId: string | undefined | null,
): boolean {
if (!shouldSendOsNotification(kind)) return false
if (!sessionId) return true
const child = isChildSession(instanceId, sessionId)
// Avoid notification spam from spawned child/subagent sessions arriving before hydration.
if (child === null) return false
if (child) return false
return true
}
function getInstanceDisplayName(instanceId: string): string { function getInstanceDisplayName(instanceId: string): string {
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder return instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
@@ -492,7 +515,7 @@ function handleSessionIdle(instanceId: string, event: EventSessionIdle): void {
const sessionId = event.properties?.sessionID const sessionId = event.properties?.sessionID
if (!sessionId) return if (!sessionId) return
if (shouldSendOsNotification("idle")) { if (shouldSendOsNotificationForSession("idle", instanceId, sessionId)) {
const title = getInstanceDisplayName(instanceId) const title = getInstanceDisplayName(instanceId)
const label = getSessionTitle(instanceId, sessionId) const label = getSessionTitle(instanceId, sessionId)
const body = label ? `Session "${label}" is idle` : "Session is idle" const body = label ? `Session "${label}" is idle` : "Session is idle"
@@ -607,9 +630,10 @@ function handlePermissionUpdated(instanceId: string, event: { type: string; prop
addPermissionToQueue(instanceId, permission) addPermissionToQueue(instanceId, permission)
upsertPermissionV2(instanceId, permission) upsertPermissionV2(instanceId, permission)
if (shouldSendOsNotification("needsInput")) { const sessionId = getPermissionSessionId(permission)
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
const title = getInstanceDisplayName(instanceId) const title = getInstanceDisplayName(instanceId)
const sessionId = getPermissionSessionId(permission)
const label = getSessionTitle(instanceId, sessionId) const label = getSessionTitle(instanceId, sessionId)
const body = label ? `Session "${label}" needs permission` : "Session needs permission" const body = label ? `Session "${label}" needs permission` : "Session needs permission"
fireOsNotification({ title, body }) fireOsNotification({ title, body })
@@ -634,9 +658,10 @@ function handleQuestionAsked(instanceId: string, event: { type: string; properti
addQuestionToQueue(instanceId, request) addQuestionToQueue(instanceId, request)
upsertQuestionV2(instanceId, request) upsertQuestionV2(instanceId, request)
if (shouldSendOsNotification("needsInput")) { const sessionId = getQuestionSessionId(request)
if (shouldSendOsNotificationForSession("needsInput", instanceId, sessionId)) {
const title = getInstanceDisplayName(instanceId) const title = getInstanceDisplayName(instanceId)
const sessionId = getQuestionSessionId(request)
const label = getSessionTitle(instanceId, sessionId) const label = getSessionTitle(instanceId, sessionId)
const body = label ? `Session "${label}" needs input` : "Session needs input" const body = label ? `Session "${label}" needs input` : "Session needs input"
fireOsNotification({ title, body }) fireOsNotification({ title, body })

View File

@@ -124,7 +124,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-md); gap: var(--space-md);
text-align: left; text-align: start;
background: transparent; background: transparent;
border: none; border: none;
color: var(--text-primary); color: var(--text-primary);

View File

@@ -40,11 +40,11 @@
} }
.selector-trigger-primary--align-left { .selector-trigger-primary--align-left {
@apply text-left w-full; @apply text-start w-full;
} }
.selector-trigger-secondary { .selector-trigger-secondary {
@apply text-xs text-left truncate; @apply text-xs text-start truncate w-full;
color: var(--text-muted); color: var(--text-muted);
} }

View File

@@ -43,7 +43,7 @@
padding: 1.25rem; padding: 1.25rem;
background: background:
linear-gradient(180deg, color-mix(in oklab, var(--surface-secondary) 92%, var(--accent-primary) 8%), var(--surface-secondary)); linear-gradient(180deg, color-mix(in oklab, var(--surface-secondary) 92%, var(--accent-primary) 8%), var(--surface-secondary));
border-right: 1px solid var(--border-base); border-inline-end: 1px solid var(--border-base);
} }
.settings-screen-nav-header { .settings-screen-nav-header {
@@ -121,6 +121,9 @@
color: var(--text-primary); color: var(--text-primary);
transform: translateX(2px); transform: translateX(2px);
} }
[dir="rtl"] .settings-nav-button[data-selected="true"] {
transform: translateX(-2px);
}
.settings-nav-button-icon { .settings-nav-button-icon {
width: 1rem; width: 1rem;
@@ -360,7 +363,7 @@
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
background: var(--surface-base); background: var(--surface-base);
color: var(--text-primary); color: var(--text-primary);
text-align: left; text-align: start;
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
@@ -418,7 +421,7 @@
} }
.settings-choice-check { .settings-choice-check {
margin-left: auto; margin-inline-start: auto;
color: var(--accent-primary); color: var(--accent-primary);
opacity: 0; opacity: 0;
} }
@@ -488,7 +491,7 @@
.settings-screen-nav { .settings-screen-nav {
gap: 0.75rem; gap: 0.75rem;
padding: 1rem; padding: 1rem;
border-right: none; border-inline-end: none;
border-bottom: 1px solid var(--border-base); border-bottom: 1px solid var(--border-base);
} }

View File

@@ -24,6 +24,21 @@
color: inherit; color: inherit;
} }
/* Auto-detect text direction per block element for RTL language support (e.g. Hebrew, Arabic) */
.markdown-body p,
.markdown-body li,
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6,
.markdown-body blockquote,
.markdown-body td,
.markdown-body th {
unicode-bidi: plaintext;
}
.markdown-body h1, .markdown-body h1,
.markdown-body h2, .markdown-body h2,
.markdown-body h3, .markdown-body h3,
@@ -121,6 +136,7 @@
border-radius: 8px; border-radius: 8px;
padding: 0.75rem; padding: 0.75rem;
margin: 1rem 0; margin: 1rem 0;
direction: ltr;
} }
.markdown-body pre:not(.shiki) code, .markdown-body pre:not(.shiki) code,
@@ -129,16 +145,19 @@
} }
.markdown-body blockquote { .markdown-body blockquote {
border-left: 3px solid var(--border-base); border-inline-start: 3px solid var(--border-base);
color: var(--text-secondary); color: var(--text-secondary);
background-color: var(--surface-muted); background-color: var(--surface-muted);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0 8px 8px 0; border-start-start-radius: 0;
border-start-end-radius: 8px;
border-end-end-radius: 8px;
border-end-start-radius: 0;
} }
.markdown-body ul, .markdown-body ul,
.markdown-body ol { .markdown-body ol {
padding-left: 1.5rem; padding-inline-start: 1.5rem;
margin: 0.5rem 0; margin: 0.5rem 0;
} }
@@ -166,7 +185,7 @@
.markdown-body td { .markdown-body td {
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
text-align: left; text-align: start;
color: var(--text-primary); color: var(--text-primary);
background-color: transparent; background-color: transparent;
} }
@@ -221,7 +240,7 @@
cursor: pointer; cursor: pointer;
color: var(--text-secondary); color: var(--text-secondary);
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease; transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
margin-left: auto; margin-inline-start: auto;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }

View File

@@ -28,7 +28,7 @@
/* Message error block */ /* Message error block */
.message-error-block { .message-error-block {
@apply text-sm p-3 rounded border-l-[3px] my-2; @apply text-sm p-3 rounded border-s-[3px] my-2;
color: var(--status-error); color: var(--status-error);
background-color: var(--message-error-bg); background-color: var(--message-error-bg);
border-color: var(--status-error); border-color: var(--status-error);

View File

@@ -132,15 +132,22 @@
margin-bottom: 0; margin-bottom: 0;
} }
.message-stream-block {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 2px;
}
.message-step-start { .message-step-start {
background-color: var(--message-assistant-bg); background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border); border-inline-start: 4px solid var(--message-assistant-border);
margin-top: 0; margin-top: 0;
} }
.message-step-finish { .message-step-finish {
background-color: var(--message-assistant-bg); background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border); border-inline-start: 4px solid var(--message-assistant-border);
margin: 0; margin: 0;
} }
@@ -165,7 +172,7 @@
font-size: 9px; font-size: 9px;
color: var(--text-muted); color: var(--text-muted);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
margin-right: 0.35rem; margin-inline-end: 0.35rem;
} }
.message-step-heading { .message-step-heading {
@@ -182,7 +189,7 @@
} }
.message-error-block { .message-error-block {
@apply text-sm p-3 rounded border-l-[3px] my-2; @apply text-sm p-3 rounded border-s-[3px] my-2;
color: var(--status-error); color: var(--status-error);
background-color: var(--message-error-bg); background-color: var(--message-error-bg);
border-color: var(--status-error); border-color: var(--status-error);
@@ -251,6 +258,7 @@
padding: 8px; padding: 8px;
background-color: var(--surface-code); background-color: var(--surface-code);
border-radius: 4px; border-radius: 4px;
direction: ltr;
} }
.message-error-part { .message-error-part {
@@ -328,12 +336,12 @@
.message-step-start { .message-step-start {
background-color: var(--message-assistant-bg); background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border); border-inline-start: 4px solid var(--message-assistant-border);
} }
.message-step-finish { .message-step-finish {
background-color: var(--message-assistant-bg); background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border); border-inline-start: 4px solid var(--message-assistant-border);
} }
.message-step-heading { .message-step-heading {
@@ -356,7 +364,7 @@
} }
.message-step-time { .message-step-time {
@apply text-[11px] text-[var(--text-muted)] font-normal ml-auto; @apply text-[11px] text-[var(--text-muted)] font-normal ms-auto;
} }
.message-step-meta-inline { .message-step-meta-inline {
@@ -383,7 +391,7 @@
.message-reasoning-card { .message-reasoning-card {
--reasoning-border-color: var(--border-strong, var(--border-base)); --reasoning-border-color: var(--border-strong, var(--border-base));
background-color: var(--message-assistant-bg); background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border); border-inline-start: 4px solid var(--message-assistant-border);
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
padding: 0; padding: 0;
@@ -417,7 +425,7 @@
padding: 0.25rem 0.6rem; padding: 0.25rem 0.6rem;
font: inherit; font: inherit;
color: inherit; color: inherit;
text-align: left; text-align: start;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease; transition: background-color 0.2s ease, box-shadow 0.2s ease;
} }
@@ -539,4 +547,5 @@
color: var(--text-primary); color: var(--text-primary);
white-space: pre-wrap; white-space: pre-wrap;
margin: 0; margin: 0;
unicode-bidi: plaintext;
} }

View File

@@ -207,7 +207,7 @@
.message-scroll-button-wrapper { .message-scroll-button-wrapper {
position: absolute; position: absolute;
right: 1rem; inset-inline-end: 1rem;
bottom: 1rem; bottom: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -274,7 +274,7 @@
} }
.message-quote-button + .message-quote-button { .message-quote-button + .message-quote-button {
border-left: 1px solid var(--list-item-highlight-border); border-inline-start: 1px solid var(--list-item-highlight-border);
} }
.message-quote-button:hover { .message-quote-button:hover {

View File

@@ -3,7 +3,7 @@
.message-select-checkbox { .message-select-checkbox {
width: 14px; width: 14px;
height: 14px; height: 14px;
margin-right: 0.5rem; margin-inline-end: 0.5rem;
cursor: pointer; cursor: pointer;
accent-color: var(--status-error); accent-color: var(--status-error);
flex: 0 0 auto; flex: 0 0 auto;
@@ -134,7 +134,7 @@
} }
.message-delete-mode-menu { .message-delete-mode-menu {
right: 0; inset-inline-end: 0;
bottom: calc(100% + 6px); bottom: calc(100% + 6px);
min-width: 150px; min-width: 150px;
width: max-content; width: max-content;

View File

@@ -20,7 +20,7 @@
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
right: 64px; inset-inline-end: 64px;
width: 1px; width: 1px;
background-color: var(--border-muted); background-color: var(--border-muted);
pointer-events: none; pointer-events: none;
@@ -32,7 +32,7 @@
} }
.message-layout--with-timeline::after { .message-layout--with-timeline::after {
right: 40px; inset-inline-end: 40px;
} }
} }
@@ -311,12 +311,12 @@
/* Tool segments that are part of a group get a left accent border. */ /* Tool segments that are part of a group get a left accent border. */
.message-timeline-group-child { .message-timeline-group-child {
border-left: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent); border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
} }
/* The assistant "parent" at the bottom of a tool group gets the same border. */ /* The assistant "parent" at the bottom of a tool group gets the same border. */
.message-timeline-group-parent { .message-timeline-group-parent {
border-left: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent); border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
} }
/* Extra spacing before the first tool in a group to separate from the /* Extra spacing before the first tool in a group to separate from the
@@ -346,7 +346,7 @@
/* Extend the overlay box into the stream so ribs are not relying on /* Extend the overlay box into the stream so ribs are not relying on
overflow-visible behavior (which is brittle around scroll containers). */ overflow-visible behavior (which is brittle around scroll containers). */
--xray-overhang: calc(var(--max-rib-width, 50vw) + 84px); --xray-overhang: calc(var(--max-rib-width, 50vw) + 84px);
left: calc(-1 * var(--xray-overhang)); inset-inline-start: calc(-1 * var(--xray-overhang));
width: calc(100% + var(--xray-overhang)); width: calc(100% + var(--xray-overhang));
overflow: hidden; overflow: hidden;
padding: 0.25rem; padding: 0.25rem;
@@ -374,10 +374,10 @@
.message-timeline-xray-token-label { .message-timeline-xray-token-label {
position: absolute; position: absolute;
right: 100%; inset-inline-end: 100%;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
margin-right: 4px; margin-inline-end: 4px;
height: 1.5rem; height: 1.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -403,16 +403,25 @@
var(--status-success) calc(100% - var(--segment-weight) * 100%), var(--status-success) calc(100% - var(--segment-weight) * 100%),
var(--status-error) calc(var(--segment-weight) * 100%) var(--status-error) calc(var(--segment-weight) * 100%)
); );
border-radius: 3px 0 0 3px; border-start-start-radius: 3px;
border-end-start-radius: 3px;
border-start-end-radius: 0;
border-end-end-radius: 0;
transition: width 0.3s ease, background-color 0.3s ease; transition: width 0.3s ease, background-color 0.3s ease;
box-shadow: -2px 0 4px rgba(0, 0, 0, 0.25); box-shadow: -2px 0 4px rgba(0, 0, 0, 0.25);
} }
[dir="rtl"] .message-timeline-relative-bar {
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.25);
}
.message-timeline-absolute-bar { .message-timeline-absolute-bar {
height: 3px; height: 3px;
width: calc(var(--segment-weight) * var(--max-rib-width, 50vw)); width: calc(var(--segment-weight) * var(--max-rib-width, 50vw));
background-color: var(--text-muted); background-color: var(--text-muted);
border-radius: 2px 0 0 2px; border-start-start-radius: 2px;
border-end-start-radius: 2px;
border-start-end-radius: 0;
border-end-end-radius: 0;
transition: width 0.3s ease; transition: width 0.3s ease;
opacity: 0.5; opacity: 0.5;
position: relative; position: relative;
@@ -425,7 +434,7 @@
.message-timeline-absolute-bar-overflow::before { .message-timeline-absolute-bar-overflow::before {
content: ""; content: "";
position: absolute; position: absolute;
left: -1px; inset-inline-start: -1px;
top: -3px; top: -3px;
bottom: -3px; bottom: -3px;
width: 3px; width: 3px;

View File

@@ -35,7 +35,9 @@
} }
.prompt-input { .prompt-input {
@apply w-full pl-3 pr-10 pt-2.5 border text-sm resize-none outline-none transition-colors; @apply w-full pt-2.5 border text-sm resize-none outline-none transition-colors;
padding-inline-start: 2.5rem;
padding-inline-end: 0.75rem;
font-family: inherit; font-family: inherit;
background-color: var(--surface-base); background-color: var(--surface-base);
color: var(--text-primary); color: var(--text-primary);
@@ -65,8 +67,8 @@
.prompt-input-overlay { .prompt-input-overlay {
position: absolute; position: absolute;
bottom: 1rem; bottom: 1rem;
left: 0.75rem; inset-inline-start: 0.75rem;
right: 0.75rem; inset-inline-end: 0.75rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
@@ -81,11 +83,13 @@
color: var(--text-primary); color: var(--text-primary);
} }
/* Navigation buttons container (expand, prev, next) */ /* Navigation buttons container (expand, prev, next).
Intentionally at inline-start (left in LTR, right in RTL) so buttons never overlap
the scrollbar, which browsers always place at inline-end. */
.prompt-nav-buttons { .prompt-nav-buttons {
position: absolute; position: absolute;
top: 0.25rem; top: 0.25rem;
right: 0.25rem; inset-inline-start: 0.25rem;
bottom: 0.25rem; bottom: 0.25rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -160,6 +164,11 @@
@apply opacity-60 cursor-not-allowed; @apply opacity-60 cursor-not-allowed;
} }
/* In RTL: override dir="auto" which defaults to LTR on empty textarea */
[dir="rtl"] .prompt-input {
direction: rtl;
}
.prompt-input::placeholder { .prompt-input::placeholder {
color: var(--text-muted); color: var(--text-muted);
} }
@@ -256,7 +265,7 @@
display: none; display: none;
position: absolute; position: absolute;
bottom: calc(100% + 6px); bottom: calc(100% + 6px);
left: 0; inset-inline-start: 0;
padding: 8px; padding: 8px;
background-color: var(--surface-base); background-color: var(--surface-base);
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
@@ -335,6 +344,7 @@
.prompt-input { .prompt-input {
min-height: 0; min-height: 0;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
padding-inline-start: 2.5rem; /* preserve space for nav buttons */
padding-bottom: 0.75rem; padding-bottom: 0.75rem;
} }

View File

@@ -5,7 +5,7 @@
.tool-call-message { .tool-call-message {
@apply flex flex-col gap-2 p-3 w-full; @apply flex flex-col gap-2 p-3 w-full;
background-color: var(--message-tool-bg); background-color: var(--message-tool-bg);
border-left: 4px solid var(--message-tool-border); border-inline-start: 4px solid var(--message-tool-border);
color: inherit; color: inherit;
} }
@@ -94,7 +94,7 @@
} }
.tool-call-header-toggle { .tool-call-header-toggle {
@apply flex items-center gap-2 p-2 w-full bg-transparent border-none cursor-pointer text-left; @apply flex items-center gap-2 p-2 w-full bg-transparent border-none cursor-pointer text-start;
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-size: 13px; font-size: 13px;
border-radius: 0; border-radius: 0;
@@ -105,7 +105,7 @@
.tool-call-header-toggle::before { .tool-call-header-toggle::before {
content: "▶"; content: "▶";
font-size: 11px; font-size: 11px;
margin-right: 0.35rem; margin-inline-end: 0.35rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -159,7 +159,7 @@
} }
.tool-call-summary { .tool-call-summary {
@apply flex-1 text-left inline-flex items-center gap-2; @apply flex-1 text-start inline-flex items-center gap-2;
color: var(--text-primary); color: var(--text-primary);
} }
@@ -168,26 +168,26 @@
} }
.tool-call-summary[data-tool-icon=""]::before { .tool-call-summary[data-tool-icon=""]::before {
margin-right: 0; margin-inline-end: 0;
content: ""; content: "";
} }
.tool-call-summary[data-tool-icon]:not([data-tool-icon=""])::before { .tool-call-summary[data-tool-icon]:not([data-tool-icon=""])::before {
margin-right: 0.35rem; margin-inline-end: 0.35rem;
} }
/* ToolState uses status="completed"; keep "success" as a legacy alias. */ /* ToolState uses status="completed"; keep "success" as a legacy alias. */
.tool-call-status-completed, .tool-call-status-completed,
.tool-call-status-success { .tool-call-status-success {
border-left: 3px solid var(--status-success); border-inline-start: 3px solid var(--status-success);
} }
.tool-call-status-error { .tool-call-status-error {
border-left: 3px solid var(--status-error); border-inline-start: 3px solid var(--status-error);
} }
.tool-call-status-running { .tool-call-status-running {
border-left: 3px solid var(--status-warning); border-inline-start: 3px solid var(--status-warning);
} }
.tool-call-status-running .tool-call-status { .tool-call-status-running .tool-call-status {
@@ -195,7 +195,7 @@
} }
.tool-call-status-pending { .tool-call-status-pending {
border-left: 3px solid var(--accent-primary); border-inline-start: 3px solid var(--accent-primary);
} }
.tool-call-status-pending .tool-call-summary { .tool-call-status-pending .tool-call-summary {
@@ -257,7 +257,7 @@
border: none; border: none;
border-bottom: 1px solid var(--tool-call-border-color); border-bottom: 1px solid var(--tool-call-border-color);
width: 100%; width: 100%;
text-align: left; text-align: start;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: normal; font-weight: normal;
color: var(--text-primary); color: var(--text-primary);
@@ -267,7 +267,7 @@
.tool-call-io-toggle::before { .tool-call-io-toggle::before {
content: "▶"; content: "▶";
font-size: 11px; font-size: 11px;
margin-right: 0.35rem; margin-inline-end: 0.35rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
@@ -393,7 +393,7 @@
} }
.tool-call-awaiting-permission { .tool-call-awaiting-permission {
border-left-color: var(--status-warning); border-inline-start-color: var(--status-warning);
} }
.tool-call-permission { .tool-call-permission {
@@ -484,7 +484,7 @@
} }
.tool-call-permission-shortcuts .kbd { .tool-call-permission-shortcuts .kbd {
margin-right: 0.25rem; margin-inline-end: 0.25rem;
} }
.tool-call-permission-queued-text { .tool-call-permission-queued-text {
@@ -549,6 +549,7 @@
min-height: auto; min-height: auto;
max-height: none; max-height: none;
overflow-y: visible; overflow-y: visible;
direction: ltr;
} }
/* Shiki injects inline background colors; force token surfaces. */ /* Shiki injects inline background colors; force token surfaces. */
@@ -610,7 +611,7 @@
} }
.tool-call-diagnostics-heading { .tool-call-diagnostics-heading {
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left; @apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-start;
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-size: 13px; font-size: 13px;
color: var(--text-primary); color: var(--text-primary);
@@ -634,7 +635,7 @@
.tool-call-diagnostics-heading { .tool-call-diagnostics-heading {
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left; @apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-start;
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
font-size: 13px; font-size: 13px;
color: var(--text-primary); color: var(--text-primary);
@@ -693,8 +694,8 @@
gap: var(--space-xs); gap: var(--space-xs);
max-height: calc(4 * var(--tool-call-line-unit, 1.4em)); max-height: calc(4 * var(--tool-call-line-unit, 1.4em));
overflow-y: scroll; overflow-y: scroll;
padding-right: 0; padding-inline-end: 0;
margin-right: 0; margin-inline-end: 0;
scrollbar-gutter: stable both-edges; scrollbar-gutter: stable both-edges;
scrollbar-width: thin; scrollbar-width: thin;
} }
@@ -762,6 +763,7 @@
overflow-x: auto; overflow-x: auto;
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em)); max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
overflow-y: scroll; overflow-y: scroll;
direction: ltr;
} }
.tool-call-section code { .tool-call-section code {
@@ -843,7 +845,7 @@
.tool-call-error-content { .tool-call-error-content {
background-color: var(--message-error-bg); background-color: var(--message-error-bg);
border-left: 3px solid var(--status-error); border-inline-start: 3px solid var(--status-error);
padding: 12px; padding: 12px;
margin: 8px 0; margin: 8px 0;
border-radius: 4px; border-radius: 4px;

View File

@@ -86,7 +86,7 @@
.tool-call-task-summary .tool-call::before { .tool-call-task-summary .tool-call::before {
content: ""; content: "";
position: absolute; position: absolute;
left: 0; inset-inline-start: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 3px; width: 3px;
@@ -117,7 +117,7 @@
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
padding: 0.35rem 0.5rem 0.35rem 0.75rem; padding: 0.35rem 0.5rem 0.35rem 0.75rem;
border-left: 2px solid var(--tool-call-border-color, var(--border-base)); border-inline-start: 2px solid var(--tool-call-border-color, var(--border-base));
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-family: var(--font-family-mono); font-family: var(--font-family-mono);
line-height: 1.35; line-height: 1.35;
@@ -134,19 +134,19 @@
} }
.tool-call-task-item[data-task-status="completed"] { .tool-call-task-item[data-task-status="completed"] {
border-left-color: var(--status-success); border-inline-start-color: var(--status-success);
} }
.tool-call-task-item[data-task-status="running"] { .tool-call-task-item[data-task-status="running"] {
border-left-color: var(--status-warning); border-inline-start-color: var(--status-warning);
} }
.tool-call-task-item[data-task-status="pending"] { .tool-call-task-item[data-task-status="pending"] {
border-left-color: var(--accent-primary); border-inline-start-color: var(--accent-primary);
} }
.tool-call-task-item[data-task-status="error"] { .tool-call-task-item[data-task-status="error"] {
border-left-color: var(--status-error); border-inline-start-color: var(--status-error);
} }
.tool-call-task-icon { .tool-call-task-icon {

View File

@@ -1,39 +1,58 @@
.message-stream { .virtual-follow-list-shell {
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-0.5;
background-color: var(--surface-base);
color: inherit;
/* Prevent browser scroll anchoring fighting our virtualization compensation. */
overflow-anchor: none;
}
.message-stream-block {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.0625rem; flex: 1;
min-height: 0;
contain: layout paint style; position: relative;
}
.virtual-item-wrapper {
width: 100%; width: 100%;
} }
.virtual-item-placeholder, .message-stream {
flex: 1;
min-height: 0;
overflow-y: auto;
background-color: var(--surface-base);
color: inherit;
/* Scrolling optimizations */
overscroll-behavior-y: contain;
/* Prevents scroll chaining to parent elements */
will-change: scroll-position;
/* GPU acceleration hint for smoother scrolling */
-webkit-overflow-scrolling: touch;
/* Momentum scrolling on iOS */
/* Prevent browser scroll anchoring fighting our virtualization compensation. */
overflow-anchor: none;
/* Scrollbar styling */
scrollbar-gutter: stable;
}
.virtual-follow-list-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 10;
/* Ensure it doesn't affect layout at all */
height: 0;
overflow: visible;
}
.virtual-follow-list-overlay > * {
pointer-events: auto;
}
.virtual-follow-list-controls-container {
position: absolute;
bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px));
inset-inline-end: var(--space-md);
z-index: 20;
}
.message-stream-placeholder { .message-stream-placeholder {
display: block; display: block;
width: 100%; width: 100%;
position: relative; position: relative;
background-color: transparent; background-color: transparent;
} }
.virtual-item-content {
width: 100%;
position: relative;
}
.virtual-item-content-hidden {
position: absolute;
inset: 0;
visibility: hidden;
pointer-events: none;
}

View File

@@ -42,7 +42,7 @@
} }
.modal-item { .modal-item {
@apply w-full px-4 py-3 flex items-start gap-3 transition-colors cursor-pointer border-none text-left; @apply w-full px-4 py-3 flex items-start gap-3 transition-colors cursor-pointer border-none text-start;
color: var(--text-primary); color: var(--text-primary);
} }

View File

@@ -77,7 +77,7 @@
} }
.panel-list-item-content { .panel-list-item-content {
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full min-w-0; @apply flex-1 text-start px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full min-w-0;
} }
.panel-list-item-content:hover { .panel-list-item-content:hover {

View File

@@ -11,8 +11,8 @@
content: ''; content: '';
position: absolute; position: absolute;
bottom: -1px; bottom: -1px;
left: 0; inset-inline-start: 0;
right: 0; inset-inline-end: 0;
height: 1px; height: 1px;
background-color: var(--border-base); background-color: var(--border-base);
z-index: 0; z-index: 0;
@@ -42,7 +42,7 @@
border: 1px solid transparent; border: 1px solid transparent;
border-bottom: none; border-bottom: none;
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
margin-right: 2px; margin-inline-end: 2px;
z-index: 1; z-index: 1;
} }
@@ -90,8 +90,8 @@
.file-split-handle { .file-split-handle {
cursor: col-resize; cursor: col-resize;
background-color: transparent; background-color: transparent;
border-left: 1px solid var(--border-base); border-inline-start: 1px solid var(--border-base);
border-right: 1px solid var(--border-base); border-inline-end: 1px solid var(--border-base);
user-select: none; user-select: none;
touch-action: none; touch-action: none;
} }
@@ -148,7 +148,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--surface-secondary); background-color: var(--surface-secondary);
border-left: 1px solid var(--border-base); border-inline-start: 1px solid var(--border-base);
/* Monaco uses layered positioned elements; keep overlay well above it. */ /* Monaco uses layered positioned elements; keep overlay well above it. */
z-index: 200; z-index: 200;
} }
@@ -334,6 +334,7 @@
.monaco-viewer { .monaco-viewer {
width: 100%; width: 100%;
height: 100%; height: 100%;
direction: ltr;
} }
.file-viewer-empty { .file-viewer-empty {
@@ -459,7 +460,7 @@
} }
.section-label { .section-label {
margin-left: 2px; margin-inline-start: 2px;
} }
.section-info-icon { .section-info-icon {
@@ -528,7 +529,7 @@
} }
.right-panel-empty--left { .right-panel-empty--left {
@apply items-start justify-start text-left w-full; @apply items-start justify-start text-start w-full;
} }
.right-panel-empty-text { .right-panel-empty-text {

View File

@@ -26,10 +26,10 @@
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; inset-inline-start: 0;
width: min(90vw, 360px); width: min(90vw, 360px);
max-width: 360px; max-width: 360px;
border-right: 1px solid var(--border-base); border-inline-end: 1px solid var(--border-base);
box-shadow: var(--folder-card-shadow); box-shadow: var(--folder-card-shadow);
transform: translateX(0); transform: translateX(0);
transition: transform 0.25s ease, opacity 0.2s ease; transition: transform 0.25s ease, opacity 0.2s ease;
@@ -41,6 +41,9 @@
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
[dir="rtl"] .session-sidebar-collapsed {
transform: translateX(100%);
}
.session-sidebar-backdrop { .session-sidebar-backdrop {
@apply absolute inset-0; @apply absolute inset-0;
@@ -54,7 +57,7 @@
.session-sidebar-menu-button--floating { .session-sidebar-menu-button--floating {
position: absolute; position: absolute;
top: 1rem; top: 1rem;
left: 1rem; inset-inline-start: 1rem;
z-index: 20; z-index: 20;
} }
@@ -128,7 +131,7 @@ session-sidebar-controls .selector-trigger-primary {
.mobile-fullscreen-exit-wrapper { .mobile-fullscreen-exit-wrapper {
position: fixed; position: fixed;
top: calc(env(safe-area-inset-top, 0px) + 12px); top: calc(env(safe-area-inset-top, 0px) + 12px);
right: calc(env(safe-area-inset-right, 0px) + 12px); inset-inline-end: calc(env(safe-area-inset-right, 0px) + 12px);
z-index: 1250; z-index: 1250;
pointer-events: none; pointer-events: none;
} }
@@ -143,11 +146,11 @@ session-sidebar-controls .selector-trigger-primary {
} }
.session-resize-handle--left { .session-resize-handle--left {
right: 0; inset-inline-end: 0;
} }
.session-resize-handle--right { .session-resize-handle--right {
left: 0; inset-inline-start: 0;
} }
.session-resize-handle:hover { .session-resize-handle:hover {
@@ -160,14 +163,20 @@ session-sidebar-controls .selector-trigger-primary {
} }
.session-resize-handle--left::before { .session-resize-handle--left::before {
right: 0; inset-inline-end: 0;
transform: translateX(50%); transform: translateX(50%);
} }
[dir="rtl"] .session-resize-handle--left::before {
transform: translateX(-50%);
}
.session-resize-handle--right::before { .session-resize-handle--right::before {
left: 0; inset-inline-start: 0;
transform: translateX(-50%); transform: translateX(-50%);
} }
[dir="rtl"] .session-resize-handle--right::before {
transform: translateX(50%);
}
.session-list-header { .session-list-header {
@apply border-b relative; @apply border-b relative;
@@ -190,14 +199,14 @@ session-sidebar-controls .selector-trigger-primary {
} }
.session-item-base { .session-item-base {
@apply w-full flex flex-col gap-1 px-3 py-2.5 text-left transition-colors outline-none; @apply w-full flex flex-col gap-1 px-3 py-2.5 text-start transition-colors outline-none;
font-family: var(--font-family-sans); font-family: var(--font-family-sans);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }
.session-item-base.session-item-child { .session-item-base.session-item-child {
padding-left: 2.25rem; padding-inline-start: 2.25rem;
position: relative; position: relative;
} }
@@ -206,7 +215,7 @@ session-sidebar-controls .selector-trigger-primary {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 1.125rem; inset-inline-start: 1.125rem;
width: 1px; width: 1px;
background-color: var(--text-secondary); background-color: var(--text-secondary);
opacity: 0.95; opacity: 0.95;
@@ -221,7 +230,7 @@ session-sidebar-controls .selector-trigger-primary {
content: ""; content: "";
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 1.125rem; inset-inline-start: 1.125rem;
width: 0.875rem; width: 0.875rem;
height: 1px; height: 1px;
background-color: var(--text-secondary); background-color: var(--text-secondary);
@@ -231,11 +240,11 @@ session-sidebar-controls .selector-trigger-primary {
} }
.session-item-base.session-item-border-user { .session-item-base.session-item-border-user {
border-left: 4px solid var(--message-user-border); border-inline-start: 4px solid var(--message-user-border);
} }
.session-item-base.session-item-border-assistant { .session-item-base.session-item-border-assistant {
border-left: 4px solid var(--message-assistant-border); border-inline-start: 4px solid var(--message-assistant-border);
} }
.session-item-expander { .session-item-expander {

View File

@@ -77,23 +77,23 @@ export default defineConfig({
theme_color: "#1a1a1a", theme_color: "#1a1a1a",
}, },
workbox: { workbox: {
// Workbox defaults to 2 MiB; our main bundle can slightly exceed that. // Workbox defaults to 2 MiB; our main bundle can slightly exceed that.
// This is a build-time limit for the precache manifest, not a hard runtime cap. // This is a build-time limit for the precache manifest, not a hard runtime cap.
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
// Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html. // Preserve server-side auth redirects (e.g., /login) instead of serving cached index.html.
navigateFallback: null, navigateFallback: null,
// Only precache static assets (avoid caching HTML documents / routes). // Only precache static assets (avoid caching HTML documents / routes).
globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"], globPatterns: ["**/*.{js,css,png,jpg,jpeg,svg,webp,ico,woff,woff2,ttf,eot,json,webmanifest}"],
// Monaco assets can be large; cache them at runtime instead. // Monaco assets can be large; cache them at runtime instead.
globIgnores: [ globIgnores: [
"**/*.html", "**/*.html",
"**/assets/*worker-*.js", "**/assets/*worker-*.js",
"**/assets/editor.api-*.js", "**/assets/editor.api-*.js",
"**/monaco/vs/**/*", "**/monaco/vs/**/*",
], ],
// Only cache static UI assets; never cache API traffic. // Only cache static UI assets; never cache API traffic.
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: ({ url, request }) => { urlPattern: ({ url, request }) => {
if (url.pathname.startsWith("/api/")) return false if (url.pathname.startsWith("/api/")) return false
if (request.destination === "document") return false if (request.destination === "document") return false
@@ -134,6 +134,34 @@ export default defineConfig({
main: resolve(__dirname, "./src/renderer/index.html"), main: resolve(__dirname, "./src/renderer/index.html"),
loading: resolve(__dirname, "./src/renderer/loading.html"), loading: resolve(__dirname, "./src/renderer/loading.html"),
}, },
output: {
manualChunks(id) {
const normalizedId = id.replace(/\\/g, "/")
if (normalizedId.includes("/node_modules/@git-diff-view/")) {
return "git-diff-vendor"
}
if (normalizedId.includes("/node_modules/highlight.js/") || normalizedId.includes("/node_modules/lowlight/")) {
return "highlight-vendor"
}
if (normalizedId.includes("/node_modules/fast-diff/")) {
return "fast-diff-vendor"
}
if (normalizedId.includes("/node_modules/monaco-editor/")) {
return "monaco-vendor"
}
if (
normalizedId.includes("/src/components/file-viewer/") ||
normalizedId.includes("/src/lib/monaco/")
) {
return "monaco-viewer"
}
},
},
}, },
}, },
}) })