Compare commits

...

20 Commits

Author SHA1 Message Date
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
54 changed files with 1467 additions and 1591 deletions

View File

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

View File

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

View File

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

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"
}

31
package-lock.json generated
View File

@@ -10984,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,
@@ -12113,6 +12143,7 @@
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0",
"virtua": "^0.48.8",
"yaml": "^2.4.2"
},
"devDependencies": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,11 @@ import { formatCompactCount } from "../lib/formatters"
import { useI18n, type Locale } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { openSettings, settingsOpen } from "../stores/settings-screen"
import { openExternalUrl } from "../lib/external-url"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const GITHUB_URL = "https://github.com/NeuralNomadsAI/CodeNomad"
const DISCORD_URL = "https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
interface FolderSelectionViewProps {
@@ -232,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")
@@ -425,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"
@@ -433,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"
@@ -447,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" />
@@ -456,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"
@@ -464,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" />

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

@@ -902,6 +902,7 @@ export default function MessageBlock(props: MessageBlockProps) {
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/>
</Match>
</Switch>
@@ -1280,6 +1281,7 @@ interface ReasoningCardProps {
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onContentRendered?: () => void
}
function ReasoningCard(props: ReasoningCardProps) {
@@ -1288,6 +1290,25 @@ function ReasoningCard(props: ReasoningCardProps) {
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
let pendingRenderNotificationFrame: number | null = null
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
})
createEffect(() => {
setExpanded(Boolean(props.defaultExpanded))
@@ -1356,6 +1377,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) => {

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

@@ -133,11 +133,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}

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

@@ -4,6 +4,7 @@ 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,12 +14,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")
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()
const [, setIsFocused] = createSignal(false)
@@ -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)
}
@@ -449,6 +488,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

@@ -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 }
@@ -88,7 +88,7 @@ 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} />
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
{params.scrollHelpers.renderSentinel()}
</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,26 @@
import type { Accessor, JSXElement } from "solid-js"
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
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
@@ -101,15 +116,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

@@ -43,7 +43,7 @@ export function createMarkdownContentRenderer(params: {
ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
<pre class="whitespace-pre-wrap break-words text-sm font-mono" dir="auto">{options.content}</pre>
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div>
)

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

@@ -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") {

View File

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

View File

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

View File

@@ -132,6 +132,13 @@
margin-bottom: 0;
}
.message-stream-block {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 2px;
}
.message-step-start {
background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border);

View File

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

View File

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