Compare commits

...

53 Commits

Author SHA1 Message Date
Pascal André
3bad0afd7d perf(ui): defer locale and overlay bundles (#238)
## Summary
- defer locale and overlay loading work away from the first critical
render path
- seed locale state from the bootstrap preload so the first render can
use the preloaded language immediately
- keep bootstrap cache and locale fallback behavior consistent on
subsequent launches

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

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

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

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

## Technical notes

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

## Summary

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

## Affected components

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

## Test plan

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

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

---------

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

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

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

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

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

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

Fixes #234.

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

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

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

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

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

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

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

Closes #228

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

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

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

### 🚀 Performance Results

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

### 🛠️ Key Changes

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

### 🎥 UI Verification


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


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

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

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-03-20 22:46:05 +00:00
Shantur Rathore
68407a01a4 ci: post PR artifact comments per build 2026-03-20 18:00:18 +00:00
Shantur Rathore
0283493f2a ci: prefer latest PR build run for artifact comments 2026-03-20 17:56:31 +00:00
Shantur Rathore
e989795de3 ci: move PR artifact comments to trusted workflow 2026-03-20 09:24:27 +00:00
Shantur Rathore
103d2bf1a8 ci: comment PR artifacts from validation run 2026-03-20 07:40:59 +00:00
Shantur Rathore
0ce7a47e03 ci: read PR number from workflow run 2026-03-20 07:22:56 +00:00
Shantur Rathore
5df8809c82 ci: resolve artifact comments by PR head branch 2026-03-20 07:13:04 +00:00
Shantur Rathore
6e22614648 ci: resolve PR number for artifact comment 2026-03-19 21:15:48 +00:00
Shantur Rathore
5d87e1e563 ci: upload PR build artifacts and comment link 2026-03-19 20:52:14 +00:00
Shantur Rathore
d735b189f5 refactor(tauri): use imported event and dialog APIs 2026-03-19 19:38:43 +00:00
Shantur Rathore
3d575f4f68 fix(tauri): align wake lock bridge with v2 API 2026-03-19 19:20:18 +00:00
Shantur Rathore
b58728dc0e add PR branch authorization workflows
Restrict non-dev pull requests to an allowlisted set of actors and skip cross-platform PR builds unless that authorization check passes. Keep dev open for general contributions while guiding other PRs back to the dev branch.
2026-03-19 15:01:36 +00:00
Shantur Rathore
672177f570 add PR build validation workflow
Run the full cross-platform build matrix on pull request creation and updates so build regressions are caught before merge without publishing release artifacts.
2026-03-19 14:52:48 +00:00
Shantur Rathore
6961efde0b Merge pull request #224 from Pagecran/upstream/tauri-prebuild-sync
fix(tauri): sync server UI bundle during prebuild
2026-03-18 20:39:22 +00:00
Shantur Rathore
b3e0233f4b Merge pull request #232 from NeuralNomadsAI/codenomad/issue-231
fix(tauri): stop CLI process group on exit
2026-03-18 20:33:55 +00:00
Pascal André
fcebcb0174 fix(tauri): sync server UI bundle during prebuild
Ensure the Tauri prebuild step refreshes packages/server/public from the current UI renderer bundle so the packaged desktop app does not serve a stale folder-selection UI.
2026-03-18 20:45:08 +01:00
Shantur Rathore
eaab5e2e9f fix(tauri): stop CLI process group on exit 2026-03-18 19:43:41 +00:00
Shantur Rathore
b12825f923 Merge pull request #227 from Pagecran/upstream/tauri-windows-runtime
fix(tauri): improve Windows desktop runtime behavior
2026-03-18 19:37:31 +00:00
Pascal André
8245f474b8 fix(tauri): avoid non-Windows spawn warning 2026-03-18 20:21:40 +01:00
Pascal André
3a15b311a8 fix(tauri): hide taskkill during Windows cleanup 2026-03-18 20:19:10 +01:00
Pascal André
6cb6c0af32 fix(tauri): align desktop bundle identifier 2026-03-18 20:19:10 +01:00
Pascal André
7f631611fd fix(tauri): hide Windows CLI console window
Set CREATE_NO_WINDOW on the spawned local CLI process so the packaged Windows Tauri app does not flash an extra console window when it launches Node.
2026-03-18 20:19:10 +01:00
Pascal André
9d91ecc649 fix(tauri): kill Windows CLI process trees on shutdown
Use taskkill /T /F for the local server process on Windows so child Node/Bun processes do not survive app shutdown or startup timeouts.
2026-03-18 20:19:10 +01:00
Pascal André
87afb06d34 fix(tauri): restore Windows app identity
Set the same explicit AppUserModelID that the legacy Electron app used so Windows taskbar grouping and notification attribution stay consistent in the Tauri desktop build.
2026-03-18 20:18:59 +01:00
Pascal André
4402d9afb0 fix(tauri): align desktop version metadata
Match the Tauri package, Cargo, and bundle version metadata to the current legacy desktop version so About dialogs and installer artifacts stop reporting 0.1.0.
2026-03-18 20:18:07 +01:00
Shantur Rathore
7c3f808d69 Minium server 0.12.3 2026-03-13 20:06:41 +00:00
Shantur Rathore
a59e929b12 Release v0.12.3 2026-03-13 20:04:20 +00:00
Shantur Rathore
8ff4019839 fix(ui): stabilize prompt async optimistic messages
Reconcile optimistic user messages by replacing the oldest synthetic pending message when the server-backed message arrives. Stop sending prompt part ids and rely on message-level replacement so v1.2.25 validation passes without duplicating optimistic content.
2026-03-13 19:17:55 +00:00
Shantur Rathore
d9068ac8c6 fix(ui): tighten settings content padding
Reduce the Settings scroll area gutter while keeping a consistent inset on all sides.
2026-03-11 11:01:04 +00:00
Shantur Rathore
51f8eff3f7 fix(ui): remove settings rounded corners
Make the Settings screen use square corners across panels, cards, and embedded controls.
2026-03-11 10:55:51 +00:00
Shantur Rathore
627ff2d42b feat(ui): centralize interaction preferences
Expose interaction defaults in Settings and reuse the same registry for command palette actions.
2026-03-11 10:53:28 +00:00
Shantur Rathore
0d9da40102 feat(ui): add unified settings screen 2026-03-11 10:10:58 +00:00
Shantur Rathore
ff94c9714e fix(tauri): align dev CLI args with electron 2026-03-10 22:23:38 +00:00
Shantur Rathore
429825f434 feat(desktop): unify folder drag-and-drop across runtimes 2026-03-10 22:12:23 +00:00
Shantur Rathore
d836d2e62d fix(tauri): remove Windows-only CLI dependency 2026-03-10 20:15:16 +00:00
Shantur Rathore
f77fb1562e fix(ui): stabilize streaming follow mode
Disable follow-mode virtualization churn and simplify reasoning header layout so streaming thinking blocks stop nudging the scroll position while the list is pinned to bottom.
2026-03-10 18:44:55 +00:00
Shantur Rathore
b33421a375 fix(ui): stabilize virtual list rerender measurements
Keep visible rows mounted during follow-up measurements and clear stale refs so async message rendering no longer flickers or measures detached blocks. Coalesce per-item render notifications so content-heavy rows only trigger one remeasurement per frame.
2026-03-10 06:28:11 +00:00
Shantur Rathore
c64a9a03f9 fix(ui): stabilize virtual message list measurements 2026-03-10 06:28:11 +00:00
Shantur Rathore
0d215342e3 Merge pull request #210 from Pagecran/fix/tauri-windows-startup
fix(tauri): restore Windows desktop startup
2026-03-08 17:26:20 +00:00
Pascal André
beb14ea0a2 fix(tauri): restore Windows desktop startup 2026-03-08 16:20:31 +01:00
115 changed files with 6397 additions and 3624 deletions

View File

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

View File

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

57
.github/workflows/pr-build.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: PR Build Validation
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
permissions:
contents: read
actions: write
concurrency:
group: pr-build-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
authorize:
runs-on: ubuntu-latest
outputs:
allowed: ${{ steps.auth.outputs.allowed }}
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check PR authorization
id: auth
shell: bash
run: |
set -euo pipefail
if [ "$BASE_REF" = "dev" ]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "allowed=true" >> "$GITHUB_OUTPUT"
else
echo "allowed=false" >> "$GITHUB_OUTPUT"
echo "Skipping builds for unauthorized PR targeting $BASE_REF" >&2
fi
build:
needs: authorize
if: ${{ needs.authorize.outputs.allowed == 'true' && !github.event.pull_request.draft }}
uses: ./.github/workflows/build-and-upload.yml
with:
ref: ${{ github.event.pull_request.head.sha }}
upload: false
upload_actions_artifacts: true
actions_artifacts_retention_days: 7
actions_artifacts_name_prefix: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}-
set_versions: false

View File

@@ -0,0 +1,54 @@
name: Restrict Non-Dev PRs
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
permissions:
contents: read
pull-requests: write
jobs:
restrict-non-dev-prs:
if: ${{ github.event.pull_request.base.ref != 'dev' }}
runs-on: ubuntu-latest
env:
ALLOWED_ACTORS: ${{ vars.ALLOWED_NON_DEV_PR_ACTORS }}
ACTOR: ${{ github.actor }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
steps:
- name: Check allowed actor
id: auth
shell: bash
run: |
set -euo pipefail
normalized=",${ALLOWED_ACTORS},"
if [[ "$normalized" == *",${ACTOR},"* ]]; then
echo "authorized=true" >> "$GITHUB_OUTPUT"
else
echo "authorized=false" >> "$GITHUB_OUTPUT"
fi
- name: Comment on unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment "$PR_NUMBER" --body "Thanks for the contribution. PRs need to target \`dev\` branch. Please retarget this PR to the dev branch"
- name: Close unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr close "$PR_NUMBER"
- name: Fail unauthorized PR
if: ${{ steps.auth.outputs.authorized != 'true' }}
run: |
echo "Actor $ACTOR is not allowed to open PRs targeting $BASE_REF" >&2
exit 1

6
manifest.json Normal file
View File

@@ -0,0 +1,6 @@
{
"minServerVersion": "0.12.3",
"latestUIVersion": "0.12.3-rtl",
"uiPackageURL": "https://github.com/MusiCode1/CodeNomad/releases/download/v0.12.3-rtl/codenomad-ui-rtl.zip",
"sha256": "a2ce1aaa04345a2f9ca9d3c3149567867f3a5e477cf6eb269381e6dc1bec7ca2"
}

86
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.12.2",
"version": "0.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.12.2",
"version": "0.12.3",
"license": "MIT",
"dependencies": {
"7zip-bin": "^5.2.0",
@@ -3253,9 +3253,9 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -3305,6 +3305,32 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
@@ -10218,14 +10244,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/tauri-plugin-keepawake-api": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
"dependencies": {
"@tauri-apps/api": ">=2.0.0-beta.6"
}
},
"node_modules/temp-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -10966,6 +10984,36 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/virtua": {
"version": "0.48.8",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0",
"solid-js": ">=1.0",
"svelte": ">=5.0",
"vue": ">=3.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/vite": {
"version": "5.4.21",
"dev": true,
@@ -11985,7 +12033,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.2",
"version": "0.12.3",
"license": "MIT",
"dependencies": {
"@codenomad/ui": "file:../ui",
@@ -12022,7 +12070,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.12.2",
"version": "0.12.3",
"license": "MIT",
"dependencies": {
"@fastify/cors": "^8.5.0",
@@ -12063,7 +12111,7 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.12.2",
"version": "0.12.3",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
@@ -12071,7 +12119,7 @@
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.12.2",
"version": "0.12.3",
"license": "MIT",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
@@ -12081,6 +12129,8 @@
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3",
"ansi-sequence-parser": "^1.1.3",
@@ -12093,7 +12143,7 @@
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0",
"virtua": "^0.48.8",
"yaml": "^2.4.2"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.12.2",
"version": "0.12.3",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",
@@ -31,4 +31,4 @@
"devDependencies": {
"baseline-browser-mapping": "^2.9.11"
}
}
}

View File

@@ -1,4 +1,4 @@
{
"minServerVersion": "0.11.4",
"minServerVersion": "0.12.3",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View File

@@ -1,4 +1,5 @@
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import fs from "fs"
import type { CliProcessManager, CliStatus } from "./process-manager"
let wakeLockId: number | null = null
@@ -65,6 +66,24 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { canceled: result.canceled, paths: result.filePaths }
})
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
if (!Array.isArray(paths)) {
return []
}
const directories = paths.filter((value): value is string => {
if (typeof value !== "string" || value.trim().length === 0) {
return false
}
try {
return fs.statSync(value).isDirectory()
} catch {
return false
}
})
return directories
})
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
const next = Boolean(enabled)
if (next) {

View File

@@ -1,4 +1,4 @@
const { contextBridge, ipcRenderer } = require("electron")
const { contextBridge, ipcRenderer, webUtils } = require("electron")
const electronAPI = {
onCliStatus: (callback) => {
@@ -12,6 +12,14 @@ const electronAPI = {
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
getPathForFile: (file) => {
try {
return webUtils.getPathForFile(file)
} catch {
return null
}
},
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
}

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.2",
"version": "0.12.3",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {

View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.12.2",
"version": "0.12.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.12.2",
"version": "0.12.3",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.12.2",
"version": "0.12.3",
"description": "CodeNomad Server",
"license": "MIT",
"author": {
@@ -46,4 +46,4 @@
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.12.2",
"version": "0.12.3",
"private": true,
"license": "MIT",
"scripts": {

View File

@@ -20,6 +20,7 @@ const serverDevInstallCommand =
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const uiDevInstallCommand =
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const serverPrepareUiCommand = "npm run prepare-ui --workspace @neuralnomads/codenomad"
const envWithRootBin = {
...process.env,
@@ -91,6 +92,15 @@ function ensureUiBuild() {
}
}
function syncServerUiBundle() {
console.log("[prebuild] syncing server public UI bundle...")
execSync(serverPrepareUiCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
@@ -246,6 +256,7 @@ function copyUiLoadingAssets() {
ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
syncServerUiBundle()
copyServerArtifacts()
stripNodeModuleBins()
copyUiLoadingAssets()

View File

@@ -1,6 +1,6 @@
[package]
name = "codenomad-tauri"
version = "0.1.0"
version = "0.12.3"
edition = "2021"
license = "MIT"
@@ -19,9 +19,13 @@ thiserror = "1"
anyhow = "1"
which = "4"
libc = "0.2"
keepawake = "0.6"
tauri-plugin-dialog = "2"
dirs = "5"
tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
url = "2"
tauri-plugin-keepawake = "0.1.1"
tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -2379,34 +2379,70 @@
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"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": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
"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 start command without any pre-configured scope.",
"description": "Enables the is_registered command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
"const": "global-shortcut:allow-is-registered",
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"description": "Enables the register command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
"const": "global-shortcut:allow-register",
"markdownDescription": "Enables the register command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"description": "Enables the register_all command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
"const": "global-shortcut:allow-register-all",
"markdownDescription": "Enables the register_all command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"description": "Enables the unregister command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
"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`",

View File

@@ -2378,36 +2378,6 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",

View File

@@ -9,6 +9,8 @@ use std::ffi::OsStr;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -17,10 +19,24 @@ use std::thread;
use std::time::{Duration, Instant};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
fn log_line(message: &str) {
println!("[tauri-cli] {message}");
}
#[cfg(windows)]
fn configure_spawn(command: &mut Command) {
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn configure_spawn(_command: &mut Command) {}
fn workspace_root() -> Option<PathBuf> {
std::env::current_dir().ok().and_then(|mut dir| {
for _ in 0..3 {
@@ -36,6 +52,46 @@ const SESSION_COOKIE_NAME: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
#[cfg(unix)]
fn configure_posix_process_group(command: &mut Command) {
// Ensure the CLI runs in its own process group so we can terminate wrapper
// processes (login shell/tsx) without leaving the server orphaned.
unsafe {
command.pre_exec(|| {
if libc::setpgid(0, 0) != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(windows)]
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
if force {
args.push("/F".to_string());
}
let mut command = Command::new("taskkill");
command.args(&args);
configure_spawn(&mut command);
match command.output() {
Ok(output) => {
if output.status.success() {
return true;
}
// If the PID is already gone, treat it as success.
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
let combined = format!("{stdout}\n{stderr}");
combined.contains("not found") || combined.contains("no running instance")
}
Err(_) => false,
}
}
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
let mut display = url.to_string();
@@ -348,11 +404,19 @@ impl CliProcessManager {
log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
let pid = child.id() as i32;
// Prefer signaling the process group to avoid orphaning children
// when the CLI was launched via a wrapper shell.
let group_res = libc::kill(-pid, libc::SIGTERM);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGTERM);
}
}
#[cfg(windows)]
{
let _ = child.kill();
if !kill_process_tree_windows(child.id(), false) {
let _ = child.kill();
}
}
let start = Instant::now();
@@ -368,11 +432,17 @@ impl CliProcessManager {
));
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGKILL);
let pid = child.id() as i32;
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
}
#[cfg(windows)]
{
let _ = child.kill();
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
break;
}
@@ -450,9 +520,12 @@ impl CliProcessManager {
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()?
}
ShellCommandType::Direct(cmd) => {
@@ -462,9 +535,12 @@ impl CliProcessManager {
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()?
}
};
@@ -537,7 +613,24 @@ impl CliProcessManager {
locked.error = Some("CLI did not start in time".to_string());
log_line("timeout waiting for CLI readiness");
if let Some(child) = child_holder_clone.lock().as_mut() {
let _ = child.kill();
#[cfg(unix)]
unsafe {
let pid = child.id() as i32;
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
}
#[cfg(windows)]
{
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = child.kill();
}
}
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
Self::emit_status(&app_clone, &locked);
@@ -828,14 +921,31 @@ impl CliEntry {
if dev {
// Dev: plain HTTP + Vite dev server proxy.
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
std::env::var("ELECTRON_RENDERER_URL")
.ok()
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| "http://localhost:3000".to_string());
let log_level = std::env::var("CLI_LOG_LEVEL")
.ok()
.map(|value| value.trim().to_lowercase())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "info".to_string());
args.push("--https".to_string());
args.push("false".to_string());
args.push("--http".to_string());
args.push("true".to_string());
args.push("--http-port".to_string());
args.push("0".to_string());
args.push("--ui-dev-server".to_string());
args.push("http://localhost:3000".to_string());
args.push(ui_dev_server);
args.push("--log-level".to_string());
args.push("debug".to_string());
args.push(log_level);
} else {
// Prod desktop: always keep loopback HTTP enabled.
args.push("--https".to_string());
@@ -900,6 +1010,11 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/index.js")));
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
let resources = dir.join("../Resources");
candidates.push(Some(resources.join("server/dist/bin.js")));
candidates.push(Some(resources.join("server/dist/index.js")));
@@ -995,9 +1110,18 @@ fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
}
fn normalize_path(path: PathBuf) -> String {
if let Ok(clean) = path.canonicalize() {
clean.to_string_lossy().to_string()
let resolved = if let Ok(clean) = path.canonicalize() {
clean
} else {
path.to_string_lossy().to_string()
path
};
let rendered = resolved.to_string_lossy().to_string();
if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") {
format!("\\\\{}", stripped)
} else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") {
stripped.to_string()
} else {
rendered
}
}

View File

@@ -3,20 +3,52 @@
mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus};
use keepawake::KeepAwake;
use serde::Deserialize;
use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
use tauri_plugin_opener::OpenerExt;
use url::Url;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[cfg(windows)]
use std::ffi::OsStr;
#[cfg(windows)]
use std::iter;
#[cfg(windows)]
use std::os::windows::ffi::OsStrExt;
#[cfg(windows)]
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.2;
const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0;
#[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
#[derive(Clone)]
pub struct AppState {
pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct WakeLockConfig {
display: bool,
idle: bool,
sleep: bool,
}
#[tauri::command]
@@ -35,6 +67,38 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
Ok(state.manager.status())
}
#[tauri::command]
fn wake_lock_start(
state: tauri::State<AppState>,
config: Option<WakeLockConfig>,
) -> Result<(), String> {
let config = config.unwrap_or(WakeLockConfig {
display: true,
idle: false,
sleep: false,
});
let mut builder = keepawake::Builder::default();
builder
.display(config.display)
.idle(config.idle)
.sleep(config.sleep)
.reason("CodeNomad active session")
.app_name("CodeNomad")
.app_reverse_domain("ai.neuralnomads.codenomad.client");
let wake_lock = builder.create().map_err(|err| err.to_string())?;
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
*state_lock = Some(wake_lock);
Ok(())
}
#[tauri::command]
fn wake_lock_stop(state: tauri::State<AppState>) -> Result<(), String> {
let mut state_lock = state.wake_lock.lock().map_err(|err| err.to_string())?;
state_lock.take();
Ok(())
}
fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
@@ -46,7 +110,10 @@ fn should_allow_internal(url: &Url) -> bool {
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
// This must be treated as an internal origin or the navigation guard will
// redirect it to the system browser and the app will appear blank.
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
"http" | "https" => matches!(
url.host_str(),
Some("127.0.0.1" | "localhost" | "tauri.localhost")
),
_ => false,
}
}
@@ -66,6 +133,132 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
false
}
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
paths
.iter()
.filter_map(|path| match std::fs::metadata(path) {
Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()),
_ => None,
})
.collect()
}
fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) {
if let Some(window) = app_handle.get_webview_window(window_label) {
let _ = window.emit(event_name, ());
}
}
fn emit_folder_drop_event(
app_handle: &AppHandle,
window_label: &str,
event_name: &str,
paths: &[std::path::PathBuf],
) {
let directories = collect_directory_paths(paths);
if directories.is_empty() {
return;
}
if let Some(window) = app_handle.get_webview_window(window_label) {
let _ = window.emit(event_name, json!({ "paths": directories }));
}
}
fn clamp_zoom_level(value: f64) -> f64 {
value.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
}
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
if let Some(window) = app_handle.get_webview_window("main") {
let normalized = clamp_zoom_level(next_zoom);
if window.set_zoom(normalized).is_ok() {
if let Ok(mut zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
*zoom_level = normalized;
}
}
}
}
fn reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.reload();
}
}
fn force_reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
if let Ok(mut url) = window.url() {
if should_allow_internal(&url) {
let reload_token = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.to_string();
let existing_pairs: Vec<(String, String)> = url
.query_pairs()
.into_owned()
.filter(|(key, _)| key != "__codenomad_force_reload")
.collect();
{
let mut pairs = url.query_pairs_mut();
pairs.clear();
for (key, value) in existing_pairs {
pairs.append_pair(&key, &value);
}
pairs.append_pair("__codenomad_force_reload", &reload_token);
}
let _ = window.navigate(url);
return;
}
}
let _ = window.reload();
}
}
fn toggle_fullscreen_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let next_fullscreen = !window.is_fullscreen().unwrap_or(false);
let _ = window.set_fullscreen(next_fullscreen);
if cfg!(not(target_os = "macos")) {
if next_fullscreen {
let _ = window.hide_menu();
} else {
let _ = window.show_menu();
}
}
}
}
fn fullscreen_shortcut() -> Option<Shortcut> {
if cfg!(target_os = "macos") {
None
} else {
Some(Shortcut::new(None, ShortcutCode::F11))
}
}
#[cfg(windows)]
fn set_windows_app_user_model_id() {
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
.encode_wide()
.chain(iter::once(0))
.collect();
let result = unsafe { SetCurrentProcessExplicitAppUserModelID(app_id.as_ptr()) };
if result < 0 {
eprintln!("[tauri] failed to set AppUserModelID: {result}");
}
}
#[cfg(not(windows))]
fn set_windows_app_user_model_id() {}
fn main() {
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
@@ -74,14 +267,48 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_keepawake::init())
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state() != ShortcutState::Pressed {
return;
}
if fullscreen_shortcut().as_ref() == Some(shortcut) {
toggle_fullscreen_window(app);
}
})
.build(),
)
.plugin(tauri_plugin_notification::init())
.plugin(navigation_guard)
.manage(AppState {
manager: CliProcessManager::new(),
wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
})
.setup(|app| {
set_windows_app_user_model_id();
build_menu(&app.handle())?;
if let Some(shortcut) = fullscreen_shortcut() {
let shortcut_manager = app.handle().global_shortcut();
let _ = shortcut_manager.register(shortcut.clone());
if let Some(window) = app.get_webview_window("main") {
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let WindowEvent::Focused(focused) = event {
let shortcut_manager = app_handle.global_shortcut();
if *focused {
let _ = shortcut_manager.register(shortcut.clone());
} else {
let _ = shortcut_manager.unregister(shortcut.clone());
}
}
});
}
}
let dev_mode = is_dev_mode();
let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone();
@@ -92,7 +319,12 @@ fn main() {
});
Ok(())
})
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
.invoke_handler(tauri::generate_handler![
cli_get_status,
cli_restart,
wake_lock_start,
wake_lock_stop
])
.on_menu_event(|app_handle, event| {
match event.id().0.as_str() {
// File menu
@@ -101,36 +333,42 @@ fn main() {
let _ = window.emit("menu:newInstance", ());
}
}
"close" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
"quit" => {
app_handle.exit(0);
}
// View menu
"reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload()");
}
reload_main_window(app_handle);
}
"force_reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload(true)");
}
force_reload_main_window(app_handle);
}
"toggle_devtools" => {
if let Some(window) = app_handle.get_webview_window("main") {
window.open_devtools();
if window.is_devtools_open() {
window.close_devtools();
} else {
window.open_devtools();
}
}
}
"reset_zoom" => {
set_main_window_zoom(app_handle, DEFAULT_ZOOM_LEVEL);
}
"zoom_in" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level + ZOOM_STEP);
}
}
"zoom_out" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level - ZOOM_STEP);
}
}
"toggle_fullscreen" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
}
toggle_fullscreen_window(app_handle);
}
// Window menu
@@ -144,6 +382,11 @@ fn main() {
let _ = window.maximize();
}
}
"close_window" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
// App menu (macOS)
"about" => {
@@ -187,6 +430,27 @@ fn main() {
app.exit(0);
});
}
tauri::RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }),
..
} => {
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths);
}
tauri::RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }),
..
} => {
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths);
}
tauri::RunEvent::WindowEvent {
label,
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave),
..
} => {
emit_window_event(&app_handle, &label, "desktop:folder-drag-leave");
}
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::CloseRequested { api, .. },
..
@@ -210,6 +474,7 @@ fn main() {
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
let is_mac = cfg!(target_os = "macos");
let is_linux = cfg!(target_os = "linux");
// Create submenus
let mut submenus = Vec::new();
@@ -234,16 +499,77 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
"new_instance",
"New Instance",
true,
Some("CmdOrCtrl+N")
Some("CmdOrCtrl+N"),
)?;
let file_menu = SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
.build()?;
let file_menu = if is_mac {
SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.close_window()
.build()?
} else {
SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text("quit", "Quit")
.build()?
};
submenus.push(file_menu);
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
let force_reload_item = MenuItem::with_id(
app,
"force_reload",
"Force Reload",
true,
Some("CmdOrCtrl+Shift+R"),
)?;
let toggle_devtools_item = MenuItem::with_id(
app,
"toggle_devtools",
"Toggle Developer Tools",
true,
Some("Alt+CmdOrCtrl+I"),
)?;
let reset_zoom_item =
MenuItem::with_id(app, "reset_zoom", "Actual Size", true, Some("CmdOrCtrl+0"))?;
let zoom_in_item = MenuItem::with_id(
app,
"zoom_in",
if is_mac { "Zoom In" } else { "Zoom In\tCtrl++" },
true,
None::<&str>,
)?;
let zoom_out_item = MenuItem::with_id(
app,
"zoom_out",
if is_mac {
"Zoom Out"
} else {
"Zoom Out\tCtrl+-"
},
true,
None::<&str>,
)?;
let toggle_fullscreen_item = MenuItem::with_id(
app,
"toggle_fullscreen",
if is_mac {
"Toggle Full Screen"
} else {
"Toggle Full Screen\tF11"
},
true,
if is_mac {
Some("Ctrl+Cmd+F")
} else {
None::<&str>
},
)?;
let close_window_item =
MenuItem::with_id(app, "close_window", "Close", true, Some("CmdOrCtrl+W"))?;
// Edit menu with predefined items for standard functionality
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
@@ -259,27 +585,48 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
// View menu
let view_menu = SubmenuBuilder::new(app, "View")
.text("reload", "Reload")
.text("force_reload", "Force Reload")
.text("toggle_devtools", "Toggle Developer Tools")
.item(&reload_item)
.item(&force_reload_item)
.item(&toggle_devtools_item)
.separator()
.item(&reset_zoom_item)
.item(&zoom_in_item)
.item(&zoom_out_item)
.separator()
.text("toggle_fullscreen", "Toggle Full Screen")
.item(&toggle_fullscreen_item)
.build()?;
submenus.push(view_menu);
// Window menu
let window_menu = SubmenuBuilder::new(app, "Window")
.text("minimize", "Minimize")
.text("zoom", "Zoom")
.build()?;
let window_menu = if is_linux {
SubmenuBuilder::new(app, "Window")
.text("minimize", "Minimize")
.text("zoom", "Zoom")
.separator()
.item(&close_window_item)
.build()?
} else if is_mac {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.build()?
} else {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.separator()
.close_window()
.build()?
};
submenus.push(window_menu);
// Build the main menu with all submenus
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus
.iter()
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
.collect();
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
app.set_menu(menu)?;
Ok(())
}

View File

@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad",
"version": "0.1.0",
"identifier": "ai.opencode.client",
"version": "0.12.3",
"identifier": "ai.neuralnomads.codenomad.client",
"build": {
"beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server",

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.12.2",
"version": "0.12.3",
"private": true,
"license": "MIT",
"type": "module",
@@ -18,8 +18,10 @@
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3",
"ansi-sequence-parser": "^1.1.3",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
@@ -30,7 +32,7 @@
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0",
"virtua": "^0.48.8",
"yaml": "^2.4.2"
},
"devDependencies": {
@@ -43,4 +45,4 @@
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-solid": "^2.10.0"
}
}
}

View File

@@ -9,12 +9,10 @@ import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { SettingsScreen } from "./components/settings-screen"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger"
@@ -54,11 +52,11 @@ import {
} from "./stores/sessions"
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
import { openSettings } from "./stores/settings-screen"
const log = getLogger("actions")
const App: Component = () => {
const { isDark } = useTheme()
const { t } = useI18n()
const {
preferences,
@@ -77,8 +75,6 @@ const App: Component = () => {
setToolInputsVisibility,
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const phoneQuery = useMediaQuery("(max-width: 767px)")
@@ -184,10 +180,6 @@ const App: Component = () => {
}
})
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => {
initReleaseNotifications()
})
@@ -252,7 +244,6 @@ const App: Component = () => {
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
log.info("Created instance", {
instanceId,
@@ -274,7 +265,7 @@ const App: Component = () => {
function handleLaunchErrorAdvanced() {
clearLaunchError()
setIsAdvancedSettingsOpen(true)
openSettings("opencode")
}
function handleNewInstanceRequest() {
@@ -487,7 +478,6 @@ const App: Component = () => {
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
@@ -533,10 +523,6 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
@@ -546,12 +532,8 @@ const App: Component = () => {
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onClose={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
/>
@@ -559,7 +541,7 @@ const App: Component = () => {
</div>
</Show>
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<SettingsScreen />
<AlertDialog />

View File

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

View File

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

View File

@@ -2,32 +2,32 @@ import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { ThemeModeToggle } from "./theme-mode-toggle"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
import { githubStars } from "../stores/github-stars"
import { formatCompactCount } from "../lib/formatters"
import { useI18n, type Locale } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void
isLoading?: boolean
advancedSettingsOpen?: boolean
onAdvancedSettingsOpen?: () => void
onAdvancedSettingsClose?: () => void
onOpenRemoteAccess?: () => void
onClose?: () => void
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
@@ -193,6 +193,31 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
})
})
function dropTargetBlocked() {
return isLoading() || isFolderBrowserOpen() || settingsOpen()
}
function showInvalidFolderDropAlert() {
showAlertDialog(t("folderSelection.drop.invalidMessage"), {
title: t("folderSelection.drop.invalidTitle"),
variant: "warning",
})
}
const folderDrop = useFolderDrop({
enabled: () => !dropTargetBlocked(),
onInvalidDrop: showInvalidFolderDropAlert,
onDrop: async (paths) => {
const firstPath = paths[0]
if (!firstPath) {
showInvalidFolderDropAlert()
return
}
handleFolderSelect(firstPath)
},
})
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
const minutes = Math.floor(seconds / 60)
@@ -210,11 +235,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
props.onSelectFolder(path, selectedBinary())
}
const openExternalLink = (url: string) => {
if (typeof window === "undefined") return
window.open(url, "_blank", "noopener,noreferrer")
}
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
@@ -237,11 +257,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
handleFolderSelect(path)
}
function handleBinaryChange(binary: string) {
setSelectedBinary(binary)
}
function handleRemove(path: string, e?: Event) {
if (isLoading()) return
e?.stopPropagation()
@@ -317,6 +332,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
style="background-color: var(--surface-secondary)"
onDragEnter={folderDrop.bind.onDragEnter}
onDragOver={folderDrop.bind.onDragOver}
onDragLeave={folderDrop.bind.onDragLeave}
onDrop={folderDrop.bind.onDrop}
>
<div
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
@@ -367,16 +386,24 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Select>
</div>
<div class="absolute top-4 right-6 flex items-center gap-2">
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
<Show when={props.onOpenRemoteAccess}>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()}
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => openSettings("appearance")}
aria-label={t("settings.open.title")}
title={t("settings.open.title")}
>
<Settings class="w-4 h-4" />
</button>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => openSettings("remote")}
aria-label={t("instanceTabs.remote.ariaLabel")}
title={t("instanceTabs.remote.title")}
>
<MonitorUp class="w-4 h-4" />
</button>
<Show when={props.onClose}>
<button
type="button"
@@ -396,7 +423,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<div class="mt-3 flex justify-center gap-2">
<a
href="https://github.com/NeuralNomadsAI/CodeNomad"
href={GITHUB_URL}
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -404,13 +431,13 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.github")}
onClick={(event) => {
event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
void openExternalUrl(GITHUB_URL, "folder-selection")
}}
>
<GitHubMarkIcon class="w-4 h-4" />
</a>
<a
href="https://github.com/NeuralNomadsAI/CodeNomad"
href={GITHUB_URL}
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
@@ -418,7 +445,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.githubStars")}
onClick={(event) => {
event.preventDefault()
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
void openExternalUrl(GITHUB_URL, "folder-selection")
}}
>
<Star class="w-4 h-4" />
@@ -427,7 +454,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show>
</a>
<a
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
href={DISCORD_URL}
target="_blank"
rel="noreferrer"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -435,9 +462,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
title={t("folderSelection.links.discord")}
onClick={(event) => {
event.preventDefault()
openExternalLink(
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
)
void openExternalUrl(DISCORD_URL, "folder-selection")
}}
>
<DiscordSymbolIcon class="w-4 h-4" />
@@ -564,12 +589,12 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</button>
</div>
{/* Advanced settings section */}
{/* OpenCode settings section */}
<div class="panel-section w-full">
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
<button onClick={() => openSettings("opencode")} class="panel-section-header w-full justify-between">
<div class="flex items-center gap-2">
<Settings class="w-4 h-4 icon-muted" />
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
<span class="text-sm font-medium text-secondary">{t("folderSelection.opencode")}</span>
</div>
<ChevronRight class="w-4 h-4 icon-muted" />
</button>
@@ -619,16 +644,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</div>
</Show>
<Show when={folderDrop.isSupported && folderDrop.isActive() && !dropTargetBlocked()}>
<div class="folder-drop-overlay" aria-hidden="true">
<div class="folder-drop-card">
<FolderPlus class="w-8 h-8 icon-muted" />
<p class="folder-drop-title">{t("folderSelection.drop.title")}</p>
<p class="folder-drop-subtext">{t("folderSelection.drop.subtitle")}</p>
</div>
</div>
</Show>
</div>
<AdvancedSettingsModal
open={Boolean(props.advancedSettingsOpen)}
onClose={() => props.onAdvancedSettingsClose?.()}
selectedBinary={selectedBinary()}
onBinaryChange={handleBinaryChange}
isLoading={props.isLoading}
/>
<DirectoryBrowserDialog
open={isFolderBrowserOpen()}
title={t("folderSelection.dialog.title")}

View File

@@ -1,15 +1,14 @@
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Component, For, Show, createMemo } from "solid-js"
import { Dynamic } from "solid-js/web"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n"
import { ThemeModeToggle } from "./theme-mode-toggle"
import NotificationsSettingsModal from "./notifications-settings-modal"
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { useConfig } from "../stores/preferences"
import { openSettings } from "../stores/settings-screen"
interface InstanceTabsProps {
instances: Map<string, Instance>
@@ -17,13 +16,11 @@ interface InstanceTabsProps {
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
onNew: () => void
onOpenRemoteAccess?: () => void
}
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
@@ -33,8 +30,10 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
})
const notificationTitle = createMemo(() => {
if (!notificationsSupported()) return "Notifications unsupported"
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
if (!notificationsSupported()) return t("settings.notifications.status.unsupported")
return notificationsEnabled()
? t("settings.notifications.status.enabled")
: t("settings.notifications.status.disabled")
})
return (
@@ -72,32 +71,35 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
/>
</div>
</Show>
<ThemeModeToggle class="new-tab-button" />
<button
class="new-tab-button"
onClick={() => openSettings("appearance")}
title={t("settings.open.title")}
aria-label={t("settings.open.ariaLabel")}
>
<Settings class="w-4 h-4" />
</button>
<button
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
onClick={() => setNotificationsOpen(true)}
title={notificationTitle()}
aria-label={notificationTitle()}
>
<button
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
onClick={() => openSettings("notifications")}
title={notificationTitle()}
aria-label={notificationTitle()}
>
<Dynamic component={notificationIcon()} class="w-4 h-4" />
</button>
<Show when={Boolean(props.onOpenRemoteAccess)}>
<button
class="new-tab-button tab-remote-button"
onClick={() => props.onOpenRemoteAccess?.()}
title={t("instanceTabs.remote.title")}
aria-label={t("instanceTabs.remote.ariaLabel")}
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
<button
class="new-tab-button tab-remote-button"
onClick={() => openSettings("remote")}
title={t("instanceTabs.remote.title")}
aria-label={t("instanceTabs.remote.ariaLabel")}
>
<MonitorUp class="w-4 h-4" />
</button>
</div>
</div>
</div>
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
</div>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message"
@@ -29,6 +28,12 @@ const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
const LazyToolCall = lazy(() => import("./tool-call"))
function ToolCallFallback() {
return <div class="tool-call tool-call-loading" />
}
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -500,16 +505,18 @@ function ToolCallItem(props: ToolCallItemProps) {
</div>
</div>
<ToolCall
toolCall={resolvedToolPart()}
toolCallId={props.partId}
messageId={props.messageId}
messageVersion={messageVersion()}
partVersion={partVersion()}
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onContentRendered}
/>
<Suspense fallback={<ToolCallFallback />}>
<LazyToolCall
toolCall={resolvedToolPart()}
toolCallId={props.partId}
messageId={props.messageId}
messageVersion={messageVersion()}
partVersion={partVersion()}
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onContentRendered}
/>
</Suspense>
</div>
)}
</Show>
@@ -578,7 +585,6 @@ export default function MessageBlock(props: MessageBlockProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
const isDeleteMessageHovered = () => {
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
@@ -903,6 +909,7 @@ export default function MessageBlock(props: MessageBlockProps) {
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/>
</Match>
</Switch>
@@ -1281,6 +1288,7 @@ interface ReasoningCardProps {
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onContentRendered?: () => void
}
function ReasoningCard(props: ReasoningCardProps) {
@@ -1289,12 +1297,25 @@ function ReasoningCard(props: ReasoningCardProps) {
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
let pendingRenderNotificationFrame: number | null = null
let headerEl: HTMLDivElement | undefined
let actionsEl: HTMLDivElement | undefined
let primaryEl: HTMLSpanElement | undefined
let metaMeasureEl: HTMLSpanElement | undefined
const [showMetaInline, setShowMetaInline] = createSignal(true)
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
})
createEffect(() => {
setExpanded(Boolean(props.defaultExpanded))
@@ -1323,33 +1344,6 @@ function ReasoningCard(props: ReasoningCardProps) {
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
const updateMetaLayout = () => {
if (!hasMeta()) return
if (!headerEl || !actionsEl || !primaryEl || !metaMeasureEl) return
const headerWidth = headerEl.getBoundingClientRect().width
const actionsWidth = actionsEl.getBoundingClientRect().width
const primaryWidth = primaryEl.getBoundingClientRect().width
const metaWidth = metaMeasureEl.getBoundingClientRect().width
const availableLeft = Math.max(0, headerWidth - actionsWidth - 12)
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
}
createEffect(() => {
if (!hasMeta() || typeof ResizeObserver === "undefined") {
setShowMetaInline(true)
return
}
updateMetaLayout()
const observer = new ResizeObserver(() => updateMetaLayout())
if (headerEl) observer.observe(headerEl)
if (actionsEl) observer.observe(actionsEl)
if (primaryEl) observer.observe(primaryEl)
onCleanup(() => observer.disconnect())
})
const reasoningText = () => {
const part = props.part as any
if (!part) return ""
@@ -1390,6 +1384,12 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
createEffect(() => {
if (!expanded()) return
reasoningText()
notifyContentRendered()
})
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
@@ -1428,7 +1428,7 @@ function ReasoningCard(props: ReasoningCardProps) {
return (
<div class="delete-hover-scope message-reasoning-card">
<div class="message-reasoning-header" ref={(el) => (headerEl = el)}>
<div class="message-reasoning-header">
<button
type="button"
class="message-reasoning-toggle"
@@ -1437,7 +1437,7 @@ function ReasoningCard(props: ReasoningCardProps) {
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
>
<span class="message-reasoning-label">
<span class="message-reasoning-label-primary" ref={(el) => (primaryEl = el)}>
<span class="message-reasoning-label-primary">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
@@ -1458,43 +1458,10 @@ function ReasoningCard(props: ReasoningCardProps) {
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
</span>
<Show when={hasMeta() && showMetaInline()}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</Show>
<Show when={hasMeta()}>
<span
ref={(el) => (metaMeasureEl = el)}
class="message-step-meta-inline message-step-meta-inline--measure"
>
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</Show>
</span>
</button>
<div class="message-reasoning-actions" ref={(el) => (actionsEl = el)}>
<div class="message-reasoning-actions">
<button
type="button"
class="message-action-button"
@@ -1543,7 +1510,7 @@ function ReasoningCard(props: ReasoningCardProps) {
</div>
</div>
<Show when={hasMeta() && !showMetaInline()}>
<Show when={hasMeta()}>
<div class="message-reasoning-meta-row">
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
import { Dialog } from "@kobalte/core/dialog"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
import { createMemo, For, type Component } from "solid-js"
import { useI18n } from "../lib/i18n"
import {
activeSettingsSection,
closeSettings,
settingsOpen,
setActiveSettingsSection,
type SettingsSectionId,
} from "../stores/settings-screen"
import { AppearanceSettingsSection } from "./settings/appearance-settings-section"
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
export const SettingsScreen: Component = () => {
const { t } = useI18n()
const sections = createMemo(() => [
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
])
const renderSection = () => {
switch (activeSettingsSection()) {
case "notifications":
return <NotificationsSettingsSection />
case "remote":
return <RemoteAccessSettingsSection />
case "opencode":
return <OpenCodeSettingsSection />
case "appearance":
default:
return <AppearanceSettingsSection />
}
}
return (
<Dialog open={settingsOpen()} onOpenChange={(open) => !open && closeSettings()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="settings-screen-frame">
<Dialog.Content class="modal-surface settings-screen-shell">
<Dialog.Title class="sr-only">{t("settings.title")}</Dialog.Title>
<aside class="settings-screen-nav">
<div class="settings-screen-nav-header">
<div class="settings-screen-nav-title-row">
<span class="settings-screen-nav-icon-wrap">
<Settings class="settings-screen-nav-icon" />
</span>
<div>
<h2 class="settings-screen-title">{t("settings.title")}</h2>
</div>
</div>
</div>
<nav class="settings-screen-nav-list" aria-label={t("settings.navigationAriaLabel")}>
<For each={sections()}>
{(section) => {
const Icon = section.icon
return (
<button
type="button"
class="settings-nav-button"
data-selected={activeSettingsSection() === section.id ? "true" : "false"}
onClick={() => setActiveSettingsSection(section.id)}
>
<Icon class="settings-nav-button-icon" />
<span>{section.label}</span>
</button>
)
}}
</For>
</nav>
</aside>
<div class="settings-screen-content">
<header class="settings-screen-content-header">
<div class="settings-screen-content-header-title-group">
<p class="settings-screen-content-eyebrow">{t("settings.content.eyebrow")}</p>
<h1 class="settings-screen-content-title">
{sections().find((section) => section.id === activeSettingsSection())?.label}
</h1>
</div>
<button
type="button"
class="selector-button selector-button-secondary settings-screen-close"
onClick={closeSettings}
aria-label={t("settings.close")}
title={t("settings.close")}
>
<X class="w-4 h-4" />
</button>
</header>
<div class="settings-screen-scroll">{renderSection()}</div>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -0,0 +1,270 @@
import { Select } from "@kobalte/core/select"
import { createEffect, createMemo, createSignal, For, type Component } from "solid-js"
import { Check, ChevronDown, Laptop, Moon, Sun } from "lucide-solid"
import { useI18n } from "../../lib/i18n"
import { useTheme, type ThemeMode } from "../../lib/theme"
import { useConfig } from "../../stores/preferences"
import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry"
const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [
{ value: "system", icon: Laptop },
{ value: "light", icon: Sun },
{ value: "dark", icon: Moon },
]
export const AppearanceSettingsSection: Component = () => {
const { t } = useI18n()
const { themeMode, setThemeMode } = useTheme()
const {
preferences,
updatePreferences,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
} = useConfig()
const behaviorSettings = createMemo(() =>
getBehaviorSettings({
preferences,
updatePreferences,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
setToolInputsVisibility,
}),
)
const [overrides, setOverrides] = createSignal<Map<string, unknown>>(new Map())
const setOverride = (id: string, value: unknown) => {
setOverrides((prev) => {
const next = new Map(prev)
next.set(id, value)
return next
})
}
createEffect(() => {
const current = overrides()
if (current.size === 0) return
const prefs = preferences()
const settings = behaviorSettings()
let changed = false
const next = new Map(current)
for (const setting of settings) {
if (!next.has(setting.id)) continue
const overrideValue = next.get(setting.id)
const actualValue = setting.get(prefs)
if (Object.is(actualValue, overrideValue)) {
next.delete(setting.id)
changed = true
}
}
if (changed) {
setOverrides(next)
}
})
const readSettingValue = (setting: BehaviorSetting) => {
const current = overrides()
if (current.has(setting.id)) return current.get(setting.id)
return setting.get(preferences())
}
type SelectOption = { value: string; label: string }
const BehaviorRow: Component<{ setting: BehaviorSetting }> = (props) => {
const setting = props.setting
const disabled = createMemo(() => (setting.disabled ? Boolean(setting.disabled()) : false))
if (setting.kind === "toggle") {
const options = createMemo<SelectOption[]>(() => [
{ value: "true", label: t("settings.common.enabled") },
{ value: "false", label: t("settings.common.disabled") },
])
const currentValue = createMemo(() => String(Boolean(readSettingValue(setting))))
const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue()))
return (
<div class={`settings-toggle-row ${disabled() ? "opacity-60" : ""}`}>
<div>
<div class="settings-toggle-title">{t(setting.titleKey)}</div>
<div class="settings-toggle-caption">{t(setting.subtitleKey)}</div>
</div>
<Select<SelectOption>
value={selectedOption()}
onChange={(opt) => {
if (!opt) return
const next = opt.value === "true"
setOverride(setting.id, next)
setting.set(next)
}}
options={options()}
optionValue="value"
optionTextValue="label"
disabled={disabled()}
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger class="selector-trigger" aria-label={t(setting.titleKey)}>
<div class="flex-1 min-w-0">
<Select.Value<SelectOption>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
)
}
const enumSetting = setting as Extract<BehaviorSetting, { kind: "enum" }>
const options = createMemo<SelectOption[]>(() =>
enumSetting.options.map((opt: { value: string; labelKey: string }) => ({
value: String(opt.value),
label: t(opt.labelKey),
})),
)
const currentValue = createMemo(() => String(readSettingValue(setting) ?? ""))
const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue()))
return (
<div class={`settings-toggle-row ${disabled() ? "opacity-60" : ""}`}>
<div>
<div class="settings-toggle-title">{t(setting.titleKey)}</div>
<div class="settings-toggle-caption">{t(setting.subtitleKey)}</div>
</div>
<Select<SelectOption>
value={selectedOption()}
onChange={(opt) => {
if (!opt) return
setOverride(setting.id, opt.value)
enumSetting.set(opt.value as any)
}}
options={options()}
optionValue="value"
optionTextValue="label"
disabled={disabled()}
itemComponent={(itemProps) => (
<Select.Item item={itemProps.item} class="selector-option">
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
</Select.Item>
)}
>
<Select.Trigger class="selector-trigger" aria-label={t(setting.titleKey)}>
<div class="flex-1 min-w-0">
<Select.Value<SelectOption>>
{(state) => (
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{state.selectedOption()?.label}
</span>
)}
</Select.Value>
</div>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
</div>
)
}
const modeLabel = (mode: ThemeMode) => {
if (mode === "system") return t("theme.mode.system")
if (mode === "light") return t("theme.mode.light")
return t("theme.mode.dark")
}
return (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.appearance.theme.title")}</h3>
<p class="settings-card-subtitle">{t("settings.appearance.theme.subtitle")}</p>
</div>
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
</div>
<div class="settings-choice-grid">
{themeModeOptions.map((option) => {
const Icon = option.icon
return (
<button
type="button"
class="settings-choice"
data-selected={themeMode() === option.value ? "true" : "false"}
onClick={() => setThemeMode(option.value)}
>
<span class="settings-choice-icon-wrap">
<Icon class="settings-choice-icon" />
</span>
<span class="settings-choice-copy">
<span class="settings-choice-label">{modeLabel(option.value)}</span>
<span class="settings-choice-description">{t(`settings.appearance.theme.option.${option.value}`)}</span>
</span>
<span class="settings-choice-check" aria-hidden="true">
<Check class="w-4 h-4" />
</span>
</button>
)
})}
</div>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.appearance.behavior.title")}</h3>
<p class="settings-card-subtitle">{t("settings.appearance.behavior.subtitle")}</p>
</div>
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
</div>
<div class="settings-stack">
<For each={behaviorSettings()}>{(setting) => <BehaviorRow setting={setting} />}</For>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,227 @@
import { Show, createEffect, createResource, type Component } from "solid-js"
import { Bell } from "lucide-solid"
import { showToastNotification } from "../../lib/notifications"
import {
getOsNotificationCapability,
requestOsNotificationPermission,
type OsNotificationPermission,
} from "../../lib/os-notifications"
import { useConfig } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
function formatPermissionLabel(permission: OsNotificationPermission, t: ReturnType<typeof useI18n>["t"]): string {
switch (permission) {
case "granted":
return t("settings.notifications.permission.granted")
case "denied":
return t("settings.notifications.permission.denied")
case "default":
return t("settings.notifications.permission.default")
case "unsupported":
return t("settings.notifications.permission.unsupported")
default:
return String(permission)
}
}
export const NotificationsSettingsSection: Component = () => {
const { t } = useI18n()
const { preferences, updatePreferences } = useConfig()
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
createEffect(() => {
void refetch()
})
const handleEnableToggle = async (enabled: boolean) => {
if (!enabled) {
updatePreferences({ osNotificationsEnabled: false })
return
}
const cap = capability()
if (cap && !cap.supported) {
showToastNotification({
title: t("settings.section.notifications.title"),
message: cap.info ?? t("settings.notifications.messages.unsupportedEnvironment"),
variant: "warning",
})
updatePreferences({ osNotificationsEnabled: false })
return
}
const permission = await requestOsNotificationPermission()
if (permission !== "granted") {
showToastNotification({
title: t("settings.section.notifications.title"),
message:
permission === "denied"
? t("settings.notifications.messages.permissionDenied")
: t("settings.notifications.messages.permissionNotGranted"),
variant: "warning",
})
updatePreferences({ osNotificationsEnabled: false })
return
}
updatePreferences({ osNotificationsEnabled: true })
void refetch()
}
const handleRequestPermission = async () => {
const cap = capability()
if (cap && !cap.supported) {
showToastNotification({
title: t("settings.section.notifications.title"),
message: cap.info ?? t("settings.notifications.messages.unsupportedGeneral"),
variant: "warning",
})
return
}
const permission = await requestOsNotificationPermission()
if (permission === "granted") {
showToastNotification({
title: t("settings.section.notifications.title"),
message: t("settings.notifications.messages.permissionGranted"),
variant: "success",
duration: 6000,
})
void refetch()
return
}
showToastNotification({
title: t("settings.section.notifications.title"),
message:
permission === "denied"
? t("settings.notifications.messages.permissionRequestDenied")
: t("settings.notifications.messages.permissionNotGranted"),
variant: "warning",
})
void refetch()
}
const supported = () => capability()?.supported ?? false
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported", t)
const infoMessage = () => capability()?.info
return (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Bell class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.notifications.sessionStatus.title")}</h3>
<p class="settings-card-subtitle">{t("settings.notifications.sessionStatus.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
</div>
<div class="settings-stack">
<div class="settings-toggle-row">
<div>
<div class="settings-toggle-title">{t("settings.notifications.enable.title")}</div>
<div class="settings-toggle-caption">
{t("settings.notifications.enable.permission", { permission: permissionLabel() })}
</div>
</div>
<label class="settings-checkbox-toggle">
<input
type="checkbox"
checked={Boolean(preferences().osNotificationsEnabled)}
disabled={!supported() && capability.state === "ready"}
onChange={(event) => void handleEnableToggle(event.currentTarget.checked)}
/>
<span>{t("settings.common.enabled")}</span>
</label>
</div>
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("settings.notifications.requestPermission.title")}</div>
<div class="settings-toggle-caption">{t("settings.notifications.requestPermission.subtitle")}</div>
</div>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
onClick={() => void handleRequestPermission()}
>
{t("settings.notifications.requestPermission.action")}
</button>
</div>
</Show>
<div class="settings-toggle-row">
<div>
<div class="settings-toggle-title">{t("settings.notifications.allowVisible.title")}</div>
<div class="settings-toggle-caption">{t("settings.notifications.allowVisible.subtitle")}</div>
</div>
<label class="settings-checkbox-toggle">
<input
type="checkbox"
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
disabled={!preferences().osNotificationsEnabled}
onChange={(event) => updatePreferences({ osNotificationsAllowWhenVisible: event.currentTarget.checked })}
/>
<span>{t("settings.common.enabled")}</span>
</label>
</div>
<Show when={Boolean(infoMessage())}>
<div class="settings-inline-note">{infoMessage()}</div>
</Show>
<Show when={!supported() && capability.state === "ready"}>
<div class="settings-inline-note">{t("settings.notifications.unsupportedNote")}</div>
</Show>
</div>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("settings.notifications.events.title")}</h3>
<p class="settings-card-subtitle">{t("settings.notifications.events.subtitle")}</p>
</div>
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
</div>
<div class="settings-stack">
<div class="settings-toggle-row">
<div>
<div class="settings-toggle-title">{t("settings.notifications.events.needsInput")}</div>
</div>
<label class="settings-checkbox-toggle">
<input
type="checkbox"
checked={Boolean(preferences().notifyOnNeedsInput)}
disabled={!preferences().osNotificationsEnabled}
onChange={(event) => updatePreferences({ notifyOnNeedsInput: event.currentTarget.checked })}
/>
<span>{t("settings.common.enabled")}</span>
</label>
</div>
<div class="settings-toggle-row">
<div>
<div class="settings-toggle-title">{t("settings.notifications.events.idle")}</div>
</div>
<label class="settings-checkbox-toggle">
<input
type="checkbox"
checked={Boolean(preferences().notifyOnIdle)}
disabled={!preferences().osNotificationsEnabled}
onChange={(event) => updatePreferences({ notifyOnIdle: event.currentTarget.checked })}
/>
<span>{t("settings.common.enabled")}</span>
</label>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { createEffect, createSignal, type Component } from "solid-js"
import { Terminal } from "lucide-solid"
import OpenCodeBinarySelector from "../opencode-binary-selector"
import EnvironmentVariablesEditor from "../environment-variables-editor"
import { useConfig } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
export const OpenCodeSettingsSection: Component = () => {
const { t } = useI18n()
const { serverSettings, updateLastUsedBinary } = useConfig()
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
createEffect(() => {
const binary = serverSettings().opencodeBinary || "opencode"
setSelectedBinary((current) => (current === binary ? current : binary))
})
const handleBinaryChange = (binary: string) => {
setSelectedBinary(binary)
updateLastUsedBinary(binary)
}
return (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Terminal class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.opencode.runtime.title")}</h3>
<p class="settings-card-subtitle">{t("settings.opencode.runtime.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("advancedSettings.environmentVariables.title")}</h3>
<p class="settings-card-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<EnvironmentVariablesEditor />
</div>
</div>
)
}

View File

@@ -0,0 +1,401 @@
import { Switch } from "@kobalte/core/switch"
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
import { toDataURL } from "qrcode"
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
import { serverApi } from "../../lib/api-client"
import { restartCli } from "../../lib/native/cli"
import { serverSettings, setListeningMode } from "../../stores/preferences"
import { showConfirmDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger"
import { useI18n } from "../../lib/i18n"
const log = getLogger("actions")
export const RemoteAccessSettingsSection: Component = () => {
const { t } = useI18n()
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{
authenticated: boolean
username?: string
passwordUserProvided?: boolean
} | null>(null)
const [loading, setLoading] = createSignal(false)
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null)
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
const [passwordValue, setPasswordValue] = createSignal("")
const [passwordConfirm, setPasswordConfirm] = createSignal("")
const [passwordError, setPasswordError] = createSignal<string | null>(null)
const [savingPassword, setSavingPassword] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo(() => {
const list = addresses()
if (!allowExternalConnections()) return []
return list.filter((address) => address.scope !== "loopback")
})
const refreshMeta = async () => {
setLoading(true)
setError(null)
setPasswordError(null)
try {
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
setMeta(metaResult)
setAuthStatus(authResult)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}
onMount(() => {
void refreshMeta()
})
const toggleExpanded = async (url: string) => {
if (expandedUrl() === url) {
setExpandedUrl(null)
return
}
setExpandedUrl(url)
if (!qrCodes()[url]) {
try {
const dataUrl = await toDataURL(url, { margin: 1, scale: 4 })
setQrCodes((prev) => ({ ...prev, [url]: dataUrl }))
} catch (err) {
log.error("Failed to generate QR code", err)
}
}
}
const handleAllowConnectionsChange = async (checked: boolean) => {
const targetMode: "local" | "all" = checked ? "all" : "local"
if (targetMode === currentMode() || applyingListeningMode()) return
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
title: checked
? t("remoteAccess.listeningMode.restartConfirm.title.all")
: t("remoteAccess.listeningMode.restartConfirm.title.local"),
variant: "warning",
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
})
if (!confirmed) return
setApplyingListeningMode(true)
setError(null)
try {
await setListeningMode(targetMode)
const restarted = await restartCli()
if (!restarted) {
setError(t("remoteAccess.restart.errorManual"))
} else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplyingListeningMode(false)
}
void refreshMeta()
}
const handleOpenUrl = (url: string) => {
try {
window.open(url, "_blank", "noopener,noreferrer")
} catch (err) {
log.error("Failed to open URL", err)
}
}
const handleSubmitPassword = async () => {
setPasswordError(null)
const next = passwordValue()
const confirm = passwordConfirm()
if (next.trim().length < 8) {
setPasswordError(t("remoteAccess.password.error.tooShort"))
return
}
if (next !== confirm) {
setPasswordError(t("remoteAccess.password.error.mismatch"))
return
}
setSavingPassword(true)
try {
const result = await serverApi.setServerPassword(next)
setAuthStatus({
authenticated: true,
username: result.username,
passwordUserProvided: result.passwordUserProvided,
})
setPasswordValue("")
setPasswordConfirm("")
setPasswordFormOpen(false)
} catch (err) {
setPasswordError(err instanceof Error ? err.message : String(err))
} finally {
setSavingPassword(false)
}
}
return (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Shield class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("remoteAccess.sections.listeningMode.label")}</h3>
<p class="settings-card-subtitle">{t("remoteAccess.sections.listeningMode.help")}</p>
</div>
</div>
<div class="settings-toolbar-inline">
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
<button
class="selector-button selector-button-secondary w-auto"
type="button"
onClick={() => void refreshMeta()}
disabled={loading()}
>
<RefreshCw class={`w-4 h-4 ${loading() ? "remote-spin" : ""}`} />
<span>{t("remoteAccess.refresh")}</span>
</button>
</div>
</div>
<Switch
class="remote-toggle"
checked={allowExternalConnections()}
onChange={(nextChecked) => void handleAllowConnectionsChange(nextChecked)}
disabled={loading() || applyingListeningMode()}
>
<Switch.Input />
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
<span class="remote-toggle-state">
{allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")}
</span>
<Switch.Thumb class="remote-toggle-thumb" />
</Switch.Control>
<div class="remote-toggle-copy">
<span class="remote-toggle-title">{t("remoteAccess.toggle.title")}</span>
<span class="remote-toggle-caption">
{allowExternalConnections()
? t("remoteAccess.toggle.caption.all")
: t("remoteAccess.toggle.caption.local")}
</span>
</div>
</Switch>
<p class="remote-toggle-note">{t("remoteAccess.toggle.note")}</p>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Shield class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("remoteAccess.sections.serverPassword.label")}</h3>
<p class="settings-card-subtitle">{t("remoteAccess.sections.serverPassword.help")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<Show
when={authStatus() && authStatus()!.authenticated}
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
>
<div class="settings-card-content">
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
<p class="settings-help-text">
{authStatus()!.passwordUserProvided
? t("remoteAccess.password.status.set")
: t("remoteAccess.password.status.unset")}
</p>
<div class="settings-password-actions">
<button
class="settings-pill-button"
type="button"
onClick={() => {
setPasswordFormOpen(!passwordFormOpen())
setPasswordError(null)
}}
>
{passwordFormOpen()
? t("remoteAccess.password.actions.cancel")
: authStatus()!.passwordUserProvided
? t("remoteAccess.password.actions.change")
: t("remoteAccess.password.actions.set")}
</button>
</div>
<Show when={passwordFormOpen()}>
<div class="settings-form-group">
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
<input
class="selector-input w-full"
type="password"
value={passwordValue()}
onInput={(event) => setPasswordValue(event.currentTarget.value)}
placeholder={t("remoteAccess.password.form.placeholder")}
/>
</div>
<div class="settings-form-group">
<label class="settings-form-label">{t("remoteAccess.password.form.confirmPassword")}</label>
<input
class="selector-input w-full"
type="password"
value={passwordConfirm()}
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
/>
</div>
<Show when={passwordError()}>
{(message) => <div class="settings-error-message">{message()}</div>}
</Show>
<div class="settings-password-actions">
<button class="settings-pill-button" type="button" disabled={savingPassword()} onClick={() => void handleSubmitPassword()}>
{savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
</button>
</div>
</Show>
</div>
</Show>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Wifi class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("remoteAccess.sections.addresses.label")}</h3>
<p class="settings-card-subtitle">{t("remoteAccess.sections.addresses.help")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show
when={displayAddresses().length > 0 || meta()?.localUrl}
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
>
<div class="remote-address-list">
<Show when={meta()?.localUrl}>
{(url) => {
const value = () => url()
const expandedState = () => expandedUrl() === value()
const qr = () => qrCodes()[value()]
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{value()}</p>
<p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(value())}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url: value() })}
class="remote-qr-img"
/>
)}
</Show>
</div>
</Show>
</div>
)
}}
</Show>
<For each={displayAddresses()}>
{(address) => {
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
)}
</Show>
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
</Show>
</Show>
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,457 +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) => void
id?: string
}
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())
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
let pendingVisibility: boolean | null = null
let visibilityFrame: number | null = null
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 shouldHideContent = createMemo(() => {
if (props.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 cleanupIntersectionObserver() {
if (intersectionCleanup) {
intersectionCleanup()
intersectionCleanup = undefined
}
}
function persistMeasurement(nextHeight: number) {
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
return
}
const before = measuredHeight()
const normalized = nextHeight
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
// 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?.()
}
setHasMeasured(true)
sizeCache.set(props.cacheKey, previous)
setMeasuredHeight(previous)
if (previous !== before) props.onHeightChange?.(previous, before)
return
}
if (normalized > 0) {
sizeCache.set(props.cacheKey, normalized)
setHasMeasured(true)
if (!hasReportedMeasurement) {
hasReportedMeasurement = true
props.onMeasured?.()
}
}
setMeasuredHeight(normalized)
if (normalized !== before) props.onHeightChange?.(normalized, before)
}
function updateMeasuredHeight() {
if (!contentRef || 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)
if (next === measuredHeight()) return
persistMeasurement(next)
}
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(() => {
if (shouldHideContent() || measurementsSuspended()) {
cleanupResizeObserver()
} else if (contentRef) {
queueMicrotask(() => {
updateMeasuredHeight()
setupResizeObserver()
})
}
})
createEffect(() => {
const key = props.cacheKey
const cached = sizeCache.get(key)
if (cached !== undefined) {
setMeasuredHeight(cached)
setHasMeasured(true)
} else {
setMeasuredHeight(fallbackPlaceholderHeight())
setHasMeasured(false)
}
})
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

@@ -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

@@ -14,7 +14,7 @@ import { getLogger } from "../logger"
import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
import { registerBehaviorCommands } from "../settings/behavior-registry"
const log = getLogger("actions")
@@ -427,178 +427,19 @@ export function useCommands(options: UseCommandsOptions) {
},
})
commandRegistry.register({
id: "prompt-submit-shortcut",
label: () =>
options.preferences().promptSubmitOnEnter
? tGlobal("commands.promptSubmitShortcut.label.swapped")
: tGlobal("commands.promptSubmitShortcut.label.default"),
description: () => tGlobal("commands.promptSubmitShortcut.description"),
category: "Input & Focus",
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
action: options.togglePromptSubmitOnEnter,
})
commandRegistry.register({
id: "thinking",
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
description: () => tGlobal("commands.thinkingBlocks.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
action: options.toggleShowThinkingBlocks,
})
commandRegistry.register({
id: "timeline-tools",
label: () => tGlobal(options.preferences().showTimelineTools ? "commands.timelineToolCalls.label.hide" : "commands.timelineToolCalls.label.show"),
description: () => tGlobal("commands.timelineToolCalls.description"),
category: "System",
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
action: options.toggleShowTimelineTools,
})
commandRegistry.register({
id: "keyboard-shortcut-hints",
label: () =>
tGlobal(
options.preferences().showKeyboardShortcutHints
? "commands.keyboardShortcutHints.label.hide"
: "commands.keyboardShortcutHints.label.show",
),
description: () =>
tGlobal(
runtimeEnv.host === "web"
? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description",
),
category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web",
action: options.toggleKeyboardShortcutHints,
})
commandRegistry.register({
id: "thinking-default-visibility",
label: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.thinkingBlocksDefault.label", { state })
},
description: () => tGlobal("commands.thinkingBlocksDefault.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
action: () => {
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setThinkingBlocksExpansion(next)
},
})
commandRegistry.register({
id: "diff-view-split",
label: () => {
const prefix = (options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
},
description: () => tGlobal("commands.diffViewSplit.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
action: () => options.setDiffViewMode("split"),
})
commandRegistry.register({
id: "diff-view-unified",
label: () => {
const prefix = (options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
},
description: () => tGlobal("commands.diffViewUnified.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
action: () => options.setDiffViewMode("unified"),
})
commandRegistry.register({
id: "tool-output-default-visibility",
label: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.toolOutputsDefault.label", { state })
},
description: () => tGlobal("commands.toolOutputsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
action: () => {
const mode = options.preferences().toolOutputExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setToolOutputExpansion(next)
},
})
commandRegistry.register({
id: "diagnostics-default-visibility",
label: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.diagnosticsDefault.label", { state })
},
description: () => tGlobal("commands.diagnosticsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
action: () => {
const mode = options.preferences().diagnosticsExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
options.setDiagnosticsExpansion(next)
},
})
commandRegistry.register({
id: "tool-inputs-visibility",
label: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const state =
mode === "expanded"
? tGlobal("commands.common.expanded")
: mode === "collapsed"
? tGlobal("commands.common.collapsed")
: tGlobal("commands.common.hidden")
return tGlobal("commands.toolInputsVisibility.label", { state })
},
description: () => tGlobal("commands.toolInputsVisibility.description"),
category: "System",
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
action: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const next: ToolInputsVisibilityPreference =
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
options.setToolInputsVisibility(next)
},
})
commandRegistry.register({
id: "token-usage-visibility",
label: () => {
const visible = options.preferences().showUsageMetrics ?? true
const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden")
return tGlobal("commands.tokenUsageDisplay.label", { state })
},
description: () => tGlobal("commands.tokenUsageDisplay.description"),
category: "System",
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
action: options.toggleUsageMetrics,
})
commandRegistry.register({
id: "auto-cleanup-blank-sessions",
label: () => {
const enabled = options.preferences().autoCleanupBlankSessions
const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled")
return tGlobal("commands.autoCleanupBlankSessions.label", { state })
},
description: () => tGlobal("commands.autoCleanupBlankSessions.description"),
category: "System",
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
action: options.toggleAutoCleanupBlankSessions,
registerBehaviorCommands((command) => commandRegistry.register(command), {
preferences: options.preferences,
toggleShowThinkingBlocks: options.toggleShowThinkingBlocks,
toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints,
toggleShowTimelineTools: options.toggleShowTimelineTools,
toggleUsageMetrics: options.toggleUsageMetrics,
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
setDiffViewMode: options.setDiffViewMode,
setToolOutputExpansion: options.setToolOutputExpansion,
setDiagnosticsExpansion: options.setDiagnosticsExpansion,
setThinkingBlocksExpansion: options.setThinkingBlocksExpansion,
setToolInputsVisibility: options.setToolInputsVisibility,
})
commandRegistry.register({

View File

@@ -0,0 +1,158 @@
import { Accessor, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import {
containsFileDrop,
extractDroppedDirectoryPaths,
listenForNativeFolderDrops,
listenForNativeFolderDropState,
normalizeDroppedDirectoryPaths,
supportsDesktopFolderDrop,
} from "../native/desktop-file-drop"
import { runtimeEnv } from "../runtime-env"
interface UseFolderDropOptions {
enabled: Accessor<boolean>
onDrop: (paths: string[]) => void | Promise<void>
onInvalidDrop?: () => void
}
interface FolderDropBindings {
onDragEnter: (event: DragEvent) => void
onDragOver: (event: DragEvent) => void
onDragLeave: (event: DragEvent) => void
onDrop: (event: DragEvent) => void
}
export function useFolderDrop(options: UseFolderDropOptions): {
isActive: Accessor<boolean>
isSupported: boolean
bind: FolderDropBindings
} {
const [isActive, setIsActive] = createSignal(false)
const [dragDepth, setDragDepth] = createSignal(0)
const isSupported = supportsDesktopFolderDrop()
function reset() {
setDragDepth(0)
setIsActive(false)
}
async function handleResolvedPaths(paths: string[]) {
reset()
if (!options.enabled()) {
return
}
const directoryPaths = await normalizeDroppedDirectoryPaths(paths)
if (directoryPaths.length === 0) {
options.onInvalidDrop?.()
return
}
await options.onDrop(directoryPaths)
}
createEffect(() => {
if (!options.enabled()) {
reset()
}
})
onMount(() => {
if (!isSupported) {
return
}
let disposeNativeDrop = () => {}
let disposeNativeState = () => {}
void listenForNativeFolderDrops((paths) => {
if (!options.enabled()) {
return
}
void handleResolvedPaths(paths)
}).then((dispose) => {
disposeNativeDrop = dispose
})
void listenForNativeFolderDropState((state) => {
if (!options.enabled()) {
reset()
return
}
if (state === "enter") {
setIsActive(true)
return
}
reset()
}).then((dispose) => {
disposeNativeState = dispose
})
onCleanup(() => {
disposeNativeDrop()
disposeNativeState()
})
})
const bind: FolderDropBindings = {
onDragEnter(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
return
}
event.preventDefault()
setDragDepth((prev) => prev + 1)
setIsActive(true)
},
onDragOver(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
return
}
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy"
}
setIsActive(true)
},
onDragLeave(event) {
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
return
}
event.preventDefault()
const nextDepth = Math.max(0, dragDepth() - 1)
setDragDepth(nextDepth)
if (nextDepth === 0) {
setIsActive(false)
}
},
onDrop(event) {
if (!isSupported) {
return
}
event.preventDefault()
event.stopPropagation()
if (!options.enabled()) {
reset()
return
}
if (runtimeEnv.host === "tauri") {
reset()
return
}
const paths = extractDroppedDirectoryPaths(event)
if (paths.length === 0) {
reset()
options.onInvalidDrop?.()
return
}
void handleResolvedPaths(paths)
},
}
return {
isActive,
isSupported,
bind,
}
}

View File

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

View File

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "Unable to launch OpenCode",
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from Advanced Settings.",
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from OpenCode settings.",
"app.launchError.binaryPathLabel": "Binary path",
"app.launchError.errorOutputLabel": "Error output",
"app.launchError.openAdvancedSettings": "Open Advanced Settings",
"app.launchError.openAdvancedSettings": "Open OpenCode Settings",
"app.launchError.close": "Close",
"app.launchError.closeTitle": "Close (Esc)",
"app.launchError.fallbackMessage": "Failed to launch workspace",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "Opening...",
"folderSelection.advancedSettings": "Advanced Settings",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Navigate",
"folderSelection.hints.select": "Select",
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
"folderSelection.loading.title": "Starting instance...",
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
"folderSelection.drop.title": "Drop a folder to open it",
"folderSelection.drop.subtitle": "Start a new instance in the dropped folder.",
"folderSelection.drop.invalidTitle": "Couldn't open dropped item",
"folderSelection.drop.invalidMessage": "Drop a folder to start a new instance.",
"folderSelection.dialog.title": "Select Workspace",
"folderSelection.dialog.description": "Select workspace to start coding.",
} as const

View File

@@ -114,6 +114,10 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
"instanceShell.sessionChanges.actions.show": "Show changes",
"instanceShell.gitChanges.loading": "Loading git changes...",
"instanceShell.gitChanges.empty": "No git changes yet.",
"instanceShell.gitChanges.deleted": "Deleted",
"instanceShell.filesShell.fileListTitle": "File list",
"instanceShell.filesShell.mobileSelectorLabel": "Select file",
"instanceShell.filesShell.mobileSelectorEmpty": "Select a file",

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "Used",
"contextUsagePanel.labels.available": "Avail",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Disabled",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
"settings.behavior.keyboardHints.title": "Keyboard shortcut hints",
"settings.behavior.keyboardHints.subtitle": "Show keyboard shortcut hints across the UI.",
"settings.behavior.thinking.title": "Thinking sections",
"settings.behavior.thinking.subtitle": "Show or hide AI thinking sections in messages.",
"settings.behavior.thinkingDefault.title": "Thinking default",
"settings.behavior.thinkingDefault.subtitle": "Choose whether thinking sections start expanded or collapsed.",
"settings.behavior.timelineTools.title": "Timeline tool calls",
"settings.behavior.timelineTools.subtitle": "Show or hide tool call entries in the message timeline.",
"settings.behavior.diffView.title": "Diff view",
"settings.behavior.diffView.subtitle": "Choose how tool-call diffs are displayed.",
"settings.behavior.diffView.option.split": "Split",
"settings.behavior.diffView.option.unified": "Unified",
"settings.behavior.toolOutputsDefault.title": "Tool outputs default",
"settings.behavior.toolOutputsDefault.subtitle": "Choose whether tool outputs start expanded or collapsed.",
"settings.behavior.diagnosticsDefault.title": "Diagnostics default",
"settings.behavior.diagnosticsDefault.subtitle": "Choose whether diagnostics output starts expanded or collapsed.",
"settings.behavior.toolInputsVisibility.title": "Tool inputs visibility",
"settings.behavior.toolInputsVisibility.subtitle": "Set default visibility for tool call input arguments.",
"settings.behavior.usageMetrics.title": "Token usage metrics",
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
"settings.behavior.promptSubmit.title": "Enter to submit",
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
} as const

View File

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "No se pudo iniciar OpenCode",
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en Configuración avanzada.",
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en la configuración de OpenCode.",
"app.launchError.binaryPathLabel": "Ruta del binario",
"app.launchError.errorOutputLabel": "Salida de error",
"app.launchError.openAdvancedSettings": "Abrir Configuración avanzada",
"app.launchError.openAdvancedSettings": "Abrir Configuración de OpenCode",
"app.launchError.close": "Cerrar",
"app.launchError.closeTitle": "Cerrar (Esc)",
"app.launchError.fallbackMessage": "No se pudo iniciar el workspace",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "Abriendo...",
"folderSelection.advancedSettings": "Configuración avanzada",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Navegar",
"folderSelection.hints.select": "Seleccionar",
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
"folderSelection.loading.title": "Iniciando instancia...",
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
"folderSelection.drop.invalidTitle": "No se pudo abrir el elemento soltado",
"folderSelection.drop.invalidMessage": "Suelta una carpeta para iniciar una nueva instancia.",
"folderSelection.dialog.title": "Seleccionar workspace",
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
} as const

View File

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

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "Usado",
"contextUsagePanel.labels.available": "Disp.",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Desactivado",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "Interaccion",
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
"settings.behavior.keyboardHints.title": "Sugerencias de atajos de teclado",
"settings.behavior.keyboardHints.subtitle": "Muestra sugerencias de atajos de teclado en toda la interfaz.",
"settings.behavior.thinking.title": "Secciones de pensamiento",
"settings.behavior.thinking.subtitle": "Muestra u oculta las secciones de pensamiento de la IA en los mensajes.",
"settings.behavior.thinkingDefault.title": "Pensamiento por defecto",
"settings.behavior.thinkingDefault.subtitle": "Elige si las secciones de pensamiento comienzan expandidas o contraidas.",
"settings.behavior.timelineTools.title": "Llamadas de herramientas en la linea de tiempo",
"settings.behavior.timelineTools.subtitle": "Muestra u oculta entradas de llamadas de herramientas en la linea de tiempo de mensajes.",
"settings.behavior.diffView.title": "Vista de diferencias",
"settings.behavior.diffView.subtitle": "Elige como se muestran los diffs de llamadas de herramientas.",
"settings.behavior.diffView.option.split": "Dividida",
"settings.behavior.diffView.option.unified": "Unificada",
"settings.behavior.toolOutputsDefault.title": "Salidas de herramientas por defecto",
"settings.behavior.toolOutputsDefault.subtitle": "Elige si las salidas de herramientas comienzan expandidas o contraidas.",
"settings.behavior.diagnosticsDefault.title": "Diagnosticos por defecto",
"settings.behavior.diagnosticsDefault.subtitle": "Elige si la salida de diagnosticos comienza expandida o contraida.",
"settings.behavior.toolInputsVisibility.title": "Visibilidad de entradas de herramientas",
"settings.behavior.toolInputsVisibility.subtitle": "Establece la visibilidad por defecto de los argumentos de entrada de las llamadas de herramientas.",
"settings.behavior.usageMetrics.title": "Metricas de uso de tokens",
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
"settings.behavior.autoCleanup.title": "Limpieza automatica de sesiones en blanco",
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
"settings.behavior.promptSubmit.title": "Enter para enviar",
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
} as const

View File

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "Impossible de lancer OpenCode",
"app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les Paramètres avancés.",
"app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les paramètres OpenCode.",
"app.launchError.binaryPathLabel": "Chemin du binaire",
"app.launchError.errorOutputLabel": "Sortie d'erreur",
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres avancés",
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres OpenCode",
"app.launchError.close": "Fermer",
"app.launchError.closeTitle": "Fermer (Esc)",
"app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "Ouverture...",
"folderSelection.advancedSettings": "Paramètres avancés",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Naviguer",
"folderSelection.hints.select": "Sélectionner",
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
"folderSelection.loading.title": "Démarrage de l'instance...",
"folderSelection.loading.subtitle": "Patientez pendant que nous préparons votre espace de travail.",
"folderSelection.drop.title": "Déposez un dossier pour l'ouvrir",
"folderSelection.drop.subtitle": "Démarrez une nouvelle instance dans le dossier déposé.",
"folderSelection.drop.invalidTitle": "Impossible d'ouvrir l'élément déposé",
"folderSelection.drop.invalidMessage": "Déposez un dossier pour démarrer une nouvelle instance.",
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
} as const

View File

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

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "Utilisé",
"contextUsagePanel.labels.available": "Dispo",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Desactive",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "Interaction",
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
"settings.behavior.keyboardHints.title": "Indications de raccourcis clavier",
"settings.behavior.keyboardHints.subtitle": "Afficher des indications de raccourcis clavier dans toute l'interface.",
"settings.behavior.thinking.title": "Sections de reflexion",
"settings.behavior.thinking.subtitle": "Afficher ou masquer les sections de reflexion de l'IA dans les messages.",
"settings.behavior.thinkingDefault.title": "Etat initial de la reflexion",
"settings.behavior.thinkingDefault.subtitle": "Choisir si les sections de reflexion commencent developpees ou reduites.",
"settings.behavior.timelineTools.title": "Appels d'outils dans la chronologie",
"settings.behavior.timelineTools.subtitle": "Afficher ou masquer les entrees d'appels d'outils dans la chronologie des messages.",
"settings.behavior.diffView.title": "Vue du diff",
"settings.behavior.diffView.subtitle": "Choisir comment les diffs des appels d'outils sont affiches.",
"settings.behavior.diffView.option.split": "Scinde",
"settings.behavior.diffView.option.unified": "Unifie",
"settings.behavior.toolOutputsDefault.title": "Etat initial des sorties d'outils",
"settings.behavior.toolOutputsDefault.subtitle": "Choisir si les sorties d'outils commencent developpees ou reduites.",
"settings.behavior.diagnosticsDefault.title": "Etat initial des diagnostics",
"settings.behavior.diagnosticsDefault.subtitle": "Choisir si la sortie des diagnostics commence developpee ou reduite.",
"settings.behavior.toolInputsVisibility.title": "Visibilite des entrees d'outils",
"settings.behavior.toolInputsVisibility.subtitle": "Definir la visibilite par defaut des arguments d'entree des appels d'outils.",
"settings.behavior.usageMetrics.title": "Metriques d'utilisation des tokens",
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
"settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides",
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
} as const

View File

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "OpenCode を起動できません",
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、詳細設定から別のバイナリを選択してください。",
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、OpenCode 設定から別のバイナリを選択してください。",
"app.launchError.binaryPathLabel": "バイナリのパス",
"app.launchError.errorOutputLabel": "エラー出力",
"app.launchError.openAdvancedSettings": "詳細設定を開く",
"app.launchError.openAdvancedSettings": "OpenCode 設定を開く",
"app.launchError.close": "閉じる",
"app.launchError.closeTitle": "閉じる (Esc)",
"app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "開いています...",
"folderSelection.advancedSettings": "詳細設定",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "移動",
"folderSelection.hints.select": "選択",
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
"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

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

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "使用",
"contextUsagePanel.labels.available": "残り",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "無効",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "操作",
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
"settings.behavior.keyboardHints.title": "キーボードショートカットのヒント",
"settings.behavior.keyboardHints.subtitle": "UI全体でキーボードショートカットのヒントを表示します。",
"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": "差分表示",
"settings.behavior.diffView.subtitle": "ツール呼び出しの差分の表示方法を選びます。",
"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

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "Не удалось запустить OpenCode",
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в расширенных настройках.",
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в настройках OpenCode.",
"app.launchError.binaryPathLabel": "Путь к бинарнику",
"app.launchError.errorOutputLabel": "Вывод ошибки",
"app.launchError.openAdvancedSettings": "Открыть расширенные настройки",
"app.launchError.openAdvancedSettings": "Открыть настройки OpenCode",
"app.launchError.close": "Закрыть",
"app.launchError.closeTitle": "Закрыть (Esc)",
"app.launchError.fallbackMessage": "Не удалось запустить рабочее пространство",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "Открытие…",
"folderSelection.advancedSettings": "Расширенные настройки",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "Навигация",
"folderSelection.hints.select": "Выбрать",
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
"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

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

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "Использовано",
"contextUsagePanel.labels.available": "Доступно",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "Отключено",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "Взаимодействие",
"settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.",
"settings.behavior.keyboardHints.title": "Подсказки сочетаний клавиш",
"settings.behavior.keyboardHints.subtitle": "Показывать подсказки сочетаний клавиш по всему интерфейсу.",
"settings.behavior.thinking.title": "Разделы размышлений",
"settings.behavior.thinking.subtitle": "Показывать или скрывать разделы размышлений ИИ в сообщениях.",
"settings.behavior.thinkingDefault.title": "Размышления по умолчанию",
"settings.behavior.thinkingDefault.subtitle": "Выберите, начинать ли разделы размышлений развернутыми или свернутыми.",
"settings.behavior.timelineTools.title": "Вызовы инструментов в таймлайне",
"settings.behavior.timelineTools.subtitle": "Показывать или скрывать записи вызовов инструментов в таймлайне сообщений.",
"settings.behavior.diffView.title": "Вид диффа",
"settings.behavior.diffView.subtitle": "Выберите, как отображаются диффы вызовов инструментов.",
"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

@@ -1,9 +1,9 @@
export const appMessages = {
"app.launchError.title": "无法启动 OpenCode",
"app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在“高级设置中选择其他可执行文件。",
"app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在 OpenCode 设置中选择其他可执行文件。",
"app.launchError.binaryPathLabel": "可执行文件路径",
"app.launchError.errorOutputLabel": "错误输出",
"app.launchError.openAdvancedSettings": "打开高级设置",
"app.launchError.openAdvancedSettings": "打开 OpenCode 设置",
"app.launchError.close": "关闭",
"app.launchError.closeTitle": "关闭 (Esc)",
"app.launchError.fallbackMessage": "启动工作区失败",

View File

@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
"folderSelection.browse.buttonOpening": "正在打开...",
"folderSelection.advancedSettings": "高级设置",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "导航",
"folderSelection.hints.select": "选择",
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
"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

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

View File

@@ -55,4 +55,88 @@ export const settingsMessages = {
"contextUsagePanel.labels.used": "已用",
"contextUsagePanel.labels.available": "可用",
"contextUsagePanel.unavailable": "--",
"settings.title": "Settings",
"settings.navigationAriaLabel": "Settings sections",
"settings.close": "Close settings",
"settings.content.eyebrow": "Workspace preferences",
"settings.open.title": "Open settings",
"settings.open.ariaLabel": "Open settings",
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
"settings.common.enabled": "Enabled",
"settings.common.disabled": "已禁用",
"settings.section.appearance.title": "Appearance",
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
"settings.appearance.theme.title": "Theme",
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
"settings.appearance.theme.option.system": "Match your operating system setting",
"settings.appearance.theme.option.light": "Use the light appearance",
"settings.appearance.theme.option.dark": "Use the dark appearance",
"settings.section.notifications.title": "Notifications",
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
"settings.notifications.permission.granted": "Granted",
"settings.notifications.permission.denied": "Denied",
"settings.notifications.permission.default": "Not granted",
"settings.notifications.permission.unsupported": "Unsupported",
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
"settings.notifications.sessionStatus.title": "Session status notifications",
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
"settings.notifications.enable.title": "Enable notifications",
"settings.notifications.enable.permission": "Permission: {permission}",
"settings.notifications.requestPermission.title": "Request permission",
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
"settings.notifications.requestPermission.action": "Request",
"settings.notifications.allowVisible.title": "Notify when the app is focused",
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
"settings.notifications.events.title": "Notify me when",
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
"settings.notifications.events.needsInput": "Session needs input",
"settings.notifications.events.idle": "Session becomes idle",
"settings.notifications.status.enabled": "Notifications enabled",
"settings.notifications.status.disabled": "Notifications disabled",
"settings.notifications.status.unsupported": "Notifications unsupported",
"settings.section.remote.title": "Remote Access",
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
"settings.opencode.runtime.title": "Runtime",
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
"settings.appearance.behavior.title": "交互",
"settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。",
"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": "差异视图",
"settings.behavior.diffView.subtitle": "选择工具调用差异的显示方式。",
"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": "回车发送",
"settings.behavior.promptSubmit.subtitle": "使用回车发送Cmd/Ctrl+回车插入新行。",
} as const

View File

@@ -1,7 +1,8 @@
import { marked } from "marked"
import { createHighlighter, type Highlighter, bundledLanguages } from "shiki/bundle/full"
import { getLogger } from "./logger"
import { tGlobal } from "./i18n"
import type { Highlighter } from "shiki/bundle/full"
import { decodeHtmlEntities, escapeHtml } from "./text-render-utils"
const log = getLogger("actions")
@@ -11,43 +12,8 @@ let currentTheme: "light" | "dark" = "light"
let isInitialized = false
let highlightSuppressed = false
let rendererSetup = false
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
}
let shikiModulePromise: Promise<typeof import("shiki/bundle/full")> | null = null
let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null
// Track loaded languages and queue for on-demand loading
const loadedLanguages = new Set<string>()
@@ -89,10 +55,15 @@ async function getOrCreateHighlighter() {
return highlighterPromise
}
// Create highlighter with no preloaded languages
highlighterPromise = createHighlighter({
themes: ["github-light", "github-light-high-contrast", "github-dark"],
langs: [],
highlighterPromise = (async () => {
const shiki = await loadShikiModule()
return shiki.createHighlighter({
themes: ["github-light", "github-light-high-contrast", "github-dark"],
langs: [],
})
})().catch((error) => {
highlighterPromise = null
throw error
})
highlighter = await highlighterPromise
@@ -100,12 +71,37 @@ async function getOrCreateHighlighter() {
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 {
return token.trim().toLowerCase()
}
function resolveLanguage(token: string): { canonical: string | null; raw: string } {
const normalized = normalizeLanguageToken(token)
const bundledLanguages = bundledLanguagesCache
if (!bundledLanguages) {
return { canonical: null, raw: normalized }
}
// Check if it's a direct key match
if (normalized in bundledLanguages) {
@@ -148,32 +144,43 @@ async function ensureLanguages(content: string) {
// Queue language loading tasks
for (const token of foundLanguages) {
const { canonical, raw } = resolveLanguage(token)
const langKey = canonical || raw
const rawToken = normalizeLanguageToken(token)
if (!rawToken) {
continue
}
// Skip "text" and aliases since Shiki handles plain text already
if (langKey === "text" || raw === "text") {
if (rawToken === "text") {
continue
}
// Skip if already loaded or queued
if (loadedLanguages.has(langKey) || queuedLanguages.has(langKey)) {
if (loadedLanguages.has(rawToken) || queuedLanguages.has(rawToken)) {
continue
}
queuedLanguages.add(langKey)
queuedLanguages.add(rawToken)
// Queue the language loading task
languageLoadQueue.push(async () => {
try {
await loadShikiModule()
const { canonical, raw } = resolveLanguage(token)
const langKey = canonical || raw
if (langKey === "text" || raw === "text") {
return
}
const h = await getOrCreateHighlighter()
await h.loadLanguage(langKey as never)
loadedLanguages.add(langKey)
loadedLanguages.add(raw)
triggerLanguageListeners()
} catch {
// Quietly ignore errors
} 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() {
if (isQueueRunning || languageLoadQueue.length === 0) {
return
@@ -249,7 +210,6 @@ async function runLanguageLoadQueue() {
function setupRenderer(isDark: boolean) {
currentTheme = isDark ? "dark" : "light"
if (!highlighter) return
if (rendererSetup) return
marked.setOptions({
@@ -330,8 +290,9 @@ function setupRenderer(isDark: boolean) {
}
export async function initMarkdown(isDark: boolean) {
await getOrCreateHighlighter()
setupRenderer(isDark)
queueHighlighterWarmup()
await getOrCreateHighlighter()
isInitialized = true
}
@@ -350,15 +311,16 @@ export async function renderMarkdown(
},
): Promise<string> {
if (!isInitialized) {
await initMarkdown(currentTheme === "dark")
setupRenderer(currentTheme === "dark")
isInitialized = true
}
const suppressHighlight = options?.suppressHighlight ?? false
const decoded = decodeHtmlEntities(content)
if (!suppressHighlight) {
// Queue language loading but don't wait for it to complete
await ensureLanguages(decoded)
queueHighlighterWarmup()
void ensureLanguages(decoded)
}
const previousSuppressed = highlightSuppressed
@@ -375,13 +337,3 @@ export async function renderMarkdown(
export async function getSharedHighlighter(): Promise<Highlighter> {
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

@@ -1,3 +1,4 @@
import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env"
import { getLogger } from "../logger"
const log = getLogger("actions")
@@ -15,9 +16,8 @@ export async function restartCli(): Promise<boolean> {
}
if (runtimeEnv.host === "tauri") {
const tauri = (window as typeof window & { __TAURI__?: { invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T> } }).__TAURI__
if (tauri?.invoke) {
await tauri.invoke("cli_restart")
if (typeof window.__TAURI__?.core?.invoke === "function") {
await invoke("cli_restart")
return true
}
return false

View File

@@ -0,0 +1,146 @@
import { listen } from "@tauri-apps/api/event"
import { getLogger } from "../logger"
import { runtimeEnv } from "../runtime-env"
const log = getLogger("actions")
type NativeFolderDropState = "enter" | "leave"
interface TauriFolderDropPayload {
paths?: unknown
}
function normalizePathList(input: unknown): string[] {
if (!Array.isArray(input)) {
return []
}
return input.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
}
function getFilePath(file: File): string | null {
if (typeof file.path === "string" && file.path.trim().length > 0) {
return file.path
}
if (runtimeEnv.host === "electron") {
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
return electronPath
}
}
return null
}
async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]> {
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
if (!api?.getDirectoryPaths || paths.length === 0) {
return []
}
try {
return await api.getDirectoryPaths(paths)
} catch (error) {
log.error("[native] failed to validate dropped directory paths", error)
return []
}
}
export function supportsDesktopFolderDrop(): boolean {
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web"
}
export function containsFileDrop(event: DragEvent): boolean {
const types = event.dataTransfer?.types
if (!types) {
return false
}
return Array.from(types).includes("Files")
}
export function extractDroppedDirectoryPaths(event: DragEvent): string[] {
const dataTransfer = event.dataTransfer
if (!dataTransfer) {
return []
}
const directoryHints = new Set<string>()
for (const item of Array.from(dataTransfer.items ?? [])) {
if (item.kind !== "file") {
continue
}
const entry = item.webkitGetAsEntry?.()
if (!entry?.isDirectory) {
continue
}
const file = item.getAsFile()
const filePath = file ? getFilePath(file) : null
if (filePath) {
directoryHints.add(filePath)
}
}
const paths = new Set<string>()
for (const file of Array.from(dataTransfer.files ?? [])) {
const filePath = getFilePath(file)
if (!filePath) {
continue
}
if (directoryHints.size > 0 && !directoryHints.has(filePath)) {
continue
}
paths.add(filePath)
}
return Array.from(paths)
}
export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<string[]> {
const uniquePaths = Array.from(new Set(paths.filter((path) => typeof path === "string" && path.trim().length > 0)))
if (uniquePaths.length === 0) {
return []
}
if (runtimeEnv.host === "electron") {
return resolveElectronDirectoryPaths(uniquePaths)
}
return uniquePaths
}
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
if (runtimeEnv.host !== "tauri") {
return () => {}
}
try {
const unlisten = await listen("desktop:folder-drop", (event) => {
const payload = (event.payload ?? {}) as TauriFolderDropPayload
const paths = normalizePathList(payload.paths)
if (paths.length > 0) {
onDrop(paths)
}
})
return () => {
unlisten()
}
} catch (error) {
log.error("[native] failed to listen for folder-drop event", error)
return () => {}
}
}
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
if (runtimeEnv.host !== "tauri") {
return () => {}
}
try {
const [unlistenEnter, unlistenLeave] = await Promise.all([
listen("desktop:folder-drag-enter", () => onState("enter")),
listen("desktop:folder-drag-leave", () => onState("leave")),
])
return () => {
unlistenEnter()
unlistenLeave()
}
} catch (error) {
log.error("[native] failed to listen for folder-drop state", error)
return () => {}
}
}

View File

@@ -1,43 +1,21 @@
import { open } from "@tauri-apps/plugin-dialog"
import type { NativeDialogOptions } from "../native-functions"
import { getLogger } from "../../logger"
const log = getLogger("actions")
interface TauriDialogModule {
open?: (
options: {
title?: string
defaultPath?: string
filters?: { name?: string; extensions: string[] }[]
directory?: boolean
multiple?: boolean
},
) => Promise<string | string[] | null>
}
interface TauriBridge {
dialog?: TauriDialogModule
}
export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> {
if (typeof window === "undefined") {
return null
}
const tauriBridge = (window as Window & { __TAURI__?: TauriBridge }).__TAURI__
const dialogApi = tauriBridge?.dialog
if (!dialogApi?.open) {
return null
}
try {
const response = await dialogApi.open({
const response = await open({
title: options.title,
defaultPath: options.defaultPath,
directory: options.mode === "directory",
multiple: false,
filters: options.filters?.map((filter) => ({
name: filter.name,
name: filter.name ?? "Files",
extensions: filter.extensions,
})),
})

View File

@@ -1,3 +1,4 @@
import { invoke } from "@tauri-apps/api/core"
import { runtimeEnv } from "../runtime-env"
import { getLogger } from "../logger"
@@ -60,8 +61,7 @@ function hasAnyWakeLockSupport(): boolean {
if (api?.setWakeLock) return true
}
if (runtimeEnv.host === "tauri") {
// We'll attempt dynamic import; treat as potentially supported.
return true
return typeof window.__TAURI__?.core?.invoke === "function"
}
return Boolean((navigator as any)?.wakeLock?.request)
}
@@ -84,21 +84,18 @@ async function setElectronWakeLock(enabled: boolean): Promise<boolean> {
async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
try {
const mod = await import("tauri-plugin-keepawake-api")
const start = (mod as any).start as ((config?: any) => Promise<void>) | undefined
const stop = (mod as any).stop as (() => Promise<void>) | undefined
if (!start || !stop) {
if (!hasAnyWakeLockSupport()) {
return false
}
if (enabled) {
// Plugin config supports toggling display/idle/sleep. Use a conservative
// default to keep both system + display awake.
await start({ display: true, idle: true, sleep: true })
// Match Electron's prevent-display-sleep behavior by keeping the display
// awake without blocking explicit system sleep requests.
await invoke("wake_lock_start", { config: { display: true, idle: false, sleep: false } })
return true
}
await stop()
await invoke("wake_lock_stop")
return false
} catch (error) {
log.log("[wake-lock] tauri wake lock failed", error)
@@ -137,13 +134,12 @@ export function setWakeLockDesired(nextDesired: boolean): Promise<boolean> {
inFlight = (async () => {
try {
const ok = await applyWakeLock(target)
// Treat disable attempts as applied even if the underlying API doesn't exist.
applied = target
applied = target ? ok : false
return ok
} finally {
inFlight = null
// If desired changed while in-flight, re-apply once.
if (desired !== applied) {
if (desired !== target) {
void setWakeLockDesired(desired)
}

View File

@@ -9,17 +9,14 @@ export interface RuntimeEnvironment {
}
declare global {
interface TauriCoreModule {
invoke: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
}
interface Window {
electronAPI?: unknown
__TAURI__?: {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
}
dialog?: {
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
save?: (options: Record<string, unknown>) => Promise<string | null>
}
core?: TauriCoreModule
}
}
}

View File

@@ -0,0 +1,452 @@
import type { Accessor } from "solid-js"
import type {
Preferences,
ExpansionPreference,
ToolInputsVisibilityPreference,
} from "../../stores/preferences"
import type { Command } from "../commands"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
export type BehaviorSettingKind = "toggle" | "enum"
export type BehaviorToggleSetting = {
kind: "toggle"
id: string
titleKey: string
subtitleKey: string
get: (preferences: Preferences) => boolean
set: (next: boolean) => void
disabled?: () => boolean
}
export type BehaviorEnumSetting<T extends string = string> = {
kind: "enum"
id: string
titleKey: string
subtitleKey: string
get: (preferences: Preferences) => T
set: (next: T) => void
options: Array<{ value: T; labelKey: string }>
disabled?: () => boolean
}
export type BehaviorSetting = BehaviorToggleSetting | BehaviorEnumSetting
export type BehaviorRegistryActions = {
preferences: Accessor<Preferences>
updatePreferences?: (updates: Partial<Preferences>) => void
toggleShowThinkingBlocks: () => void
toggleKeyboardShortcutHints: () => void
toggleShowTimelineTools: () => void
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
togglePromptSubmitOnEnter: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
}
function splitKeywords(key: string): string[] {
return tGlobal(key)
.split(",")
.map((value) => value.trim())
.filter(Boolean)
}
function setBooleanByToggle(getCurrent: () => boolean, toggle: () => void, next: boolean) {
if (getCurrent() === next) return
toggle()
}
export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorSetting[] {
const prefs = actions.preferences
const updatePreferences = actions.updatePreferences
return [
{
kind: "toggle",
id: "behavior.keyboardShortcutHints",
titleKey: "settings.behavior.keyboardHints.title",
subtitleKey: "settings.behavior.keyboardHints.subtitle",
get: (p) => Boolean(p.showKeyboardShortcutHints ?? true),
set: (next) => {
if (updatePreferences) {
updatePreferences({ showKeyboardShortcutHints: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().showKeyboardShortcutHints ?? true),
actions.toggleKeyboardShortcutHints,
next,
)
},
disabled: () => runtimeEnv.host === "web",
},
{
kind: "toggle",
id: "behavior.thinkingBlocks",
titleKey: "settings.behavior.thinking.title",
subtitleKey: "settings.behavior.thinking.subtitle",
get: (p) => Boolean(p.showThinkingBlocks),
set: (next) => {
if (updatePreferences) {
updatePreferences({ showThinkingBlocks: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().showThinkingBlocks),
actions.toggleShowThinkingBlocks,
next,
)
},
},
{
kind: "enum",
id: "behavior.thinkingBlocksDefault",
titleKey: "settings.behavior.thinkingDefault.title",
subtitleKey: "settings.behavior.thinkingDefault.subtitle",
get: (p) => (p.thinkingBlocksExpansion ?? "expanded") as ExpansionPreference,
set: (next) => {
if (updatePreferences) {
updatePreferences({ thinkingBlocksExpansion: next as ExpansionPreference })
return
}
actions.setThinkingBlocksExpansion(next as ExpansionPreference)
},
options: [
{ value: "expanded", labelKey: "commands.common.expanded" },
{ value: "collapsed", labelKey: "commands.common.collapsed" },
],
},
{
kind: "toggle",
id: "behavior.timelineToolCalls",
titleKey: "settings.behavior.timelineTools.title",
subtitleKey: "settings.behavior.timelineTools.subtitle",
get: (p) => Boolean(p.showTimelineTools),
set: (next) => {
if (updatePreferences) {
updatePreferences({ showTimelineTools: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().showTimelineTools),
actions.toggleShowTimelineTools,
next,
)
},
},
{
kind: "enum",
id: "behavior.diffViewMode",
titleKey: "settings.behavior.diffView.title",
subtitleKey: "settings.behavior.diffView.subtitle",
get: (p) => (p.diffViewMode ?? "split") as "split" | "unified",
set: (next) => {
if (updatePreferences) {
updatePreferences({ diffViewMode: next as "split" | "unified" })
return
}
actions.setDiffViewMode(next as "split" | "unified")
},
options: [
{ value: "split", labelKey: "settings.behavior.diffView.option.split" },
{ value: "unified", labelKey: "settings.behavior.diffView.option.unified" },
],
},
{
kind: "enum",
id: "behavior.toolOutputsDefault",
titleKey: "settings.behavior.toolOutputsDefault.title",
subtitleKey: "settings.behavior.toolOutputsDefault.subtitle",
get: (p) => (p.toolOutputExpansion ?? "expanded") as ExpansionPreference,
set: (next) => {
if (updatePreferences) {
updatePreferences({ toolOutputExpansion: next as ExpansionPreference })
return
}
actions.setToolOutputExpansion(next as ExpansionPreference)
},
options: [
{ value: "expanded", labelKey: "commands.common.expanded" },
{ value: "collapsed", labelKey: "commands.common.collapsed" },
],
},
{
kind: "enum",
id: "behavior.diagnosticsDefault",
titleKey: "settings.behavior.diagnosticsDefault.title",
subtitleKey: "settings.behavior.diagnosticsDefault.subtitle",
get: (p) => (p.diagnosticsExpansion ?? "expanded") as ExpansionPreference,
set: (next) => {
if (updatePreferences) {
updatePreferences({ diagnosticsExpansion: next as ExpansionPreference })
return
}
actions.setDiagnosticsExpansion(next as ExpansionPreference)
},
options: [
{ value: "expanded", labelKey: "commands.common.expanded" },
{ value: "collapsed", labelKey: "commands.common.collapsed" },
],
},
{
kind: "enum",
id: "behavior.toolInputsVisibility",
titleKey: "settings.behavior.toolInputsVisibility.title",
subtitleKey: "settings.behavior.toolInputsVisibility.subtitle",
get: (p) => (p.toolInputsVisibility ?? "hidden") as ToolInputsVisibilityPreference,
set: (next) => {
if (updatePreferences) {
updatePreferences({ toolInputsVisibility: next as ToolInputsVisibilityPreference })
return
}
actions.setToolInputsVisibility(next as ToolInputsVisibilityPreference)
},
options: [
{ value: "hidden", labelKey: "commands.common.hidden" },
{ value: "collapsed", labelKey: "commands.common.collapsed" },
{ value: "expanded", labelKey: "commands.common.expanded" },
],
},
{
kind: "toggle",
id: "behavior.usageMetrics",
titleKey: "settings.behavior.usageMetrics.title",
subtitleKey: "settings.behavior.usageMetrics.subtitle",
get: (p) => Boolean(p.showUsageMetrics ?? true),
set: (next) => {
if (updatePreferences) {
updatePreferences({ showUsageMetrics: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().showUsageMetrics ?? true),
actions.toggleUsageMetrics,
next,
)
},
},
{
kind: "toggle",
id: "behavior.autoCleanupBlankSessions",
titleKey: "settings.behavior.autoCleanup.title",
subtitleKey: "settings.behavior.autoCleanup.subtitle",
get: (p) => Boolean(p.autoCleanupBlankSessions),
set: (next) => {
if (updatePreferences) {
updatePreferences({ autoCleanupBlankSessions: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().autoCleanupBlankSessions),
actions.toggleAutoCleanupBlankSessions,
next,
)
},
},
{
kind: "toggle",
id: "behavior.promptSubmitOnEnter",
titleKey: "settings.behavior.promptSubmit.title",
subtitleKey: "settings.behavior.promptSubmit.subtitle",
get: (p) => Boolean(p.promptSubmitOnEnter),
set: (next) => {
if (updatePreferences) {
updatePreferences({ promptSubmitOnEnter: next })
return
}
setBooleanByToggle(
() => Boolean(prefs().promptSubmitOnEnter),
actions.togglePromptSubmitOnEnter,
next,
)
},
},
]
}
export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[] {
return [
{
id: "prompt-submit-shortcut",
label: () =>
actions.preferences().promptSubmitOnEnter
? tGlobal("commands.promptSubmitShortcut.label.swapped")
: tGlobal("commands.promptSubmitShortcut.label.default"),
description: () => tGlobal("commands.promptSubmitShortcut.description"),
category: "Input & Focus",
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
action: actions.togglePromptSubmitOnEnter,
},
{
id: "thinking",
label: () =>
tGlobal(
actions.preferences().showThinkingBlocks
? "commands.thinkingBlocks.label.hide"
: "commands.thinkingBlocks.label.show",
),
description: () => tGlobal("commands.thinkingBlocks.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
action: actions.toggleShowThinkingBlocks,
},
{
id: "timeline-tools",
label: () =>
tGlobal(
actions.preferences().showTimelineTools
? "commands.timelineToolCalls.label.hide"
: "commands.timelineToolCalls.label.show",
),
description: () => tGlobal("commands.timelineToolCalls.description"),
category: "System",
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
action: actions.toggleShowTimelineTools,
},
{
id: "keyboard-shortcut-hints",
label: () =>
tGlobal(
actions.preferences().showKeyboardShortcutHints
? "commands.keyboardShortcutHints.label.hide"
: "commands.keyboardShortcutHints.label.show",
),
description: () =>
tGlobal(
runtimeEnv.host === "web"
? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description",
),
category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web",
action: actions.toggleKeyboardShortcutHints,
},
{
id: "thinking-default-visibility",
label: () => {
const mode = actions.preferences().thinkingBlocksExpansion ?? "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.thinkingBlocksDefault.label", { state })
},
description: () => tGlobal("commands.thinkingBlocksDefault.description"),
category: "System",
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
action: () => {
const mode = actions.preferences().thinkingBlocksExpansion ?? "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
actions.setThinkingBlocksExpansion(next)
},
},
{
id: "diff-view-split",
label: () => {
const prefix = (actions.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
},
description: () => tGlobal("commands.diffViewSplit.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
action: () => actions.setDiffViewMode("split"),
},
{
id: "diff-view-unified",
label: () => {
const prefix = (actions.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
},
description: () => tGlobal("commands.diffViewUnified.description"),
category: "System",
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
action: () => actions.setDiffViewMode("unified"),
},
{
id: "tool-output-default-visibility",
label: () => {
const mode = actions.preferences().toolOutputExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.toolOutputsDefault.label", { state })
},
description: () => tGlobal("commands.toolOutputsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
action: () => {
const mode = actions.preferences().toolOutputExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
actions.setToolOutputExpansion(next)
},
},
{
id: "diagnostics-default-visibility",
label: () => {
const mode = actions.preferences().diagnosticsExpansion || "expanded"
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
return tGlobal("commands.diagnosticsDefault.label", { state })
},
description: () => tGlobal("commands.diagnosticsDefault.description"),
category: "System",
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
action: () => {
const mode = actions.preferences().diagnosticsExpansion || "expanded"
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
actions.setDiagnosticsExpansion(next)
},
},
{
id: "tool-inputs-visibility",
label: () => {
const mode = actions.preferences().toolInputsVisibility || "hidden"
const state =
mode === "expanded"
? tGlobal("commands.common.expanded")
: mode === "collapsed"
? tGlobal("commands.common.collapsed")
: tGlobal("commands.common.hidden")
return tGlobal("commands.toolInputsVisibility.label", { state })
},
description: () => tGlobal("commands.toolInputsVisibility.description"),
category: "System",
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
action: () => {
const mode = actions.preferences().toolInputsVisibility || "hidden"
const next: ToolInputsVisibilityPreference =
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
actions.setToolInputsVisibility(next)
},
},
{
id: "token-usage-visibility",
label: () => {
const visible = actions.preferences().showUsageMetrics ?? true
const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden")
return tGlobal("commands.tokenUsageDisplay.label", { state })
},
description: () => tGlobal("commands.tokenUsageDisplay.description"),
category: "System",
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
action: actions.toggleUsageMetrics,
},
{
id: "auto-cleanup-blank-sessions",
label: () => {
const enabled = actions.preferences().autoCleanupBlankSessions
const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled")
return tGlobal("commands.autoCleanupBlankSessions.label", { state })
},
description: () => tGlobal("commands.autoCleanupBlankSessions.description"),
category: "System",
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
action: actions.toggleAutoCleanupBlankSessions,
},
]
}
export function registerBehaviorCommands(register: (command: Command) => void, actions: BehaviorRegistryActions) {
const commands = getBehaviorCommands(actions)
commands.forEach((command) => register(command))
}

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 { InstanceConfigProvider } from "./stores/instance-config"
import { runtimeEnv } from "./lib/runtime-env"
import { I18nProvider } from "./lib/i18n"
import { I18nProvider, preloadLocaleMessages } from "./lib/i18n"
import { storage } from "./lib/storage"
import "./index.css"
import "@git-diff-view/solid/styles/diff-view-pure.css"
@@ -31,15 +31,19 @@ async function bootstrap() {
try {
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") {
document.documentElement.removeAttribute("data-theme")
} else {
if (theme === "light" || theme === "dark") {
document.documentElement.setAttribute("data-theme", theme)
} else {
document.documentElement.removeAttribute("data-theme")
}
await preloadLocaleMessages(locale)
} catch {
// If config fails to load, fall back to CSS defaults.
await preloadLocaleMessages()
}
}

View File

@@ -1,3 +1,5 @@
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { Show, createSignal, onCleanup, onMount } from "solid-js"
import { render } from "solid-js/web"
import iconUrl from "../../images/CodeNomad-Icon.png"
@@ -27,13 +29,6 @@ interface CliStatus {
error?: string | null
}
interface TauriBridge {
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
event?: {
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
}
}
function pickPhraseKey(previous?: PhraseKey) {
const filtered = phraseKeys.filter((key) => key !== previous)
const source = filtered.length > 0 ? filtered : phraseKeys
@@ -46,17 +41,6 @@ function navigateTo(url?: string | null) {
window.location.replace(url)
}
function getTauriBridge(): TauriBridge | null {
if (typeof window === "undefined") {
return null
}
const bridge = (window as { __TAURI__?: TauriBridge }).__TAURI__
if (!bridge || !bridge.event || !bridge.invoke) {
return null
}
return bridge
}
function annotateDocument() {
if (typeof document === "undefined") {
return
@@ -77,25 +61,22 @@ function LoadingApp() {
setPhraseKey(pickPhraseKey())
const unsubscribers: Array<() => void> = []
async function bootstrapTauri(tauriBridge: TauriBridge | null) {
if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
return
}
async function bootstrapTauri() {
try {
const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => {
const readyUnlisten = await listen("cli:ready", (event) => {
const payload = (event?.payload as CliStatus) || {}
setError(null)
setStatusKey(null)
navigateTo(payload.url)
})
const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => {
const errorUnlisten = await listen("cli:error", (event) => {
const payload = (event?.payload as CliStatus) || {}
if (payload.error) {
setError(payload.error)
setStatusKey("loadingScreen.status.issue")
}
})
const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => {
const statusUnlisten = await listen("cli:status", (event) => {
const payload = (event?.payload as CliStatus) || {}
if (payload.state === "error" && payload.error) {
setError(payload.error)
@@ -109,7 +90,7 @@ function LoadingApp() {
})
unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
const result = await tauriBridge.invoke<CliStatus>("cli_get_status")
const result = await invoke<CliStatus>("cli_get_status")
if (result?.state === "ready" && result.url) {
navigateTo(result.url)
} else if (result?.state === "error" && result.error) {
@@ -123,7 +104,7 @@ function LoadingApp() {
}
if (isTauriHost()) {
void bootstrapTauri(getTauriBridge())
void bootstrapTauri()
}
onCleanup(() => {

View File

@@ -5,7 +5,7 @@ import { getQuestionCallId, getQuestionMessageId } from "../../types/question"
import type { Message, MessageInfo, ClientPart } from "../../types/message"
import type { Session } from "../../types/session"
import { messageStoreBus } from "./bus"
import type { MessageStatus, SessionRevertState } from "./types"
import type { MessageStatus, ReplaceMessageIdOptions, SessionRevertState } from "./types"
interface SessionMetadata {
id: string
@@ -121,10 +121,10 @@ export function applyPartDeltaV2(
})
}
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string, options?: Omit<ReplaceMessageIdOptions, "oldId" | "newId">): void {
if (!oldId || !newId || oldId === newId) return
const store = messageStoreBus.getOrCreate(instanceId)
store.replaceMessageId({ oldId, newId })
store.replaceMessageId({ oldId, newId, ...(options ?? {}) })
}
function extractPermissionMessageId(permission: PermissionRequestLike): string | undefined {

View File

@@ -586,10 +586,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
return
}
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
const cloned = clonePart(input.part)
setState(
"messages",
input.messageId,
@@ -792,6 +792,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
id: options.newId,
isEphemeral: false,
updatedAt: Date.now(),
partIds: options.clearParts ? [] : existing.partIds,
parts: options.clearParts ? {} : existing.parts,
}
setState("messages", options.newId, cloned)

View File

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

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