Compare commits

..

31 Commits

Author SHA1 Message Date
VooDisss
9bf4d351de Refactor Git Changes workflow and diff handling (#311)
# Git Changes PR Review Context

Fixes: #310 

## Purpose of this document

This document is intended to give a PR reviewer or gatekeeper enough
neutral context to review the Git Changes feature series accurately.

## BEFORE/AFTER SNAPSHOT:

<img width="835" height="1163" alt="image"
src="https://github.com/user-attachments/assets/463d6f8c-1a6b-4cf0-8ab8-44a92c534ca5"
/>


It distinguishes:

1. the intended scope of the work
2. implementation choices that were deliberate
3. behaviors that were explicitly tested and accepted during development
4. remaining follow-up areas that were not part of the required intent

It should not be treated as a request to approve the PR automatically.
It exists to reduce false-positive review findings caused by missing
context.

---

## High-level scope

The work in this series refactors and extends the existing `Git Changes`
tab in the right panel.

The intended feature scope includes:

1. grouped staged / unstaged change presentation
2. correct section-aware diff loading
3. per-file stage / unstage controls
4. commit message compose box and commit action for staged changes
5. prompt-context insertion from the Git diff viewer
6. auto-refresh behavior that reduces dependence on the manual refresh
button

This work is intentionally implemented inside the existing Git Changes
vertical slice rather than as a new SCM subsystem.

---

## Files and areas intentionally changed

### Server / API surface

The following server areas were intentionally extended:

1. `packages/server/src/api-types.ts`
2. `packages/server/src/events/bus.ts`
3. `packages/server/src/server/http-server.ts`
4. `packages/server/src/server/routes/workspaces.ts`
5. `packages/server/src/workspaces/git-status.ts`
6. `packages/server/src/workspaces/git-mutations.ts`
7. `packages/server/src/workspaces/worktree-directory.ts`
8. `packages/server/src/workspaces/instance-events.ts`

### UI surface

The following UI areas were intentionally extended:

1. `packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx`
2. `packages/ui/src/components/instance/instance-shell2.tsx`
3.
`packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx`
4.
`packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts`
5.
`packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx`
6. `packages/ui/src/components/instance/shell/right-panel/types.ts`
7. `packages/ui/src/components/instance/shell/storage.ts`
8. `packages/ui/src/components/prompt-input.tsx`
9. `packages/ui/src/components/prompt-input/types.ts`
10. `packages/ui/src/components/session/session-view.tsx`
11. `packages/ui/src/lib/api-client.ts`
12. `packages/ui/src/lib/i18n/messages/*/instance.ts`
13. `packages/ui/src/styles/panels/right-panel.css`

---

## Intentional product and architecture decisions

The following outcomes were deliberate and should not be flagged as
issues merely because they exist.

### Git status / diff architecture

1. The UI does not rely only on the proxied OpenCode `file.status()`
payload.
2. CodeNomad adds server-backed worktree Git status and diff endpoints
to expose staged / unstaged semantics correctly.
3. Server-backed worktree mutation endpoints were added for:
   - stage
   - unstage
   - commit
4. The existing event bus / SSE channel is reused for Git invalidation,
instead of adding a bespoke invalidation route.

### Git Changes UI structure

1. The file list is grouped into:
   - `Staged Changes`
   - `Changes`
2. Both sections are collapsible.
3. Section open state is persisted.
4. The same file may appear in both sections when Git state genuinely
requires that.
5. Rows are filename-first, with parent path as secondary text.
6. Rows are intentionally compact compared to the original flat list.

### Diff behavior

1. Diff loading is section-aware.
2. Deleted files are supported in grouped mode.
3. Binary files are treated as non-line-oriented in the diff viewer.
4. Binary diffs suppress line-based prompt-context affordances.

### Stage / unstage / commit workflow

1. Stage and unstage are per-file row actions.
2. Bulk stage-all / unstage-all was intentionally not added.
3. The commit compose box is intentionally rendered inside the `Staged
Changes` section.
4. The commit button is intentionally overlaid inside the commit input
area.
5. The current commit compose flow is minimal by design:
   - no push
   - no amend flow
   - no branch management

### Prompt-context insertion

1. Prompt insertion is intentionally an HTML comment marker, not a full
diff payload.
2. The expected inserted form is:

   `<!-- Git change context: <path> lines X-Y -->`

3. The trigger UI is intentionally a seam/gutter action in the Monaco
diff viewer, not a toolbar button.

### Row action reveal behavior

1. Stage / unstage row actions are intentionally hover-revealed on
hover-capable layouts.
2. The row action reveal intentionally uses:
   - delayed hide
   - slight stats fade/shift
   - compact idle width
3. On non-hover layouts, the action remains visible for reliability.

### Auto-refresh behavior

The accepted refresh model is intentionally hybrid:

1. refresh on Git Changes tab activation
2. 20-second polling only while the Git Changes tab is active
3. immediate invalidation from completed raw tool events for:
   - `write`
   - `edit`
   - `apply_patch`

This hybrid model is intentional. Polling remains as a fallback even
after tool-event invalidation.

---

## Behaviors explicitly tested during development

The following behaviors were explicitly exercised during development and
used to guide fixes.

### Grouped staged / unstaged behavior

1. files appear in the correct staged / unstaged sections
2. section collapse / expand works
3. collapse state persists
4. line counts are section-specific

### Diff behavior

1. staged diff loads differently from unstaged diff
2. deleted-file handling was verified and corrected
3. binary-file rendering was corrected to avoid line-oriented behavior
4. untracked binary files no longer report fake text line counts

### Mutation behavior

1. per-file stage works from `Changes`
2. per-file unstage works from `Staged Changes`
3. stage / unstage selection remapping was exercised and corrected
4. unborn-repo unstage behavior was explicitly hardened

### Prompt-context behavior

1. selected line / range insertion was tested
2. button placement in the Monaco seam/gutter was iterated and verified

### Auto-refresh behavior

1. tab-activation refresh was tested
2. 20-second active-tab polling was tested
3. raw completed tool invalidation was tested in the running UI for:
   - `write`
   - `edit`
   - `apply_patch`
4. stale async overwrite and stale selection restoration bugs were found
and fixed through review/testing

---

## Review findings that were investigated and are no longer intended
blocker topics

The following areas were previously raised by strict reviews and then
either fixed or determined to be acceptable within scope.

### Fixed in the current series

1. duplicate stage / unstage firing
2. stale diff response overwriting newer selection
3. passive refresh restoring a stale selection
4. instance-wide invalidation overreach
5. selected diff staying stale after tool invalidation
6. worktree-switch status races
7. unhandled rejection risk from async invalidation publication
8. queued invalidation intent being lost during in-flight refresh
9. `git-diff` path traversal / absolute path boundary issue

### Investigated and considered non-blocking within current intent

1. split add/delete presentation for tracked rename behavior
   - this was compared against VS Code behavior during manual testing
   - no stage/unstage corruption was observed in the tested flow
- this is currently treated as a representation tradeoff, not a proven
blocker

---

## Remaining non-blocker follow-up areas

The following are still reasonable follow-up topics, but they were not
part of the required blocker-fix scope.

1. normalize directory-to-worktree matching more aggressively on Windows
so tool invalidation works more reliably from nested directories or
path-format variations
2. improve keyboard discoverability of hover-revealed stage / unstage
actions
3. reserve textarea space for the overlaid commit button if the overlay
tradeoff is reconsidered
4. reduce size/complexity in:
   - `RightPanel.tsx`
   - `right-panel.css`
5. tighten raw SSE tool-event parsing into a more explicit helper if
that event bridge grows further

These follow-ups should not be interpreted as evidence that the core
implementation is incomplete unless a reviewer finds a new concrete
failure.

---

## Suggested review focus

If a gatekeeper or reviewer is evaluating this PR, the most useful focus
areas are:

1. whether staged / unstaged behavior is correct for normal Git
workflows
2. whether the new server worktree Git endpoints remain narrowly scoped
3. whether auto-refresh remains bounded to the active Git Changes
context
4. whether the explicit fixes for stale async behavior and invalidation
races are sufficient
5. whether any unintentional server boundary broadening or state
corruption remains

Less useful review topics, unless tied to a concrete failure, are:

1. preference disagreements with accepted prompt insertion format
2. preference disagreements with the overlaid commit button placement
3. preference disagreements with keeping polling fallback alongside tool
invalidation
4. objections to server-backed Git endpoints purely because they add
surface area

---

## Summary

This series intentionally evolves the existing Git Changes tab into a
more complete source-control workflow for:

1. grouped staged / unstaged inspection
2. section-aware diffs
3. per-file staging and unstaging
4. commit composition for staged changes
5. prompt-context insertion from Git diffs
6. bounded auto-refresh for both passive viewing and agent-driven file
mutations

The intended review standard is to find concrete correctness, layering,
or maintenance problems that remain after this series — not to re-argue
the already accepted product choices listed above.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-16 23:11:48 +01:00
Shantur Rathore
657e78da6a feat(electron): publish linux AppImage artifacts 2026-04-16 11:28:39 +01:00
Shantur Rathore
dee356558f docs: add SideCars README section 2026-04-16 09:59:53 +01:00
Shantur Rathore
03ed3d3b2c Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-04-16 08:43:33 +01:00
Shantur Rathore
a111de1af8 Minimum version to 0.14.0 2026-04-16 08:43:16 +01:00
Shantur Rathore
8a3b162be9 Bump version to 0.14.0 2026-04-16 08:42:33 +01:00
Shantur Rathore
c62cb3ce4a fix(server): share voice mode state across listeners 2026-04-13 21:36:49 +01:00
Shantur Rathore
d9811e735d fix(server): reject stale voice mode enables 2026-04-13 20:37:31 +01:00
Pascal André
1ce58b9dd9 fix(tauri): own Windows CLI subtree with a job object (#320)
## Summary
- Follow-up to #240 to make Windows desktop shutdown reliable this time,
even when the tracked CLI wrapper PID exits before its descendants
- Attach the spawned CLI process to a Windows Job Object with
`KILL_ON_JOB_CLOSE`, so the desktop app owns the whole subtree instead
of relying only on `taskkill /PID <wrapper> /T`
- Keep the current graceful-then-force shutdown path, but add a robust
OS-level fallback that reaps orphaned workspace processes when the
wrapper is already gone

## Root Cause
The previous Windows shutdown logic still depended on the PID tracked by
Tauri. In practice that PID can be a short-lived Node wrapper. Once that
wrapper exits, `taskkill` can report success or PID-not-found while
descendants remain alive, and the desktop app no longer has a reliable
handle to reap them.

## Validation
- `cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml`
- `cargo build --release --manifest-path
packages/tauri-app/src-tauri/Cargo.toml`
- Manual local test: orphaned processes are cleaned up after desktop
shutdown
2026-04-12 21:10:15 +01:00
Pascal André
1907a4da03 perf(ui): virtualize message timeline rendering, #274 follow-up ( BIG SPEED IMPROVEMENT ) (#291)
## Summary
- virtualize MessageTimeline so large session histories stop rendering
the full timeline sidebar at once.
- keep the existing full render path in selection mode so xray/selection
behavior stays intact.
- route active-segment scrolling through the virtualizer so timeline
navigation still follows the selected message.

## Benefit
- prompt field was very laggy in cession with big history and timeline
had many bugs, this is fixed.
- the session with big history now load as fast as a new session .
2026-04-11 22:52:00 +01:00
Shantur Rathore
abf4c67fcc fix(ui): separate dictated prompt text 2026-04-11 20:34:53 +01:00
Shantur Rathore
bc130ceb5b fix(ui): portal timeline preview tooltip 2026-04-11 19:53:25 +01:00
Shantur Rathore
8505a43b16 fix(ui): add toggle for holding long assistant replies 2026-04-11 19:47:57 +01:00
Shantur Rathore
2a3329b5ed fix(ui): hold auto-follow on oversized assistant replies 2026-04-11 19:28:27 +01:00
VooDisss
c9c1cf21f0 fix(ui): stop forced auto-follow during streaming (#309)
# PR Draft: Fix sticky auto-scroll during streaming chat responses

Fixes #308

## Summary

This change makes chat auto-scroll easier to escape while assistant
output is still streaming.

The goal is to stop the viewport from repeatedly pulling the user back
toward the bottom once they begin scrolling upward to inspect earlier
content.

## Why

Before this change, streaming updates could keep reasserting
bottom-follow behavior during active rendering. That made auto-scroll
feel sticky and forced users to scroll repeatedly or forcefully just to
review earlier parts of an in-progress response.

The intended behavior is simpler: once the user scrolls upward to leave
follow mode, the UI should respect that decision instead of fighting it
during subsequent stream updates.

## What Changed

1. Removed render-time force-bottom behavior from the shared
follow-scroll helper path.
2. Updated streamed reasoning output to restore scroll without forcing
the viewport back to the bottom.
3. Updated streamed tool-call output to use the same non-forcing restore
behavior.

## Scope Boundaries

Included:

- Sticky auto-scroll behavior during streamed chat output
- Shared follow-scroll behavior used by streamed nested panes
- Reasoning and tool-call streaming paths that reused the same forced
follow behavior

Not included:

- A full rewrite of the virtualized message list follow model
- Broader scroll UX changes outside the streaming follow/escape behavior
- Unrelated UI or plugin configuration changes in the worktree

## Technical Notes

The core problem was not basic auto-scroll itself, but a render-time
path that could keep forcing bottom-follow behavior while new streamed
content was arriving.

That meant a user's attempt to scroll upward could be overridden
repeatedly by subsequent stream updates, which is why the auto-scroll
felt sticky. The fix removes that override and keeps render-time
restoration dependent on the current follow state instead.

## Files Changed

- `packages/ui/src/lib/follow-scroll.tsx`
- `packages/ui/src/components/message-block.tsx`
- `packages/ui/src/components/tool-call.tsx`

## Verification

Performed:

1. Reproduced the sticky auto-scroll behavior with a long multi-line
streaming response.
2. Verified that scrolling upward during streaming now disengages follow
more naturally in the affected streamed panes.
3. Ran `npm run typecheck --workspace @codenomad/ui`.
4. Ran `npm run build --workspace @codenomad/ui`.

Build note:

- The UI typecheck passes.
- The UI build succeeds.
- The build still emits existing third-party and chunk-size warnings
unrelated to this change.

## Risks and Follow-up

1. The broader scroll-follow model is still more heuristic-heavy than
ideal, so there may be future follow-up work to simplify it further.
2. This PR intentionally applies the smallest targeted fix to the known
snap-back path instead of rewriting the full chat scroll system.

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-10 16:26:33 +01:00
Shantur Rathore
c7d4f99e48 fix(ui): prevent settings modal overflow on phones 2026-04-09 21:00:17 +01:00
Shantur Rathore
d50c00afb4 revert: remove debouncing and transparent window from zoom fix
Reverted debouncing logic and transparent window mode that were causing issues.
Kept the zoom step reduction from 0.2 to 0.1 for finer control.
2026-04-09 16:23:45 +01:00
Shantur Rathore
0ef57df3bc fix(ui): show token stats and simplify context window calculation
- Track messageInfoVersion in cache signature to rebuild when tokens arrive via SSE
- Read tokens from step-finish part directly (embedded in SSE events)
- Simplify available tokens to show full context window when no explicit input limit
2026-04-08 22:19:10 +01:00
Shantur Rathore
0739ec857c Reapply "fix(ui): support unified diff patch format in session changes viewer"
This reverts commit af6429162f.
2026-04-08 20:57:23 +01:00
Shantur Rathore
b060ab45ff Revert "feat(tauri): add zip bundle target for macOS and Windows"
This reverts commit 197898c01c.
2026-04-08 20:57:23 +01:00
Shantur Rathore
af6429162f Revert "fix(ui): support unified diff patch format in session changes viewer"
This reverts commit 2e9ee2cde6.
2026-04-08 20:57:12 +01:00
Shantur Rathore
2e9ee2cde6 fix(ui): support unified diff patch format in session changes viewer
Session diffs now use a compact patch field instead of storing full
before/after content. Added parsePatchToBeforeAfter utility to extract
before/after from unified diff format, and updated MonacoDiffViewer to
accept patch prop as alternative to before/after strings.
2026-04-08 20:48:13 +01:00
Shantur Rathore
d45c0b9367 fix(tauri): prevent Windows zoom freeze with debouncing and transparent window
- Add 50ms debounce to zoom operations to prevent WebView2 IPC bottleneck
- Enable transparent window mode for better Windows resize/zoom performance
- Reduce zoom step from 0.2 to 0.1 for finer control
2026-04-08 20:47:49 +01:00
Shantur Rathore
197898c01c feat(tauri): add zip bundle target for macOS and Windows
- Add build scripts for platform-specific builds with zip bundles
- Update CI workflow to use --bundles flag for explicit target selection
- macOS: use app,zip (removed dmg)
- Windows: use nsis,zip
- Linux: use appimage,deb,rpm
2026-04-08 20:34:08 +01:00
Shantur Rathore
0c0cfd2d22 fix(ui): keep speech input chained and scrolled to bottom 2026-04-08 19:02:06 +01:00
Shantur Rathore
5107ac207e feat(ui): show background process notify state 2026-04-08 16:09:17 +01:00
Shantur Rathore
1130066a33 feat(background-process): notify sessions when tasks end
Send synthetic session notifications when background processes finish, fail, stop, or terminate so the originating agent can react without polling. Hide synthetic text-only prompts from the UI stream so operational notifications stay out of the visible transcript.
2026-04-08 15:48:50 +01:00
Shantur Rathore
403a3ff189 Scroll fixes - Improve scroll to bottom handling for reasoning, bash and task tools (#288)
Fixes #286 and more
2026-04-04 15:11:45 +01:00
codenomadbot[bot]
7996e514c4 fix(ui): preserve prompt text when dismissing mention picker (#285)
## Summary
- preserve the current prompt text when dismissing the `@` mention/file
picker with `Esc`
- let `Enter` fall back to normal prompt submission when the mention
picker is open but there is no selectable result

## Verification
- source inspection of the prompt input and picker flow
- local `npm run typecheck --workspace @codenomad/ui` is blocked in this
environment because workspace dependencies are not installed

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

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-04 00:48:37 +01:00
Pascal André
141be2cde0 perf(ui): fix O(n²) reactive subscriptions in timeline effects (HUGE SPEED IMPROVEMENT) (#274)
## Summary

- Wraps store-proxied array iteration in `untrack()` in two
`createEffect` blocks and one `createMemo` in `message-section.tsx` to
prevent SolidJS from creating O(n) per-element reactive subscriptions on
every run
- Replaces `ids.includes()` with `Set.has()` for O(1) cleanup lookups in
the part-count tracking effect

## Problem

Two `createEffect` blocks in `message-section.tsx` iterate the
`messageIds()` store proxy array inside a tracked reactive context. This
causes SolidJS to create **O(n) per-element subscriptions** on every
run. When any element changes, all n subscriptions fire, re-running the
entire effect — resulting in **O(n²) total work**.

Additionally, the cleanup loop in the part-count tracking effect uses
`ids.includes(trackedId)` which is O(n) per tracked ID, compounding to
O(n²).

For long-running sessions with large message history (e.g. 7569
messages), this caused **~4.8 seconds of input latency** when sending a
new prompt.

## Fix

1. **Timeline sync effect (~line 738):** Wrap entire body in
`untrack()`, replace `ids.slice()` with `[...ids]` to snapshot without
proxy tracking
2. **Part-count tracking effect (~line 891):** Wrap iteration in
`untrack()`, replace `ids.includes()` with `new Set(ids).has()` for O(1)
lookups
3. **`lastAssistantIndex` memo:** Read message records via `untrack()`
to avoid O(n) subscriptions on part-level updates

## Result

On a 7569-message session: prompt input latency reduced from **~4.8s to
~42ms** (114x improvement).
2026-04-03 23:01:13 +01:00
codenomadbot[bot]
259d457209 fix(desktop): launch server with unrestricted root (#283)
## Summary
- launch the Electron-managed server with `--unrestricted-root` by
default
- launch the Tauri-managed server with `--unrestricted-root` by default
- stop relying on the server's `process.cwd()` fallback for desktop
filesystem browsing

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

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-04-03 16:47:34 +01:00
85 changed files with 4613 additions and 1080 deletions

View File

@@ -212,7 +212,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
for file in packages/electron-app/release/*.zip; do for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
[ -f "$file" ] || continue [ -f "$file" ] || continue
echo "Uploading $file" echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber gh release upload "$TAG" "$file" --clobber
@@ -313,7 +313,7 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
for file in packages/electron-app/release/*.zip; do for file in packages/electron-app/release/*.zip packages/electron-app/release/*.AppImage; do
[ -f "$file" ] || continue [ -f "$file" ] || continue
echo "Uploading $file" echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber gh release upload "$TAG" "$file" --clobber
@@ -324,7 +324,9 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
path: packages/electron-app/release/*.zip path: |
packages/electron-app/release/*.zip
packages/electron-app/release/*.AppImage
retention-days: ${{ inputs.actions_artifacts_retention_days }} retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error if-no-files-found: error

View File

@@ -18,6 +18,7 @@ CodeNomad transforms OpenCode from a terminal tool into a **premium desktop work
- **🎙️ Voice Input & Speech** - **🎙️ Voice Input & Speech**
- **🌳 Git Worktrees** - **🌳 Git Worktrees**
- **💬 Rich Message Experience** - **💬 Rich Message Experience**
- **🧩 SideCars**
- **⌨️ Command Palette** - **⌨️ Command Palette**
- **📁 File System Browser** - **📁 File System Browser**
- **🔐 Authentication & Security** - **🔐 Authentication & Security**
@@ -61,6 +62,60 @@ npx @neuralnomads/codenomad-dev --launch
--- ---
## SideCars
SideCars let you open local web tools inside CodeNomad as tabs.
<details>
<summary><strong>Configuration</strong></summary>
- **Name**: Display name used in CodeNomad
- **Port**: Local HTTP or HTTPS service running on `127.0.0.1:<port>`
- **Base path**: Mounted under `/sidecars/:id`
- **Prefix mode**:
- **Preserve prefix** forwards the full `/sidecars/:id/...` path upstream
- **Strip prefix** removes `/sidecars/:id` before forwarding the request upstream
</details>
<details>
<summary><strong>VSCode (OpenVSCode Server)</strong></summary>
Run with Docker:
```bash
docker run -it --init -p 8000:3000 -v "${HOME}:${HOME}:cached" -e HOME=${HOME} gitpod/openvscode-server --server-base-path /sidecars/vscode
```
Add SideCar as:
- **Name**: `VSCode`
- **Port**: `http://127.0.0.1:8000`
- **Base path**: `/sidecars/vscode`
- **Prefix mode**: `Preserve prefix`
</details>
<details>
<summary><strong>Terminal (ttyd)</strong></summary>
Run with:
```bash
ttyd --writable zsh
```
Add SideCar as:
- **Name**: `Terminal`
- **Port**: `http://127.0.0.1:7681`
- **Base path**: `/sidecars/terminal`
- **Prefix mode**: `Strip prefix`
</details>
---
## Requirements ## Requirements
- **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH` - **[OpenCode CLI](https://opencode.ai)** — must be installed and in your `PATH`

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.3", "version": "0.14.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.3", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -12068,7 +12068,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.13.3", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -12105,7 +12105,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.3", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12147,7 +12147,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.13.3", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12155,7 +12155,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.13.3", "version": "0.14.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.13.3", "version": "0.14.0",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"license": "MIT", "license": "MIT",

View File

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

View File

@@ -539,7 +539,7 @@ export class CliProcessManager extends EventEmitter {
} }
private buildCliArgs(options: StartOptions, host: string): string[] { private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName] const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName, "--unrestricted-root"]
if (options.dev) { if (options.dev) {
// Dev: run plain HTTP + Vite dev server proxy. // Dev: run plain HTTP + Vite dev server proxy.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.13.3", "version": "0.14.0",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -147,6 +147,13 @@
"x64", "x64",
"arm64" "arm64"
] ]
},
{
"target": "AppImage",
"arch": [
"x64",
"arm64"
]
} }
], ],
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}", "artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",

View File

@@ -13,6 +13,11 @@ type BackgroundProcess = {
outputSizeBytes?: number outputSizeBytes?: number
} }
type BackgroundProcessNotificationRequest = {
sessionID: string
directory: string
}
type BackgroundProcessOptions = { type BackgroundProcessOptions = {
baseDir: string baseDir: string
} }
@@ -36,12 +41,19 @@ export function createBackgroundProcessTools(config: CodeNomadConfig, options: B
args: { args: {
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"), title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
command: tool.schema.string().describe("Shell command to run in the workspace"), command: tool.schema.string().describe("Shell command to run in the workspace"),
notify: tool.schema.boolean().optional().describe("Notify the current session when the process ends"),
}, },
async execute(args) { async execute(args, context) {
assertCommandWithinBase(args.command, options.baseDir) assertCommandWithinBase(args.command, options.baseDir)
const notification: BackgroundProcessNotificationRequest | undefined = args.notify
? {
sessionID: context.sessionID,
directory: context.directory,
}
: undefined
const process = await request<BackgroundProcess>("", { const process = await request<BackgroundProcess>("", {
method: "POST", method: "POST",
body: JSON.stringify({ title: args.title, command: args.command }), body: JSON.stringify({ title: args.title, command: args.command, notify: args.notify, notification }),
}) })
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}` return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.13.3", "version": "0.14.0",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

@@ -81,6 +81,55 @@ export interface WorktreeMap {
parentSessionWorktreeSlug: Record<string, string> parentSessionWorktreeSlug: Record<string, string>
} }
export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged"
export interface WorktreeGitStatusEntry {
path: string
originalPath?: string | null
stagedStatus: GitChangeKind | null
stagedAdditions: number
stagedDeletions: number
unstagedStatus: GitChangeKind | null
unstagedAdditions: number
unstagedDeletions: number
}
export type WorktreeGitStatusResponse = WorktreeGitStatusEntry[]
export type WorktreeGitDiffScope = "staged" | "unstaged"
export interface WorktreeGitPathsRequest {
paths: string[]
}
export interface WorktreeGitMutationResponse {
ok: true
}
export interface WorktreeGitCommitRequest {
message: string
}
export interface WorktreeGitCommitResponse {
ok: true
commitSha?: string
}
export interface WorktreeGitDiffResponse {
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
before: string
after: string
isBinary?: boolean
}
export interface WorktreeGitDiffRequest {
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
}
export type LogLevel = "debug" | "info" | "warn" | "error" export type LogLevel = "debug" | "info" | "warn" | "error"
export interface WorkspaceLogEntry { export interface WorkspaceLogEntry {
@@ -376,6 +425,8 @@ export interface ServerMeta {
export type BackgroundProcessStatus = "running" | "stopped" | "error" export type BackgroundProcessStatus = "running" | "stopped" | "error"
export type BackgroundProcessTerminalReason = "finished" | "failed" | "user_stopped" | "user_terminated"
export interface BackgroundProcess { export interface BackgroundProcess {
id: string id: string
workspaceId: string workspaceId: string
@@ -388,6 +439,8 @@ export interface BackgroundProcess {
stoppedAt?: string stoppedAt?: string
exitCode?: number exitCode?: number
outputSizeBytes?: number outputSizeBytes?: number
terminalReason?: BackgroundProcessTerminalReason
notifyEnabled?: boolean
} }
export interface BackgroundProcessListResponse { export interface BackgroundProcessListResponse {

View File

@@ -5,7 +5,7 @@ import { randomBytes } from "crypto"
import type { EventBus } from "../events/bus" import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager" import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger" import type { Logger } from "../logger"
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types" import type { BackgroundProcess, BackgroundProcessStatus, BackgroundProcessTerminalReason } from "../api-types"
const ROOT_DIR = ".codenomad/background_processes" const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json" const INDEX_FILE = "index.json"
@@ -27,6 +27,31 @@ interface RunningProcess {
outputPath: string outputPath: string
exitPromise: Promise<void> exitPromise: Promise<void>
workspaceId: string workspaceId: string
completion?: ProcessCompletion
}
interface ProcessCompletion {
reason: BackgroundProcessTerminalReason
endContext: "normal" | "workspace_cleanup"
removeAfterFinalize?: boolean
}
interface BackgroundProcessNotificationState {
sessionID: string
directory: string
sentAt?: string
}
interface PersistedBackgroundProcess extends BackgroundProcess {
notify?: BackgroundProcessNotificationState
}
interface StartOptions {
notify?: boolean
notification?: {
sessionID: string
directory: string
}
} }
export class BackgroundProcessManager { export class BackgroundProcessManager {
@@ -41,14 +66,14 @@ export class BackgroundProcessManager {
const records = await this.readIndex(workspaceId) const records = await this.readIndex(workspaceId)
const enriched = await Promise.all( const enriched = await Promise.all(
records.map(async (record) => ({ records.map(async (record) => ({
...record, ...this.toPublicProcess(record),
outputSizeBytes: await this.getOutputSize(workspaceId, record.id), outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
})), })),
) )
return enriched return enriched
} }
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> { async start(workspaceId: string, title: string, command: string, options: StartOptions = {}): Promise<BackgroundProcess> {
const workspace = this.deps.workspaceManager.get(workspaceId) const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) { if (!workspace) {
throw new Error("Workspace not found") throw new Error("Workspace not found")
@@ -73,8 +98,7 @@ export class BackgroundProcessManager {
this.killProcessTree(child, "SIGTERM") this.killProcessTree(child, "SIGTERM")
}) })
const record: BackgroundProcess = { const record: PersistedBackgroundProcess = {
id, id,
workspaceId, workspaceId,
title, title,
@@ -84,6 +108,20 @@ export class BackgroundProcessManager {
pid: child.pid, pid: child.pid,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
outputSizeBytes: 0, outputSizeBytes: 0,
notify: options.notify && options.notification
? {
sessionID: options.notification.sessionID,
directory: options.notification.directory,
}
: undefined,
}
const runningState: RunningProcess = {
id,
child,
outputPath,
exitPromise: Promise.resolve(),
workspaceId,
} }
const exitPromise = new Promise<void>((resolve) => { const exitPromise = new Promise<void>((resolve) => {
@@ -91,18 +129,21 @@ export class BackgroundProcessManager {
await new Promise<void>((resolve) => outputStream.end(resolve)) await new Promise<void>((resolve) => outputStream.end(resolve))
this.running.delete(id) this.running.delete(id)
record.status = this.statusFromExit(code) const completion = runningState.completion ?? this.completionFromExit(code)
record.terminalReason = completion.reason
record.status = this.statusFromReason(completion.reason)
record.exitCode = code === null ? undefined : code record.exitCode = code === null ? undefined : code
record.stoppedAt = new Date().toISOString() record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record) await this.finalizeRecord(workspaceId, record, completion)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
resolve() resolve()
}) })
}) })
this.running.set(id, { id, child, outputPath, exitPromise, workspaceId }) runningState.exitPromise = exitPromise
this.running.set(id, runningState)
let lastPublishAt = 0 let lastPublishAt = 0
const maybePublishSize = () => { const maybePublishSize = () => {
@@ -128,7 +169,7 @@ export class BackgroundProcessManager {
await this.upsertIndex(workspaceId, record) await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id) record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record) this.publishUpdate(workspaceId, record)
return record return this.toPublicProcess(record)
} }
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> { async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
@@ -139,19 +180,21 @@ export class BackgroundProcessManager {
const running = this.running.get(processId) const running = this.running.get(processId)
if (running?.child && !running.child.killed) { if (running?.child && !running.child.killed) {
running.completion = { reason: "user_stopped", endContext: "normal" }
this.killProcessTree(running.child, "SIGTERM") this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running) await this.waitForExit(running)
const updated = await this.findProcess(workspaceId, processId)
return updated ? this.toPublicProcess(updated) : this.toPublicProcess(record)
} }
if (record.status === "running") { if (record.status === "running") {
record.status = "stopped" record.status = "stopped"
record.terminalReason = "user_stopped"
record.stoppedAt = new Date().toISOString() record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record) await this.finalizeRecord(workspaceId, record, { reason: "user_stopped", endContext: "normal" })
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
} }
return record return this.toPublicProcess(record)
} }
async terminate(workspaceId: string, processId: string): Promise<void> { async terminate(workspaceId: string, processId: string): Promise<void> {
@@ -160,17 +203,19 @@ export class BackgroundProcessManager {
const running = this.running.get(processId) const running = this.running.get(processId)
if (running?.child && !running.child.killed) { if (running?.child && !running.child.killed) {
running.completion = { reason: "user_terminated", endContext: "normal", removeAfterFinalize: true }
this.killProcessTree(running.child, "SIGTERM") this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running) await this.waitForExit(running)
return
} }
await this.removeFromIndex(workspaceId, processId) record.status = "stopped"
await this.removeProcessDir(workspaceId, processId) record.terminalReason = "user_terminated"
record.stoppedAt = new Date().toISOString()
this.deps.eventBus.publish({ await this.finalizeRecord(workspaceId, record, {
type: "instance.event", reason: "user_terminated",
instanceId: workspaceId, endContext: "normal",
event: { type: "background.process.removed", properties: { processId } }, removeAfterFinalize: true,
}) })
} }
@@ -266,6 +311,11 @@ export class BackgroundProcessManager {
private async cleanupWorkspace(workspaceId: string) { private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) { for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue if (running.workspaceId !== workspaceId) continue
running.completion = {
reason: "user_terminated",
endContext: "workspace_cleanup",
removeAfterFinalize: true,
}
this.killProcessTree(running.child, "SIGTERM") this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running) await this.waitForExit(running)
} }
@@ -356,10 +406,17 @@ export class BackgroundProcessManager {
return args return args
} }
private statusFromExit(code: number | null): BackgroundProcessStatus { private completionFromExit(code: number | null): ProcessCompletion {
if (code === null) return "stopped" if (code === 0) {
if (code === 0) return "stopped" return { reason: "finished", endContext: "normal" }
return "error" }
return { reason: "failed", endContext: "normal" }
}
private statusFromReason(reason: BackgroundProcessTerminalReason): BackgroundProcessStatus {
if (reason === "failed") return "error"
return "stopped"
} }
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> { private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
@@ -423,25 +480,25 @@ export class BackgroundProcessManager {
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE) return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
} }
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> { private async findProcess(workspaceId: string, processId: string): Promise<PersistedBackgroundProcess | null> {
const records = await this.readIndex(workspaceId) const records = await this.readIndex(workspaceId)
return records.find((entry) => entry.id === processId) ?? null return records.find((entry) => entry.id === processId) ?? null
} }
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> { private async readIndex(workspaceId: string): Promise<PersistedBackgroundProcess[]> {
const indexPath = await this.getIndexPath(workspaceId) const indexPath = await this.getIndexPath(workspaceId)
if (!existsSync(indexPath)) return [] if (!existsSync(indexPath)) return []
try { try {
const raw = await fs.readFile(indexPath, "utf-8") const raw = await fs.readFile(indexPath, "utf-8")
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : [] return Array.isArray(parsed) ? (parsed as PersistedBackgroundProcess[]) : []
} catch { } catch {
return [] return []
} }
} }
private async upsertIndex(workspaceId: string, record: BackgroundProcess) { private async upsertIndex(workspaceId: string, record: PersistedBackgroundProcess) {
const records = await this.readIndex(workspaceId) const records = await this.readIndex(workspaceId)
const index = records.findIndex((entry) => entry.id === record.id) const index = records.findIndex((entry) => entry.id === record.id)
if (index >= 0) { if (index >= 0) {
@@ -458,7 +515,7 @@ export class BackgroundProcessManager {
await this.writeIndex(workspaceId, next) await this.writeIndex(workspaceId, next)
} }
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) { private async writeIndex(workspaceId: string, records: PersistedBackgroundProcess[]) {
const indexPath = await this.getIndexPath(workspaceId) const indexPath = await this.getIndexPath(workspaceId)
await fs.mkdir(path.dirname(indexPath), { recursive: true }) await fs.mkdir(path.dirname(indexPath), { recursive: true })
await fs.writeFile(indexPath, JSON.stringify(records, null, 2)) await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
@@ -503,14 +560,139 @@ export class BackgroundProcessManager {
} }
} }
private publishUpdate(workspaceId: string, record: BackgroundProcess) { private publishUpdate(workspaceId: string, record: PersistedBackgroundProcess) {
this.deps.eventBus.publish({ this.deps.eventBus.publish({
type: "instance.event", type: "instance.event",
instanceId: workspaceId, instanceId: workspaceId,
event: { type: "background.process.updated", properties: { process: record } }, event: { type: "background.process.updated", properties: { process: this.toPublicProcess(record) } },
}) })
} }
private toPublicProcess(record: PersistedBackgroundProcess): BackgroundProcess {
return {
id: record.id,
workspaceId: record.workspaceId,
title: record.title,
command: record.command,
cwd: record.cwd,
status: record.status,
pid: record.pid,
startedAt: record.startedAt,
stoppedAt: record.stoppedAt,
exitCode: record.exitCode,
outputSizeBytes: record.outputSizeBytes,
terminalReason: record.terminalReason,
notifyEnabled: Boolean(record.notify),
}
}
private async finalizeRecord(workspaceId: string, record: PersistedBackgroundProcess, completion: ProcessCompletion) {
if (this.shouldSendCompletionPrompt(record, completion)) {
try {
await this.sendCompletionPrompt(workspaceId, record)
if (record.notify) {
record.notify.sentAt = new Date().toISOString()
}
} catch (error) {
this.deps.logger.warn({ err: error, workspaceId, processId: record.id }, "Failed to send background process completion prompt")
}
}
if (completion.removeAfterFinalize) {
await this.removeFromIndex(workspaceId, record.id)
await this.removeProcessDir(workspaceId, record.id)
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.removed", properties: { processId: record.id } },
})
return
}
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
}
private shouldSendCompletionPrompt(record: PersistedBackgroundProcess, completion: ProcessCompletion) {
if (completion.endContext === "workspace_cleanup") return false
if (!record.notify) return false
return !record.notify.sentAt
}
private async sendCompletionPrompt(workspaceId: string, record: PersistedBackgroundProcess) {
const notify = record.notify
if (!notify || !record.terminalReason) return
if (!this.deps.workspaceManager.get(workspaceId)) {
throw new Error("Workspace not found")
}
const port = this.deps.workspaceManager.getInstancePort(workspaceId)
if (!port) {
throw new Error("Workspace instance is not ready")
}
const targetUrl = `http://127.0.0.1:${port}/session/${encodeURIComponent(notify.sessionID)}/prompt_async`
const headers: Record<string, string> = {
"content-type": "application/json",
"x-opencode-directory": /[^\x00-\x7F]/.test(notify.directory) ? encodeURIComponent(notify.directory) : notify.directory,
}
const authorization = this.deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
if (authorization) {
headers.authorization = authorization
}
const response = await fetch(targetUrl, {
method: "POST",
headers,
body: JSON.stringify({
parts: [
{
type: "text",
text: this.buildSyntheticCompletionPrompt(record),
synthetic: true,
},
],
}),
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Prompt request failed with ${response.status}`)
}
}
private buildCompletionPrompt(record: PersistedBackgroundProcess): string {
const ref = `Background process "${record.title}" (${record.id})`
switch (record.terminalReason) {
case "finished":
return `${ref} finished successfully.`
case "failed":
return record.exitCode === undefined ? `${ref} failed.` : `${ref} failed with exit code ${record.exitCode}.`
case "user_stopped":
return `${ref} was stopped by user.`
case "user_terminated":
return `${ref} was terminated by user.`
}
return `${ref} ended.`
}
private buildSyntheticCompletionPrompt(record: PersistedBackgroundProcess): string {
return `<system-message>${this.escapeTaggedText(this.buildCompletionPrompt(record))}</system-message>`
}
private escapeTaggedText(input: string): string {
return input
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
private generateId(): string { private generateId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15) const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
const random = randomBytes(3).toString("hex") const random = randomBytes(3).toString("hex")

View File

@@ -25,6 +25,9 @@ import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/networ
import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service" import { SpeechService } from "./speech/service"
import { SideCarManager } from "./sidecars/manager" import { SideCarManager } from "./sidecars/manager"
import { ClientConnectionManager } from "./clients/connection-manager"
import { PluginChannelManager } from "./plugins/channel"
import { VoiceModeManager } from "./plugins/voice-mode"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@@ -378,6 +381,14 @@ async function main() {
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host) const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
logger: logger.child({ component: "voice-mode" }),
})
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT) const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT) const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
@@ -408,6 +419,9 @@ async function main() {
speechService, speechService,
sidecarManager, sidecarManager,
authManager, authManager,
clientConnectionManager,
pluginChannel,
voiceModeManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl, uiDevServerUrl: uiResolution.uiDevServerUrl,
logger, logger,
@@ -430,6 +444,9 @@ async function main() {
speechService, speechService,
sidecarManager, sidecarManager,
authManager, authManager,
clientConnectionManager,
pluginChannel,
voiceModeManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined, uiDevServerUrl: undefined,
logger, logger,
@@ -534,6 +551,12 @@ async function main() {
logger.error({ err: error }, "SideCar manager shutdown failed") logger.error({ err: error }, "SideCar manager shutdown failed")
} }
try {
clientConnectionManager.shutdown()
} catch (error) {
logger.warn({ err: error }, "Client connection manager shutdown failed")
}
try { try {
await workspaceManager.shutdown() await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete") logger.info("Workspace manager shutdown complete")

View File

@@ -19,13 +19,13 @@ export class VoiceModeManager {
}) })
} }
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void { setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): boolean {
if (enabled && !this.options.connections.isConnected(connection)) { if (enabled && !this.options.connections.isConnected(connection)) {
this.options.logger.debug( this.options.logger.debug(
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId }, { instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
"Ignoring voice mode enable for disconnected client connection", "Ignoring voice mode enable for disconnected client connection",
) )
return return false
} }
const key = getConnectionKey(connection) const key = getConnectionKey(connection)
@@ -44,6 +44,7 @@ export class VoiceModeManager {
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection") this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
this.publishIfChanged(instanceId) this.publishIfChanged(instanceId)
return true
} }
syncInstance(instanceId: string): void { syncInstance(instanceId: string): void {
@@ -76,7 +77,10 @@ export class VoiceModeManager {
this.aggregateByInstance.delete(instanceId) this.aggregateByInstance.delete(instanceId)
} }
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode") this.options.logger.debug(
{ instanceId, enabled },
"Broadcasting aggregate voice mode",
)
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled)) this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
} }
} }

View File

@@ -10,6 +10,7 @@ import { fetch } from "undici"
import type { Logger } from "../logger" import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager" import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees" import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import { resolveWorktreeDirectory } from "../workspaces/worktree-directory"
import type { SettingsService } from "../settings/service" import type { SettingsService } from "../settings/service"
import { FileSystemBrowser } from "../filesystem/browser" import { FileSystemBrowser } from "../filesystem/browser"
@@ -54,6 +55,9 @@ interface HttpServerDeps {
speechService: SpeechService speechService: SpeechService
sidecarManager: SideCarManager sidecarManager: SideCarManager
authManager: AuthManager authManager: AuthManager
clientConnectionManager: ClientConnectionManager
pluginChannel: PluginChannelManager
voiceModeManager: VoiceModeManager
uiStaticDir: string uiStaticDir: string
uiDevServerUrl?: string uiDevServerUrl?: string
logger: Logger logger: Logger
@@ -182,13 +186,6 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }), logger: deps.logger.child({ component: "background-processes" }),
}) })
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
const voiceModeManager = new VoiceModeManager({
connections: clientConnectionManager,
channel: pluginChannel,
logger: deps.logger.child({ component: "voice-mode" }),
})
registerAuthRoutes(app, { authManager: deps.authManager }) registerAuthRoutes(app, { authManager: deps.authManager })
@@ -268,7 +265,7 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus, eventBus: deps.eventBus,
registerClient: registerSseClient, registerClient: registerSseClient,
logger: sseLogger, logger: sseLogger,
connectionManager: clientConnectionManager, connectionManager: deps.clientConnectionManager,
}) })
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager }) registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
registerStorageRoutes(app, { registerStorageRoutes(app, {
@@ -289,8 +286,8 @@ export function createHttpServer(deps: HttpServerDeps) {
workspaceManager: deps.workspaceManager, workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus, eventBus: deps.eventBus,
logger: proxyLogger, logger: proxyLogger,
channel: pluginChannel, channel: deps.pluginChannel,
voiceModeManager, voiceModeManager: deps.voiceModeManager,
}) })
registerBackgroundProcessRoutes(app, { backgroundProcessManager }) registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger }) registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
@@ -356,7 +353,6 @@ export function createHttpServer(deps: HttpServerDeps) {
}, },
stop: () => { stop: () => {
closeSseClients() closeSseClients()
clientConnectionManager.shutdown()
return app.close() return app.close()
}, },
} }
@@ -765,52 +761,6 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}` return trimmed.length === 0 ? "/" : `/${trimmed}`
} }
type WorktreeCacheEntry = {
expiresAt: number
repoRoot: string
worktrees: Array<{ slug: string; directory: string }>
}
const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
const cached = worktreeCache.get(params.workspaceId)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached
}
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
const entry: WorktreeCacheEntry = {
expiresAt: now + WORKTREE_CACHE_TTL_MS,
repoRoot,
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
}
worktreeCache.set(params.workspaceId, entry)
return entry
}
async function resolveWorktreeDirectory(params: {
workspaceId: string
workspacePath: string
worktreeSlug: string
logger: Logger
}): Promise<string | null> {
const { worktreeSlug } = params
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
if (match) {
return match.directory
}
// If the slug is new (e.g., created moments ago), refresh once.
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
}
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) { function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) { if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only") app.log.warn("UI static directory not provided; API endpoints only")

View File

@@ -9,6 +9,21 @@ interface RouteDeps {
const StartSchema = z.object({ const StartSchema = z.object({
title: z.string().trim().min(1), title: z.string().trim().min(1),
command: z.string().trim().min(1), command: z.string().trim().min(1),
notify: z.boolean().optional(),
notification: z
.object({
sessionID: z.string().trim().min(1),
directory: z.string().trim().min(1),
})
.optional(),
}).superRefine((value, ctx) => {
if (value.notify && !value.notification) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Notification metadata is required when notify is enabled",
path: ["notification"],
})
}
}) })
const OutputQuerySchema = z.object({ const OutputQuerySchema = z.object({
@@ -27,7 +42,10 @@ export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: Rout
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => { app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
const payload = StartSchema.parse(request.body ?? {}) const payload = StartSchema.parse(request.body ?? {})
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command) const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command, {
notify: payload.notify,
notification: payload.notification,
})
reply.code(201) reply.code(201)
return process return process
}) })

View File

@@ -66,11 +66,17 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
} }
const payload = VoiceModeStateSchema.parse(request.body ?? {}) const payload = VoiceModeStateSchema.parse(request.body ?? {})
deps.voiceModeManager.setEnabled( const applied = deps.voiceModeManager.setEnabled(
request.params.id, request.params.id,
{ clientId: payload.clientId, connectionId: payload.connectionId }, { clientId: payload.clientId, connectionId: payload.connectionId },
payload.enabled, payload.enabled,
) )
if (payload.enabled && !applied) {
reply.code(409).send({ error: "Client connection not active for voice mode enable" })
return
}
return { enabled: payload.enabled } return { enabled: payload.enabled }
}) })

View File

@@ -1,6 +1,10 @@
import { FastifyInstance, FastifyReply } from "fastify" import { FastifyInstance, FastifyReply } from "fastify"
import { z } from "zod" import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager" import { WorkspaceManager } from "../../workspaces/manager"
import { getWorktreeGitDiff, getWorktreeGitStatus } from "../../workspaces/git-status"
import { commitWorktreeChanges, isGitMutationError, stageWorktreePaths, unstageWorktreePaths } from "../../workspaces/git-mutations"
import { isGitAvailable, resolveRepoRoot } from "../../workspaces/git-worktrees"
import { resolveWorktreeDirectory } from "../../workspaces/worktree-directory"
interface RouteDeps { interface RouteDeps {
workspaceManager: WorkspaceManager workspaceManager: WorkspaceManager
@@ -23,6 +27,20 @@ const WorkspaceFileContentBodySchema = z.object({
contents: z.string(), contents: z.string(),
}) })
const WorktreeGitDiffQuerySchema = z.object({
path: z.string().trim().min(1, "Path is required"),
originalPath: z.string().trim().optional(),
scope: z.enum(["staged", "unstaged"]),
})
const WorktreeGitPathsBodySchema = z.object({
paths: z.array(z.string().trim().min(1, "Path is required")).min(1, "At least one path is required"),
})
const WorktreeGitCommitBodySchema = z.object({
message: z.string().trim().min(1, "Commit message is required"),
})
const WorkspaceFileSearchQuerySchema = z.object({ const WorkspaceFileSearchQuerySchema = z.object({
q: z.string().trim().min(1, "Query is required"), q: z.string().trim().min(1, "Query is required"),
limit: z.coerce.number().int().positive().max(200).optional(), limit: z.coerce.number().int().positive().max(200).optional(),
@@ -118,10 +136,138 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return handleWorkspaceError(error, reply) return handleWorkspaceError(error, reply)
} }
}) })
app.get<{
Params: { id: string; slug: string }
}>("/api/workspaces/:id/worktrees/:slug/git-status", async (request, reply) => {
try {
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
return await getWorktreeGitStatus({ workspaceFolder: directory, logger: request.log })
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.get<{
Params: { id: string; slug: string }
Querystring: { path: string; originalPath?: string; scope: "staged" | "unstaged" }
}>("/api/workspaces/:id/worktrees/:slug/git-diff", async (request, reply) => {
try {
const query = WorktreeGitDiffQuerySchema.parse(request.query ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
return await getWorktreeGitDiff({
workspaceFolder: directory,
path: query.path,
originalPath: query.originalPath,
scope: query.scope,
})
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { paths: string[] }
}>("/api/workspaces/:id/worktrees/:slug/git-stage", async (request, reply) => {
try {
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
await stageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
return { ok: true as const }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { paths: string[] }
}>("/api/workspaces/:id/worktrees/:slug/git-unstage", async (request, reply) => {
try {
const body = WorktreeGitPathsBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
await unstageWorktreePaths({ workspaceFolder: directory, paths: body.paths })
return { ok: true as const }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
app.post<{
Params: { id: string; slug: string }
Body: { message: string }
}>("/api/workspaces/:id/worktrees/:slug/git-commit", async (request, reply) => {
try {
const body = WorktreeGitCommitBodySchema.parse(request.body ?? {})
const directory = await resolveGitWorktreeDirectory(deps.workspaceManager, request.params.id, request.params.slug, request.log, reply)
if (!directory) return
const result = await commitWorktreeChanges({ workspaceFolder: directory, message: body.message })
return { ok: true as const, ...result }
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
}
async function resolveGitWorktreeDirectory(
workspaceManager: WorkspaceManager,
workspaceId: string,
worktreeSlug: string,
logger: { debug?: (obj: any, msg?: string) => void; warn?: (obj: any, msg?: string) => void },
reply: FastifyReply,
): Promise<string | null> {
const workspace = workspaceManager.get(workspaceId)
if (!workspace) {
reply.code(404)
reply.send({ error: "Workspace not found" })
return null
}
const gitAvailable = await isGitAvailable(workspace.path)
if (!gitAvailable) {
reply.code(503)
reply.send({ error: "Git is not installed or not available in PATH" })
return null
}
const { isGitRepo } = await resolveRepoRoot(workspace.path, logger)
if (!isGitRepo) {
reply.code(400)
reply.send({ error: "Workspace is not a Git repository" })
return null
}
const directory = await resolveWorktreeDirectory({
workspaceId: workspace.id,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404)
reply.send({ error: "Worktree not found" })
return null
}
return directory
} }
function handleWorkspaceError(error: unknown, reply: FastifyReply) { function handleWorkspaceError(error: unknown, reply: FastifyReply) {
if (isGitMutationError(error)) {
reply.code(error.statusCode)
return { error: error.message }
}
if (error instanceof Error && error.message === "Workspace not found") { if (error instanceof Error && error.message === "Workspace not found") {
reply.code(404) reply.code(404)
return { error: "Workspace not found" } return { error: "Workspace not found" }

View File

@@ -0,0 +1,121 @@
import { spawn } from "child_process"
import path from "path"
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
class GitMutationError extends Error {
statusCode: number
constructor(message: string, statusCode = 400) {
super(message)
this.name = "GitMutationError"
this.statusCode = statusCode
}
}
function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (code === 0) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
export function normalizeGitWorktreeRelativePath(input: string): string {
const normalized = input.trim().replace(/\\+/g, "/").replace(/^\.\//, "")
if (!normalized) {
throw new GitMutationError("Path is required", 400)
}
if (path.posix.isAbsolute(normalized) || path.win32.isAbsolute(normalized)) {
throw new GitMutationError(`Absolute paths are not allowed: ${input}`, 400)
}
if (normalized === "." || normalized === "..") {
throw new GitMutationError(`Invalid path: ${input}`, 400)
}
if (normalized.startsWith("../") || normalized.includes("/../") || normalized.endsWith("/..")) {
throw new GitMutationError(`Path traversal is not allowed: ${input}`, 400)
}
return normalized
}
function normalizeGitMutationPaths(paths: string[]): string[] {
const deduped = new Set<string>()
for (const rawPath of paths) {
deduped.add(normalizeGitWorktreeRelativePath(rawPath))
}
const normalized = Array.from(deduped)
if (normalized.length === 0) {
throw new GitMutationError("At least one path is required", 400)
}
return normalized
}
async function ensureGitCommandSucceeded(resultPromise: Promise<GitResult>, fallbackMessage: string): Promise<string> {
const result = await resultPromise
if (!result.ok) {
const message = result.stderr?.trim() || result.error.message || fallbackMessage
throw new GitMutationError(message, 409)
}
return result.stdout
}
export function isGitMutationError(error: unknown): error is GitMutationError {
return error instanceof GitMutationError
}
export async function stageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
const paths = normalizeGitMutationPaths(params.paths)
await ensureGitCommandSucceeded(runGit(["add", "--", ...paths], params.workspaceFolder), "Failed to stage files")
}
export async function unstageWorktreePaths(params: { workspaceFolder: string; paths: string[] }): Promise<void> {
const paths = normalizeGitMutationPaths(params.paths)
const headResult = await runGit(["rev-parse", "--verify", "HEAD"], params.workspaceFolder)
if (headResult.ok) {
await ensureGitCommandSucceeded(
runGit(["restore", "--staged", "--", ...paths], params.workspaceFolder),
"Failed to unstage files",
)
return
}
await ensureGitCommandSucceeded(
runGit(["rm", "--cached", "--quiet", "--", ...paths], params.workspaceFolder),
"Failed to unstage files",
)
}
export async function commitWorktreeChanges(params: { workspaceFolder: string; message: string }): Promise<{ commitSha?: string }> {
const message = params.message.trim()
if (!message) {
throw new GitMutationError("Commit message is required", 400)
}
await ensureGitCommandSucceeded(runGit(["commit", "-m", message], params.workspaceFolder), "Failed to create commit")
const shaResult = await runGit(["rev-parse", "HEAD"], params.workspaceFolder)
if (!shaResult.ok) {
return {}
}
const commitSha = shaResult.stdout.trim()
return commitSha ? { commitSha } : {}
}

View File

@@ -0,0 +1,385 @@
import { spawn } from "child_process"
import { readFile } from "fs/promises"
import path from "path"
import type { GitChangeKind, WorktreeGitDiffResponse, WorktreeGitDiffScope, WorktreeGitStatusEntry } from "../api-types"
import type { LogLike } from "./git-worktrees"
import { normalizeGitWorktreeRelativePath } from "./git-mutations"
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
type GitSuccessResult = Extract<GitResult, { ok: true }>
async function readFileAsDiffText(filePath: string): Promise<string> {
return readFile(filePath, "utf-8")
}
async function readGitBlobAsDiffText(resultPromise: Promise<GitResult>, missingOk = false): Promise<string> {
const result = await resultPromise
if (!result.ok) {
return decodeGitShowResult(result, missingOk)
}
return result.stdout
}
function runGit(args: string[], cwd: string, acceptedExitCodes: number[] = [0]): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (acceptedExitCodes.includes(code ?? 0)) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
function ensureEntry(map: Map<string, WorktreeGitStatusEntry>, path: string): WorktreeGitStatusEntry {
const existing = map.get(path)
if (existing) return existing
const next: WorktreeGitStatusEntry = {
path,
originalPath: null,
stagedStatus: null,
stagedAdditions: 0,
stagedDeletions: 0,
unstagedStatus: null,
unstagedAdditions: 0,
unstagedDeletions: 0,
}
map.set(path, next)
return next
}
function normalizeGitStatusPath(value: string): string {
return value.trim().replace(/\\+/g, "/")
}
function parseGitChangeKind(code: string): GitChangeKind | null {
const normalized = code.trim().toUpperCase()
if (!normalized) return null
if (normalized === "A") return "added"
if (normalized === "M") return "modified"
if (normalized === "D") return "deleted"
if (normalized.startsWith("R")) return "renamed"
if (normalized.startsWith("C")) return "copied"
if (normalized === "U") return "unmerged"
return null
}
function applyNameStatusOutput(
map: Map<string, WorktreeGitStatusEntry>,
output: string,
target: "stagedStatus" | "unstagedStatus",
) {
const tokens = output.split("\0")
let index = 0
while (index < tokens.length) {
const record = tokens[index++] ?? ""
if (!record) continue
const parts = record.split("\t")
const statusCode = parseGitChangeKind(parts[0] ?? "")
if (!statusCode) continue
const inlinePath = parts.slice(1).join("\t")
const firstPath = inlinePath || tokens[index++] || ""
const secondPath = statusCode === "renamed" || statusCode === "copied" ? tokens[index++] || "" : ""
const path = statusCode === "renamed" || statusCode === "copied" ? secondPath || firstPath : firstPath
const normalizedPath = normalizeGitStatusPath(path)
if (!normalizedPath) continue
const entry = ensureEntry(map, normalizedPath)
entry[target] = statusCode
if (statusCode === "renamed" || statusCode === "copied") {
const originalPath = normalizeGitStatusPath(firstPath)
entry.originalPath = originalPath || entry.originalPath || null
}
}
}
function applyUntrackedOutput(map: Map<string, WorktreeGitStatusEntry>, output: string) {
for (const rawLine of output.split(/\r?\n/)) {
const path = normalizeGitStatusPath(rawLine)
if (!path) continue
ensureEntry(map, path).unstagedStatus = "untracked"
}
}
function parseSingleNumstat(output: string): { additions: number; deletions: number; isBinary: boolean; found: boolean } {
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line) continue
const parts = rawLine.split("\t")
const isBinary = parts[0] === "-" || parts[1] === "-"
return {
additions: isBinary ? 0 : Number.parseInt(parts[0] ?? "0", 10) || 0,
deletions: isBinary ? 0 : Number.parseInt(parts[1] ?? "0", 10) || 0,
isBinary,
found: true,
}
}
return { additions: 0, deletions: 0, isBinary: false, found: false }
}
async function getUntrackedFileNumstat(workspaceFolder: string, relativePath: string): Promise<{ additions: number; deletions: number }> {
const absolutePath = path.join(workspaceFolder, relativePath)
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], workspaceFolder, [0, 1])
if (!result.ok) {
throw result.error
}
const parsed = parseSingleNumstat(result.stdout)
return { additions: parsed.additions, deletions: parsed.deletions }
}
async function applyUntrackedFileStats(map: Map<string, WorktreeGitStatusEntry>, workspaceFolder: string) {
const pending = Array.from(map.values())
.filter((entry) => entry.unstagedStatus === "untracked")
.map(async (entry) => {
try {
const stats = await getUntrackedFileNumstat(workspaceFolder, entry.path)
entry.unstagedAdditions = stats.additions
entry.unstagedDeletions = stats.deletions
} catch {
entry.unstagedAdditions = 0
entry.unstagedDeletions = 0
}
})
await Promise.all(pending)
}
function applyNumstatOutput(
map: Map<string, WorktreeGitStatusEntry>,
output: string,
target: "staged" | "unstaged",
) {
const tokens = output.split("\0")
let index = 0
while (index < tokens.length) {
const record = tokens[index++] ?? ""
if (!record) continue
const parts = record.split("\t")
if (parts.length < 3) continue
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10)
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10)
const inlinePath = parts.slice(2).join("\t")
const isRenameLike = inlinePath === ""
const originalPath = isRenameLike ? normalizeGitStatusPath(tokens[index++] ?? "") : null
const normalizedPath = normalizeGitStatusPath(isRenameLike ? tokens[index++] ?? "" : inlinePath)
if (!normalizedPath) continue
const entry = ensureEntry(map, normalizedPath)
if (originalPath) {
entry.originalPath = originalPath
}
if (target === "staged") {
entry.stagedAdditions = Number.isFinite(additions) ? additions : 0
entry.stagedDeletions = Number.isFinite(deletions) ? deletions : 0
} else {
entry.unstagedAdditions = Number.isFinite(additions) ? additions : 0
entry.unstagedDeletions = Number.isFinite(deletions) ? deletions : 0
}
}
}
export async function getWorktreeGitStatus(params: {
workspaceFolder: string
logger?: LogLike
}): Promise<WorktreeGitStatusEntry[]> {
const { workspaceFolder, logger } = params
const [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult] = await Promise.all([
runGit(["diff", "--name-status", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["diff", "--name-status", "-z", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["ls-files", "--others", "--exclude-standard"], workspaceFolder),
runGit(["diff", "--numstat", "-z", "--cached", "--find-renames", "--find-copies"], workspaceFolder),
runGit(["diff", "--numstat", "-z", "--find-renames", "--find-copies"], workspaceFolder),
])
for (const result of [stagedResult, unstagedResult, untrackedResult, stagedNumstatResult, unstagedNumstatResult]) {
if (!result.ok) {
logger?.warn?.({ workspaceFolder, err: result.error }, "Failed to read git status for worktree")
throw result.error
}
}
const stagedOutput = (stagedResult as GitSuccessResult).stdout
const unstagedOutput = (unstagedResult as GitSuccessResult).stdout
const untrackedOutput = (untrackedResult as GitSuccessResult).stdout
const stagedNumstatOutput = (stagedNumstatResult as GitSuccessResult).stdout
const unstagedNumstatOutput = (unstagedNumstatResult as GitSuccessResult).stdout
const entries = new Map<string, WorktreeGitStatusEntry>()
applyNameStatusOutput(entries, stagedOutput, "stagedStatus")
applyNameStatusOutput(entries, unstagedOutput, "unstagedStatus")
applyUntrackedOutput(entries, untrackedOutput)
applyNumstatOutput(entries, stagedNumstatOutput, "staged")
applyNumstatOutput(entries, unstagedNumstatOutput, "unstaged")
await applyUntrackedFileStats(entries, workspaceFolder)
return Array.from(entries.values()).sort((a, b) => a.path.localeCompare(b.path))
}
function decodeGitShowResult(result: GitResult, missingOk = false): string {
if (result.ok) return result.stdout
const message = result.stderr?.trim() || result.error.message || ""
if (
missingOk &&
(message.includes("exists on disk, but not in") ||
message.includes("Path '") ||
message.includes("does not exist") ||
message.includes("unknown revision or path not in the working tree"))
) {
return ""
}
throw result.error
}
async function readGitIndexBlob(workspaceFolder: string, normalizedPath: string): Promise<GitResult> {
return runGit(["cat-file", "-p", `:${normalizedPath}`], workspaceFolder)
}
async function getTrackedDiffMetadata(params: {
workspaceFolder: string
scope: WorktreeGitDiffScope
normalizedPath: string
normalizedOriginalPath: string | null
}): Promise<{ isBinary: boolean; found: boolean }> {
const args = ["diff", "--numstat"]
if (params.scope === "staged") {
args.push("--cached")
}
args.push("--find-renames", "--find-copies", "--")
args.push(params.normalizedPath)
if (params.normalizedOriginalPath && params.normalizedOriginalPath !== params.normalizedPath) {
args.push(params.normalizedOriginalPath)
}
const result = await runGit(args, params.workspaceFolder)
if (!result.ok) {
throw result.error
}
const parsed = parseSingleNumstat(result.stdout)
return { isBinary: parsed.isBinary, found: parsed.found }
}
async function getUntrackedDiffMetadata(params: {
workspaceFolder: string
normalizedPath: string
}): Promise<{ isBinary: boolean }> {
const absolutePath = path.join(params.workspaceFolder, params.normalizedPath)
const result = await runGit(["diff", "--numstat", "--no-index", "--", "/dev/null", absolutePath], params.workspaceFolder, [0, 1])
if (!result.ok) {
throw result.error
}
return { isBinary: parseSingleNumstat(result.stdout).isBinary }
}
async function resolveUnstagedBeforePath(params: {
workspaceFolder: string
normalizedPath: string
normalizedOriginalPath: string | null
}): Promise<GitResult> {
const currentPathResult = await readGitIndexBlob(params.workspaceFolder, params.normalizedPath)
if (currentPathResult.ok || !params.normalizedOriginalPath || params.normalizedOriginalPath === params.normalizedPath) {
return currentPathResult
}
return readGitIndexBlob(params.workspaceFolder, params.normalizedOriginalPath)
}
export async function getWorktreeGitDiff(params: {
workspaceFolder: string
path: string
originalPath?: string | null
scope: WorktreeGitDiffScope
}): Promise<WorktreeGitDiffResponse> {
const normalizedPath = normalizeGitWorktreeRelativePath(params.path)
const normalizedOriginalPath = params.originalPath ? normalizeGitWorktreeRelativePath(params.originalPath) : null
const trackedMetadata = await getTrackedDiffMetadata({
workspaceFolder: params.workspaceFolder,
scope: params.scope,
normalizedPath,
normalizedOriginalPath,
})
const diffMetadata =
params.scope === "unstaged" && !trackedMetadata.found
? await getUntrackedDiffMetadata({
workspaceFolder: params.workspaceFolder,
normalizedPath,
})
: trackedMetadata
if (diffMetadata.isBinary) {
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: "",
after: "",
isBinary: true,
}
}
if (params.scope === "staged") {
const [beforeResult, afterResult] = await Promise.all([
readGitBlobAsDiffText(runGit(["show", `HEAD:${normalizedOriginalPath ?? normalizedPath}`], params.workspaceFolder), true),
readGitBlobAsDiffText(readGitIndexBlob(params.workspaceFolder, normalizedPath), true),
])
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: beforeResult,
after: afterResult,
isBinary: false,
}
}
const indexResult = await resolveUnstagedBeforePath({
workspaceFolder: params.workspaceFolder,
normalizedPath,
normalizedOriginalPath,
})
const beforeResult = await readGitBlobAsDiffText(Promise.resolve(indexResult), true)
let after = beforeResult
const fsPath = path.join(params.workspaceFolder, normalizedPath)
try {
after = await readFileAsDiffText(fsPath)
} catch {
after = ""
}
return {
path: normalizedPath,
originalPath: normalizedOriginalPath,
scope: params.scope,
before: beforeResult,
after,
isBinary: false,
}
}

View File

@@ -10,6 +10,10 @@ export interface LogLike {
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
function isGitUnavailableResult(result: GitResult): boolean {
return !result.ok && (result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"
}
function runGit(args: string[], cwd: string): Promise<GitResult> { function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }) const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
@@ -38,6 +42,9 @@ function runGit(args: string[], cwd: string): Promise<GitResult> {
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> { export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
const result = await runGit(["rev-parse", "--show-toplevel"], folder) const result = await runGit(["rev-parse", "--show-toplevel"], folder)
if (isGitUnavailableResult(result)) {
throw new Error("Git is not installed or not available in PATH")
}
if (!result.ok) { if (!result.ok) {
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root") logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
return { repoRoot: folder, isGitRepo: false } return { repoRoot: folder, isGitRepo: false }
@@ -49,6 +56,11 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise
return { repoRoot, isGitRepo: true } return { repoRoot, isGitRepo: true }
} }
export async function isGitAvailable(folder: string): Promise<boolean> {
const result = await runGit(["--version"], folder)
return result.ok || !isGitUnavailableResult(result)
}
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> { function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = [] const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
const lines = output.split(/\r?\n/) const lines = output.split(/\r?\n/)
@@ -90,15 +102,22 @@ export async function listWorktrees(params: {
logger?: LogLike logger?: LogLike
}): Promise<WorktreeDescriptor[]> { }): Promise<WorktreeDescriptor[]> {
const { repoRoot, workspaceFolder, logger } = params const { repoRoot, workspaceFolder, logger } = params
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder) const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
if (!result.ok) { if (!result.ok) {
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only") logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
return [rootDescriptor] return [rootDescriptor]
} }
const records = parseWorktreePorcelain(result.stdout) const records = parseWorktreePorcelain(result.stdout)
const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot))
const rootDescriptor: WorktreeDescriptor = {
slug: "root",
directory: repoRoot,
kind: "root",
branch: rootRecord?.branch,
}
const worktrees: WorktreeDescriptor[] = [rootDescriptor] const worktrees: WorktreeDescriptor[] = [rootDescriptor]
const seen = new Set<string>(["root"]) const seen = new Set<string>(["root"])

View File

@@ -0,0 +1,99 @@
import { realpath } from "fs/promises"
import type { LogLike } from "./git-worktrees"
import { listWorktrees, resolveRepoRoot } from "./git-worktrees"
type WorktreeCacheEntry = {
expiresAt: number
repoRoot: string
worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }>
}
const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()
async function normalizeDirectoryPath(directory: string): Promise<string> {
const trimmed = (directory ?? "").trim()
if (!trimmed) return ""
try {
return await realpath(trimmed)
} catch {
return trimmed
}
}
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger?: LogLike }) {
const cached = worktreeCache.get(params.workspaceId)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached
}
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
const entry: WorktreeCacheEntry = {
expiresAt: now + WORKTREE_CACHE_TTL_MS,
repoRoot,
worktrees: await Promise.all(
worktrees.map(async (wt) => ({
slug: wt.slug,
directory: wt.directory,
normalizedDirectory: await normalizeDirectoryPath(wt.directory),
})),
),
}
worktreeCache.set(params.workspaceId, entry)
return entry
}
export async function resolveWorktreeDirectory(params: {
workspaceId: string
workspacePath: string
worktreeSlug: string
logger?: LogLike
}): Promise<string | null> {
const cached = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
const match = cached.worktrees.find((wt) => wt.slug === params.worktreeSlug)
if (match) {
return match.directory
}
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
return refreshed.worktrees.find((wt) => wt.slug === params.worktreeSlug)?.directory ?? null
}
export async function resolveWorktreeSlugForDirectory(params: {
workspaceId: string
workspacePath: string
directory: string
logger?: LogLike
}): Promise<string | null> {
const target = await normalizeDirectoryPath(params.directory ?? "")
if (!target) return null
const cached = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
const match = cached.worktrees.find((wt) => wt.normalizedDirectory === target)
if (match) {
return match.slug
}
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({
workspaceId: params.workspaceId,
workspacePath: params.workspacePath,
logger: params.logger,
})
return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null
}

View File

@@ -458,7 +458,7 @@ dependencies = [
[[package]] [[package]]
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.13.3" version = "0.14.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"dirs 5.0.1", "dirs 5.0.1",

View File

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

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "codenomad-tauri" name = "codenomad-tauri"
version = "0.13.3" version = "0.14.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
@@ -28,4 +28,4 @@ url = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] } windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }

View File

@@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::env; use std::env;
#[cfg(windows)]
use std::ffi::c_void;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs; use std::fs;
use std::io::{BufRead, BufReader, Read, Write}; use std::io::{BufRead, BufReader, Read, Write};
#[cfg(windows)]
use std::mem::{size_of, zeroed};
use std::net::TcpStream; use std::net::TcpStream;
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
@@ -19,12 +23,95 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
#[cfg(windows)]
use std::os::windows::io::AsRawHandle;
#[cfg(windows)] #[cfg(windows)]
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
#[cfg(windows)]
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
#[cfg(windows)]
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
#[cfg(windows)] #[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000; const CREATE_NO_WINDOW: u32 = 0x08000000;
#[cfg(windows)]
#[derive(Debug)]
struct WindowsJobObject {
// The desktop wrapper may observe only a short-lived Node wrapper PID while the real
// server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives
// Tauri an OS-owned handle for the whole subtree instead of relying on a single PID.
handle: HANDLE,
}
#[cfg(windows)]
impl WindowsJobObject {
fn create() -> anyhow::Result<Self> {
let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
if handle.is_null() {
return Err(anyhow::anyhow!(
"CreateJobObjectW failed: {}",
std::io::Error::last_os_error()
));
}
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let ok = unsafe {
SetInformationJobObject(
handle,
JobObjectExtendedLimitInformation,
&mut info as *mut _ as *mut c_void,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if ok == 0 {
let err = std::io::Error::last_os_error();
unsafe {
CloseHandle(handle);
}
return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err));
}
Ok(Self { handle })
}
fn assign_child(&self, child: &Child) -> anyhow::Result<()> {
let process_handle = child.as_raw_handle() as HANDLE;
let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) };
if ok == 0 {
return Err(anyhow::anyhow!(
"AssignProcessToJobObject failed: {}",
std::io::Error::last_os_error()
));
}
Ok(())
}
}
#[cfg(windows)]
impl Drop for WindowsJobObject {
fn drop(&mut self) {
if !self.handle.is_null() {
unsafe {
CloseHandle(self.handle);
}
}
}
}
#[cfg(windows)]
unsafe impl Send for WindowsJobObject {}
#[cfg(windows)]
unsafe impl Sync for WindowsJobObject {}
fn log_line(message: &str) { fn log_line(message: &str) {
println!("[tauri-cli] {message}"); println!("[tauri-cli] {message}");
} }
@@ -363,6 +450,8 @@ impl Default for CliStatus {
pub struct CliProcessManager { pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>, status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>, child: Arc<Mutex<Option<Child>>>,
#[cfg(windows)]
job: Arc<Mutex<Option<WindowsJobObject>>>,
ready: Arc<AtomicBool>, ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>, bootstrap_token: Arc<Mutex<Option<String>>>,
} }
@@ -372,6 +461,8 @@ impl CliProcessManager {
Self { Self {
status: Arc::new(Mutex::new(CliStatus::default())), status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)), child: Arc::new(Mutex::new(None)),
#[cfg(windows)]
job: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)), ready: Arc::new(AtomicBool::new(false)),
bootstrap_token: Arc::new(Mutex::new(None)), bootstrap_token: Arc::new(Mutex::new(None)),
} }
@@ -394,6 +485,8 @@ impl CliProcessManager {
let status_arc = self.status.clone(); let status_arc = self.status.clone();
let child_arc = self.child.clone(); let child_arc = self.child.clone();
#[cfg(windows)]
let job_arc = self.job.clone();
let ready_flag = self.ready.clone(); let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone(); let token_arc = self.bootstrap_token.clone();
thread::spawn(move || { thread::spawn(move || {
@@ -401,6 +494,8 @@ impl CliProcessManager {
app.clone(), app.clone(),
status_arc.clone(), status_arc.clone(),
child_arc, child_arc,
#[cfg(windows)]
job_arc,
ready_flag, ready_flag,
token_arc, token_arc,
dev, dev,
@@ -420,11 +515,12 @@ impl CliProcessManager {
} }
pub fn stop(&self) -> anyhow::Result<()> { pub fn stop(&self) -> anyhow::Result<()> {
#[cfg(windows)]
let _job = self.job.lock().take();
let mut child_opt = self.child.lock(); let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() { if let Some(mut child) = child_opt.take() {
log_line(&format!("stopping CLI pid={}", child.id())); log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(windows)]
let mut forced_tree_shutdown = false;
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
let pid = child.id() as i32; let pid = child.id() as i32;
@@ -446,18 +542,16 @@ impl CliProcessManager {
Ok(Some(_)) => break, Ok(Some(_)) => break,
Ok(None) => { Ok(None) => {
#[cfg(windows)] #[cfg(windows)]
if !forced_tree_shutdown if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
{
log_line(&format!( log_line(&format!(
"regular Windows shutdown still running after {}ms; escalating pid={}", "regular Windows shutdown still running after {}ms; escalating pid={}",
CLI_WINDOWS_FORCE_GRACE_MS, CLI_WINDOWS_FORCE_GRACE_MS,
child.id() child.id()
)); ));
forced_tree_shutdown = true;
if !kill_process_tree_windows(child.id(), true) { if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill(); let _ = child.kill();
} }
break;
} }
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) { if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
@@ -476,11 +570,7 @@ impl CliProcessManager {
} }
#[cfg(windows)] #[cfg(windows)]
{ {
if !forced_tree_shutdown if !kill_process_tree_windows(child.id(), true) {
&& !kill_process_tree_windows(child.id(), true)
{
let _ = child.kill();
} else if forced_tree_shutdown {
let _ = child.kill(); let _ = child.kill();
} }
} }
@@ -491,6 +581,9 @@ impl CliProcessManager {
Err(_) => break, Err(_) => break,
} }
} }
} else {
#[cfg(windows)]
log_line("tracked CLI process already exited; dropping Windows job object to reap descendants");
} }
let mut status = self.status.lock(); let mut status = self.status.lock();
@@ -511,6 +604,7 @@ impl CliProcessManager {
app: AppHandle, app: AppHandle,
status: Arc<Mutex<CliStatus>>, status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>, child_holder: Arc<Mutex<Option<Child>>>,
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
ready: Arc<AtomicBool>, ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>, bootstrap_token: Arc<Mutex<Option<String>>>,
dev: bool, dev: bool,
@@ -592,6 +686,22 @@ impl CliProcessManager {
let pid = child.id(); let pid = child.id();
log_line(&format!("spawned pid={pid}")); log_line(&format!("spawned pid={pid}"));
#[cfg(windows)]
match WindowsJobObject::create().and_then(|job| {
job.assign_child(&child)?;
Ok(job)
}) {
Ok(job) => {
log_line(&format!("attached pid={pid} to Windows job object"));
*job_holder.lock() = Some(job);
}
Err(err) => {
log_line(&format!(
"failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}"
));
}
}
{ {
let mut locked = status.lock(); let mut locked = status.lock();
locked.pid = Some(pid); locked.pid = Some(pid);
@@ -665,6 +775,8 @@ impl CliProcessManager {
let status_clone = status.clone(); let status_clone = status.clone();
let ready_clone = ready.clone(); let ready_clone = ready.clone();
let child_holder_clone = child_holder.clone(); let child_holder_clone = child_holder.clone();
#[cfg(windows)]
let job_holder_clone = job_holder.clone();
thread::spawn(move || { thread::spawn(move || {
let timeout = Duration::from_secs(60); let timeout = Duration::from_secs(60);
thread::sleep(timeout); thread::sleep(timeout);
@@ -719,6 +831,10 @@ impl CliProcessManager {
// Drop the handle after the process exits so other callers // Drop the handle after the process exits so other callers
// don't attempt to stop/kill a finished process. // don't attempt to stop/kill a finished process.
*guard = None; *guard = None;
#[cfg(windows)]
{
let _ = job_holder_clone.lock().take();
}
Some(status) Some(status)
} }
None => None, None => None,
@@ -776,7 +892,8 @@ impl CliProcessManager {
auth_cookie_name: &str, auth_cookie_name: &str,
) { ) {
let mut buffer = String::new(); let mut buffer = String::new();
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok(); let local_url_regex =
Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:"; let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
loop { loop {
@@ -818,7 +935,6 @@ impl CliProcessManager {
); );
continue; continue;
} }
} }
} }
Err(_) => break, Err(_) => break,
@@ -963,6 +1079,7 @@ impl CliEntry {
"--auth-cookie-name".to_string(), "--auth-cookie-name".to_string(),
auth_cookie_name.to_string(), auth_cookie_name.to_string(),
"--generate-token".to_string(), "--generate-token".to_string(),
"--unrestricted-root".to_string(),
]; ];
if dev { if dev {
@@ -1021,15 +1138,23 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
let cwd = std::env::current_dir().ok(); let cwd = std::env::current_dir().ok();
let workspace = workspace_root(); let workspace = workspace_root();
let mut candidates = vec![ let mut candidates = vec![
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")), cwd.as_ref()
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")), .map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
cwd.as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")), cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")), cwd.as_ref()
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")), .map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")), cwd.as_ref()
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")), .map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")), cwd.as_ref()
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")), .map(|p| p.join("../node_modules/tsx/dist/cli.js")),
cwd.as_ref()
.map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
cwd.as_ref()
.map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
cwd.as_ref()
.map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
workspace workspace
.as_ref() .as_ref()
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")), .map(|p| p.join("node_modules/tsx/dist/cli.mjs")),

View File

@@ -13,7 +13,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview; use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry}; use tauri::{
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
};
use tauri_plugin_global_shortcut::{ use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState, Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
}; };
@@ -31,7 +33,7 @@ use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
const DEFAULT_ZOOM_LEVEL: f64 = 1.0; const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.2; const ZOOM_STEP: f64 = 0.1;
const MIN_ZOOM_LEVEL: f64 = 0.2; const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0; const MAX_ZOOM_LEVEL: f64 = 5.0;
@@ -129,7 +131,11 @@ fn should_allow_internal(url: &Url) -> bool {
} }
} }
fn should_allow_window_origin<R: Runtime>(app_handle: &AppHandle<R>, window_label: &str, url: &Url) -> bool { fn should_allow_window_origin<R: Runtime>(
app_handle: &AppHandle<R>,
window_label: &str,
url: &Url,
) -> bool {
if should_allow_internal(url) { if should_allow_internal(url) {
return true; return true;
} }
@@ -172,7 +178,11 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?; let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
let label = format!("remote-{}", payload.id); let label = format!("remote-{}", payload.id);
let title = format!("{} - {}", payload.name, parsed.host_str().unwrap_or(payload.base_url.as_str())); let title = format!(
"{} - {}",
payload.name,
parsed.host_str().unwrap_or(payload.base_url.as_str())
);
if let Some(existing) = app.get_webview_window(&label) { if let Some(existing) = app.get_webview_window(&label) {
let _ = existing.navigate(parsed.clone()); let _ = existing.navigate(parsed.clone());
@@ -189,12 +199,13 @@ fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<()
.map_err(|err| err.to_string())? .map_err(|err| err.to_string())?
.insert(label.clone(), parsed.origin().ascii_serialization()); .insert(label.clone(), parsed.origin().ascii_serialization());
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone())) let window =
.title(title) WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
.inner_size(1400.0, 900.0) .title(title)
.min_inner_size(800.0, 600.0) .inner_size(1400.0, 900.0)
.build() .min_inner_size(800.0, 600.0)
.map_err(|err| err.to_string())?; .build()
.map_err(|err| err.to_string())?;
let app_handle = app.clone(); let app_handle = app.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad", "productName": "CodeNomad",
"version": "0.13.3", "version": "0.14.0",
"identifier": "ai.neuralnomads.codenomad.client", "identifier": "ai.neuralnomads.codenomad.client",
"build": { "build": {
"beforeDevCommand": "npm run dev:bootstrap", "beforeDevCommand": "npm run dev:bootstrap",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.13.3", "version": "0.14.0",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -1,18 +1,22 @@
import { createEffect, createSignal, onCleanup, onMount } from "solid-js" import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { loadMonaco } from "../../lib/monaco/setup" import { loadMonaco } from "../../lib/monaco/setup"
import { getOrCreateTextModel } from "../../lib/monaco/model-cache" import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
import { inferMonacoLanguageId } from "../../lib/monaco/language" import { inferMonacoLanguageId } from "../../lib/monaco/language"
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup" import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
import { useTheme } from "../../lib/theme" import { useTheme } from "../../lib/theme"
import { parsePatchToBeforeAfter } from "../../lib/diff-utils"
interface MonacoDiffViewerProps { interface MonacoDiffViewerProps {
scopeKey: string scopeKey: string
path: string path: string
before: string patch?: string
after: string before?: string
after?: string
viewMode?: "split" | "unified" viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed" contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off" wordWrap?: "on" | "off"
onRequestInsertContext?: (selection: { startLine: number; endLine: number }) => void
insertContextLabel?: string
} }
export function MonacoDiffViewer(props: MonacoDiffViewerProps) { export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
@@ -22,6 +26,20 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
let diffEditor: any = null let diffEditor: any = null
let monaco: any = null let monaco: any = null
const [ready, setReady] = createSignal(false) const [ready, setReady] = createSignal(false)
const [hoveredLine, setHoveredLine] = createSignal<number | null>(null)
const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null)
const [widgetHovered, setWidgetHovered] = createSignal(false)
const [widgetPosition, setWidgetPosition] = createSignal<{ top: number; left: number } | null>(null)
const resolvedContent = createMemo(() => {
if (props.patch !== undefined && props.patch !== null) {
return parsePatchToBeforeAfter(props.patch)
}
return {
before: props.before ?? "",
after: props.after ?? "",
}
})
const disposeEditor = () => { const disposeEditor = () => {
try { try {
@@ -37,6 +55,52 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
diffEditor = null diffEditor = null
} }
const getModifiedEditor = () => diffEditor?.getModifiedEditor?.() ?? null
const getActiveInsertRange = () => {
const selection = selectedRange()
if (selection) return selection
if (widgetHovered() && hoveredLine()) {
return { startLine: hoveredLine() as number, endLine: hoveredLine() as number }
}
const line = hoveredLine()
if (!line) return null
return { startLine: line, endLine: line }
}
const layoutInsertWidget = () => {
const modifiedEditor = getModifiedEditor()
const container = host
if (!modifiedEditor || !container) return
const activeRange = getActiveInsertRange()
if (!activeRange) {
setWidgetPosition(null)
return
}
try {
const modifiedDom = modifiedEditor.getDomNode?.() as HTMLElement | null
if (!modifiedDom) {
setWidgetPosition(null)
return
}
const margin = modifiedDom.querySelector<HTMLElement>(".margin")
const scrollable = modifiedDom.querySelector<HTMLElement>(".monaco-scrollable-element.editor-scrollable")
const lineTop = modifiedEditor.getTopForLineNumber?.(activeRange.startLine) ?? 0
const scrollTop = modifiedEditor.getScrollTop?.() ?? 0
const lineHeight = Number(modifiedEditor.getOption?.(monaco.editor.EditorOption.lineHeight) ?? 18)
const modifiedRect = modifiedDom.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
const seamLeft = modifiedRect.left - containerRect.left + (margin?.offsetWidth ?? scrollable?.offsetLeft ?? 0)
const centerTop = modifiedRect.top - containerRect.top + (lineTop - scrollTop) + lineHeight / 2
setWidgetPosition({ top: centerTop, left: seamLeft })
} catch {
setWidgetPosition(null)
}
}
onMount(() => { onMount(() => {
let cancelled = false let cancelled = false
void (async () => { void (async () => {
@@ -56,7 +120,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
renderWhitespace: "selection", renderWhitespace: "selection",
fontSize: 13, fontSize: 13,
wordWrap: props.wordWrap === "on" ? "on" : "off", wordWrap: props.wordWrap === "on" ? "on" : "off",
glyphMargin: false, glyphMargin: true,
folding: false, folding: false,
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers. // Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
lineNumbersMinChars: 4, lineNumbersMinChars: 4,
@@ -69,6 +133,8 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
}) })
setReady(true) setReady(true)
layoutInsertWidget()
})() })()
onCleanup(() => { onCleanup(() => {
@@ -83,6 +149,74 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs") monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
}) })
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const modifiedEditor = diffEditor.getModifiedEditor?.()
if (!modifiedEditor?.onDidChangeCursorSelection) return
const disposable = modifiedEditor.onDidChangeCursorSelection((event: any) => {
const selection = event?.selection
if (!selection || selection.isEmpty?.()) {
setSelectedRange(null)
layoutInsertWidget()
return
}
setSelectedRange({
startLine: Math.min(selection.startLineNumber, selection.endLineNumber),
endLine: Math.max(selection.startLineNumber, selection.endLineNumber),
})
layoutInsertWidget()
})
onCleanup(() => {
try {
disposable?.dispose?.()
} catch {
// ignore
}
})
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const modifiedEditor = getModifiedEditor()
if (!modifiedEditor?.onMouseMove || !modifiedEditor?.onMouseLeave || !modifiedEditor?.onMouseDown) return
const moveDisposable = modifiedEditor.onMouseMove((event: any) => {
const lineNumber = event?.target?.position?.lineNumber
setHoveredLine(typeof lineNumber === "number" ? lineNumber : null)
layoutInsertWidget()
})
const leaveDisposable = modifiedEditor.onMouseLeave(() => {
if (!widgetHovered()) {
setHoveredLine(null)
}
layoutInsertWidget()
})
const scrollDisposable = modifiedEditor.onDidScrollChange?.(() => {
layoutInsertWidget()
})
onCleanup(() => {
try {
moveDisposable?.dispose?.()
leaveDisposable?.dispose?.()
scrollDisposable?.dispose?.()
} catch {
// ignore
}
})
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const activeRange = getActiveInsertRange()
if (!activeRange) setWidgetPosition(null)
layoutInsertWidget()
})
createEffect(() => { createEffect(() => {
if (!ready() || !monaco || !diffEditor) return if (!ready() || !monaco || !diffEditor) return
const viewMode = props.viewMode === "unified" ? "unified" : "split" const viewMode = props.viewMode === "unified" ? "unified" : "split"
@@ -115,11 +249,12 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
createEffect(() => { createEffect(() => {
if (!ready() || !monaco || !diffEditor) return if (!ready() || !monaco || !diffEditor) return
const languageId = inferMonacoLanguageId(monaco, props.path) const languageId = inferMonacoLanguageId(monaco, props.path)
const { before, after } = resolvedContent()
const beforeKey = `${props.scopeKey}:diff:${props.path}:before` const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
const afterKey = `${props.scopeKey}:diff:${props.path}:after` const afterKey = `${props.scopeKey}:diff:${props.path}:after`
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId }) const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: before, languageId })
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId }) const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: after, languageId })
diffEditor.setModel({ original, modified }) diffEditor.setModel({ original, modified })
void ensureMonacoLanguageLoaded(languageId).then(() => { void ensureMonacoLanguageLoaded(languageId).then(() => {
@@ -132,5 +267,46 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
}) })
}) })
return <div class="monaco-viewer" ref={host} /> return (
<div class="monaco-viewer" ref={host}>
<div class="git-change-context-overlay">
<Show when={widgetPosition()}>
{(position: () => { top: number; left: number }) => (
<div
class="git-change-context-widget-host"
style={{ top: `${position().top}px`, left: `${position().left}px` }}
onMouseEnter={() => {
setWidgetHovered(true)
layoutInsertWidget()
}}
onMouseLeave={() => {
setWidgetHovered(false)
layoutInsertWidget()
}}
>
<button
type="button"
class="git-change-context-widget"
aria-label={props.insertContextLabel ?? "Add git change context to prompt"}
title={props.insertContextLabel ?? "Add git change context to prompt"}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
const activeRange = getActiveInsertRange()
if (!activeRange) return
props.onRequestInsertContext?.(activeRange)
}}
>
+
</button>
</div>
)}
</Show>
</div>
</div>
)
} }

View File

@@ -43,6 +43,7 @@ import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome" import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status" import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
import { Maximize2, ShieldAlert } from "lucide-solid" import { Maximize2, ShieldAlert } from "lucide-solid"
import type { PromptInputApi } from "../prompt-input/types"
import type { LayoutMode } from "./shell/types" import type { LayoutMode } from "./shell/types"
import { import {
@@ -105,6 +106,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
const [now, setNow] = createSignal(Date.now()) const [now, setNow] = createSignal(Date.now())
const [sessionPromptApis, setSessionPromptApis] = createSignal<Record<string, PromptInputApi | null>>({})
// Worktree selector manages its own dialogs. // Worktree selector manages its own dialogs.
const [showSessionSearch, setShowSessionSearch] = createSignal(false) const [showSessionSearch, setShowSessionSearch] = createSignal(false)
@@ -268,6 +270,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id)) const permissionQueue = createMemo(() => getPermissionQueue(props.instance.id))
const activePromptInputApi = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
return sessionPromptApis()[sessionId] ?? null
})
const registerSessionPromptApi = (sessionId: string, api: PromptInputApi | null) => {
setSessionPromptApis((current) => ({
...current,
[sessionId]: api,
}))
}
createEffect(() => { createEffect(() => {
getPermissionAutoAcceptInFlightVersion() getPermissionAutoAcceptInFlightVersion()
@@ -594,6 +609,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onCloseRightDrawer={closeRightDrawer} onCloseRightDrawer={closeRightDrawer}
onPinRightDrawer={pinRightDrawer} onPinRightDrawer={pinRightDrawer}
onUnpinRightDrawer={unpinRightDrawer} onUnpinRightDrawer={unpinRightDrawer}
promptInputApi={activePromptInputApi}
setContentEl={setRightDrawerContentEl} setContentEl={setRightDrawerContentEl}
/> />
</Box> </Box>
@@ -656,6 +672,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
onCloseRightDrawer={closeRightDrawer} onCloseRightDrawer={closeRightDrawer}
onPinRightDrawer={pinRightDrawer} onPinRightDrawer={pinRightDrawer}
onUnpinRightDrawer={unpinRightDrawer} onUnpinRightDrawer={unpinRightDrawer}
promptInputApi={activePromptInputApi}
setContentEl={setRightDrawerContentEl} setContentEl={setRightDrawerContentEl}
/> />
</Drawer> </Drawer>
@@ -892,6 +909,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
escapeInDebounce={props.escapeInDebounce} escapeInDebounce={props.escapeInDebounce}
isPhoneLayout={isPhoneLayout()} isPhoneLayout={isPhoneLayout()}
compactPromptLayout={compactPromptLayout()} compactPromptLayout={compactPromptLayout()}
registerSessionPromptApi={registerSessionPromptApi}
showSidebarToggle={showEmbeddedSidebarToggle()} showSidebarToggle={showEmbeddedSidebarToggle()}
onSidebarToggle={() => setLeftOpen(true)} onSidebarToggle={() => setLeftOpen(true)}
forceCompactStatusLayout={showEmbeddedSidebarToggle()} forceCompactStatusLayout={showEmbeddedSidebarToggle()}

View File

@@ -10,7 +10,7 @@ import {
type Component, type Component,
} from "solid-js" } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client"
import IconButton from "@suid/material/IconButton" import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen" import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin" import PushPinIcon from "@suid/icons-material/PushPin"
@@ -19,16 +19,23 @@ import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
import type { Instance } from "../../../../types/instance" import type { Instance } from "../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../server/src/api-types" import type { BackgroundProcess } from "../../../../../../server/src/api-types"
import type { Session } from "../../../../types/session" import type { Session } from "../../../../types/session"
import type { PromptInputApi } from "../../../prompt-input/types"
import type { DrawerViewState } from "../types" import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees" import {
getDefaultWorktreeSlug,
getGitRepoStatus,
getOrCreateWorktreeClient,
getWorktreeSlugForSession,
getWorktrees,
} from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api" import { requestData } from "../../../../lib/opencode-api"
import { serverApi } from "../../../../lib/api-client" import { serverApi } from "../../../../lib/api-client"
import { showConfirmDialog } from "../../../../stores/alerts" import { showConfirmDialog } from "../../../../stores/alerts"
import { showToastNotification } from "../../../../lib/notifications" import { showToastNotification } from "../../../../lib/notifications"
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
import { useGlobalPointerDrag } from "../useGlobalPointerDrag" import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import { useGitChanges } from "./useGitChanges"
import { import {
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
@@ -41,7 +48,11 @@ import {
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY, RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY, RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY,
RIGHT_PANEL_TAB_STORAGE_KEY, RIGHT_PANEL_TAB_STORAGE_KEY,
readStoredBool, readStoredBool,
readStoredEnum, readStoredEnum,
@@ -82,6 +93,7 @@ interface RightPanelProps {
onCloseRightDrawer: () => void onCloseRightDrawer: () => void
onPinRightDrawer: () => void onPinRightDrawer: () => void
onUnpinRightDrawer: () => void onUnpinRightDrawer: () => void
promptInputApi: Accessor<PromptInputApi | null>
setContentEl: (el: HTMLElement | null) => void setContentEl: (el: HTMLElement | null) => void
} }
@@ -133,6 +145,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [changesListTouched, setChangesListTouched] = createSignal(false) const [changesListTouched, setChangesListTouched] = createSignal(false)
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true) const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false) const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
const [gitStagedOpen, setGitStagedOpen] = createSignal(true)
const [gitUnstagedOpen, setGitUnstagedOpen] = createSignal(true)
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone")) const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
@@ -149,11 +163,28 @@ const RightPanel: Component<RightPanelProps> = (props) => {
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
} }
const gitSectionStorageKey = (section: "staged" | "unstaged") => {
const layout = listLayoutKey()
if (section === "staged") {
return layout === "phone"
? RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY
: RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY
}
return layout === "phone"
? RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY
: RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY
}
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => { const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false") window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
} }
const persistGitSectionOpen = (section: "staged" | "unstaged", value: boolean) => {
if (typeof window === "undefined") return
window.localStorage.setItem(gitSectionStorageKey(section), value ? "true" : "false")
}
createEffect(() => { createEffect(() => {
// Refresh persisted visibility when layout changes (phone vs non-phone). // Refresh persisted visibility when layout changes (phone vs non-phone).
const layout = listLayoutKey() const layout = listLayoutKey()
@@ -185,6 +216,12 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setGitChangesListOpen(true) setGitChangesListOpen(true)
setGitChangesListTouched(false) setGitChangesListTouched(false)
} }
const stagedPersisted = readStoredBool(gitSectionStorageKey("staged"))
setGitStagedOpen(stagedPersisted ?? true)
const unstagedPersisted = readStoredBool(gitSectionStorageKey("unstaged"))
setGitUnstagedOpen(unstagedPersisted ?? true)
}) })
createEffect(() => { createEffect(() => {
@@ -339,34 +376,56 @@ const RightPanel: Component<RightPanelProps> = (props) => {
return getDefaultWorktreeSlug(props.instanceId) return getDefaultWorktreeSlug(props.instanceId)
}) })
const gitChangesWorktreeSlug = createMemo(() => {
if (getGitRepoStatus(props.instanceId) === false) return null
const slug = worktreeSlugForViewer().trim()
return slug ? slug : null
})
const gitChangesWorktree = createMemo(() => {
const slug = gitChangesWorktreeSlug()
if (!slug) return null
return getWorktrees(props.instanceId).find((worktree) => worktree.slug === slug) ?? null
})
const gitChangesBranchLabel = createMemo(() => {
const branch = gitChangesWorktree()?.branch?.trim()
return branch || null
})
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer())) const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null) const {
const [gitStatusLoading, setGitStatusLoading] = createSignal(false) gitStatusEntries,
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null) gitStatusLoading,
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null) gitStatusError,
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false) gitSelectedItemId,
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null) gitBulkSelectedItemIds,
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null) gitSelectedLoading,
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null) gitSelectedError,
gitSelectedBefore,
const gitMostChangedPath = createMemo<string | null>(() => { gitSelectedAfter,
const entries = gitStatusEntries() gitCommitMessage,
if (!Array.isArray(entries) || entries.length === 0) return null gitCommitSubmitting,
const candidates = entries.filter((item) => item && item.status !== "deleted") gitMostChangedItemId,
if (candidates.length === 0) return null setGitCommitMessage,
const best = candidates.reduce((currentBest, item) => { handleGitRowClick,
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0) refreshGitStatus,
const score = (item?.added ?? 0) + (item?.removed ?? 0) insertGitChangeContext,
if (score > bestScore) return item submitGitCommit,
if (score < bestScore) return currentBest stageGitFile,
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest unstageGitFile,
}, candidates[0]) } = useGitChanges({
return typeof best?.path === "string" ? best.path : null t: props.t,
instanceId: props.instanceId,
rightPanelTab,
worktreeSlug: worktreeSlugForViewer,
isPhoneLayout: props.isPhoneLayout,
promptInputApi: props.promptInputApi,
closeGitList: () => setGitChangesListOpen(false),
}) })
createEffect(() => { createEffect(() => {
// Reset tab state when worktree context changes.
worktreeSlugForViewer() worktreeSlugForViewer()
setBrowserPath(".") setBrowserPath(".")
setBrowserEntries(null) setBrowserEntries(null)
@@ -375,111 +434,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedContent(null) setBrowserSelectedContent(null)
setBrowserSelectedError(null) setBrowserSelectedError(null)
setBrowserSelectedLoading(false) setBrowserSelectedLoading(false)
setGitStatusEntries(null)
setGitStatusError(null)
setGitStatusLoading(false)
setGitSelectedPath(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
}) })
const loadGitStatus = async (force = false) => {
if (!force && gitStatusEntries() !== null) return
setGitStatusLoading(true)
setGitStatusError(null)
try {
const list = await requestData<GitFileStatus[]>(browserClient().file.status(), "file.status")
setGitStatusEntries(Array.isArray(list) ? list : [])
} catch (error) {
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
setGitStatusEntries([])
} finally {
setGitStatusLoading(false)
}
}
async function openGitFile(path: string) {
setGitSelectedPath(path)
setGitSelectedLoading(true)
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
const list = gitStatusEntries() || []
const entry = list.find((item) => item.path === path) || null
if (entry?.status === "deleted") {
setGitSelectedError("Deleted file diff is not available yet")
setGitSelectedLoading(false)
return
}
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
setGitChangesListOpen(false)
}
try {
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null
if (afterText === null) {
throw new Error("Unsupported file type")
}
setGitSelectedAfter(afterText)
if (entry?.status === "added") {
setGitSelectedBefore("")
return
}
const diffText =
typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0
? String((content as any).diff)
: (content as any)?.patch
? buildUnifiedDiffFromSdkPatch((content as any).patch)
: ""
const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText)
if (beforeText === null) {
throw new Error("Unable to calculate diff for this file")
}
setGitSelectedBefore(beforeText)
} catch (error) {
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
} finally {
setGitSelectedLoading(false)
}
}
createEffect(() => {
if (rightPanelTab() !== "git-changes") return
const entries = gitStatusEntries()
if (entries === null) return
if (gitSelectedPath()) return
const next = gitMostChangedPath()
if (!next) return
void openGitFile(next)
})
const refreshGitStatus = async () => {
await loadGitStatus(true)
const selected = gitSelectedPath()
if (selected) {
void openGitFile(selected)
}
}
const bestDiffFile = createMemo<string | null>(() => { const bestDiffFile = createMemo<string | null>(() => {
const diffs = props.activeSessionDiffs() const diffs = props.activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return null if (!Array.isArray(diffs) || diffs.length === 0) return null
@@ -680,21 +636,6 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedDirty(false) setBrowserSelectedDirty(false)
}) })
createEffect(() => {
if (rightPanelTab() !== "git-changes") return
if (gitStatusLoading()) return
if (gitStatusEntries() !== null) return
void loadGitStatus()
})
createEffect(() => {
if (rightPanelTab() === "git-changes") return
setGitSelectedBefore(null)
setGitSelectedAfter(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
})
const handleSelectChangesFile = (file: string, closeList: boolean) => { const handleSelectChangesFile = (file: string, closeList: boolean) => {
setSelectedFile(file) setSelectedFile(file)
if (closeList) { if (closeList) {
@@ -911,12 +852,13 @@ const RightPanel: Component<RightPanelProps> = (props) => {
entries={gitStatusEntries} entries={gitStatusEntries}
statusLoading={gitStatusLoading} statusLoading={gitStatusLoading}
statusError={gitStatusError} statusError={gitStatusError}
selectedPath={gitSelectedPath} selectedItemId={gitSelectedItemId}
selectedBulkItemIds={gitBulkSelectedItemIds}
selectedLoading={gitSelectedLoading} selectedLoading={gitSelectedLoading}
selectedError={gitSelectedError} selectedError={gitSelectedError}
selectedBefore={gitSelectedBefore} selectedBefore={gitSelectedBefore}
selectedAfter={gitSelectedAfter} selectedAfter={gitSelectedAfter}
mostChangedPath={gitMostChangedPath} mostChangedItemId={gitMostChangedItemId}
scopeKey={gitScopeKey} scopeKey={gitScopeKey}
diffViewMode={diffViewMode} diffViewMode={diffViewMode}
diffContextMode={diffContextMode} diffContextMode={diffContextMode}
@@ -924,8 +866,28 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onViewModeChange={setDiffViewMode} onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode} onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode} onWordWrapModeChange={setDiffWordWrapMode}
onOpenFile={(path: string) => void openGitFile(path)} onRowClick={handleGitRowClick}
onRefresh={() => void refreshGitStatus()} onRefresh={() => void refreshGitStatus()}
onInsertContext={insertGitChangeContext}
onStageFile={stageGitFile}
onUnstageFile={unstageGitFile}
commitMessage={gitCommitMessage}
commitSubmitting={gitCommitSubmitting}
onCommitMessageInput={setGitCommitMessage}
onSubmitCommit={() => void submitGitCommit()}
branchLabel={gitChangesBranchLabel}
stagedOpen={gitStagedOpen}
unstagedOpen={gitUnstagedOpen}
onToggleStagedOpen={() => {
const next = !gitStagedOpen()
setGitStagedOpen(next)
persistGitSectionOpen("staged", next)
}}
onToggleUnstagedOpen={() => {
const next = !gitUnstagedOpen()
setGitUnstagedOpen(next)
persistGitSectionOpen("unstaged", next)
}}
listOpen={gitChangesListOpen} listOpen={gitChangesListOpen}
onToggleList={toggleGitList} onToggleList={toggleGitList}
splitWidth={gitChangesSplitWidth} splitWidth={gitChangesSplitWidth}

View File

@@ -0,0 +1,148 @@
import type { File as SdkGitFileStatus } from "@opencode-ai/sdk/v2/client"
import type { WorktreeGitStatusEntry } from "../../../../../../server/src/api-types"
import type { GitChangeEntry, GitChangeListItem, GitChangeSection, GitChangeStatus } from "./types"
function normalizeGitChangePath(path: unknown): string {
if (typeof path !== "string") return ""
const normalized = path.replace(/\\+/g, "/").replace(/^\.\//, "").trim()
return normalized
}
export function normalizeGitChangeStatus(status: unknown): GitChangeStatus {
return typeof status === "string" && status.trim().length > 0 ? status : "modified"
}
export function adaptSdkGitStatusEntry(entry: SdkGitFileStatus): GitChangeEntry {
return {
path: normalizeGitChangePath(entry?.path),
originalPath: null,
additions: typeof entry?.added === "number" ? entry.added : 0,
deletions: typeof entry?.removed === "number" ? entry.removed : 0,
status: normalizeGitChangeStatus(entry?.status),
}
}
export function adaptSdkGitStatusEntries(
entries: SdkGitFileStatus[] | null | undefined,
details?: WorktreeGitStatusEntry[] | null,
): GitChangeEntry[] {
const detailsByPath = new Map(
(details ?? [])
.map((entry) => {
const path = normalizeGitChangePath(entry.path)
return path ? [{ ...entry, path }, path] : null
})
.filter((entry): entry is [WorktreeGitStatusEntry, string] => Boolean(entry))
.map(([entry, path]) => [path, entry] as const),
)
const adaptedByPath = new Map<string, GitChangeEntry>()
for (const entry of entries ?? []) {
const adapted = adaptSdkGitStatusEntry(entry)
if (!adapted.path) continue
const detail = detailsByPath.get(adapted.path)
adaptedByPath.set(adapted.path, {
...adapted,
originalPath: detail?.originalPath ? normalizeGitChangePath(detail.originalPath) : adapted.originalPath ?? null,
stagedStatus: detail?.stagedStatus ?? null,
unstagedStatus: detail?.unstagedStatus ?? null,
stagedAdditions: detail?.stagedAdditions ?? 0,
stagedDeletions: detail?.stagedDeletions ?? 0,
unstagedAdditions: detail?.unstagedAdditions ?? 0,
unstagedDeletions: detail?.unstagedDeletions ?? 0,
})
}
for (const detail of details ?? []) {
const normalizedPath = normalizeGitChangePath(detail.path)
if (!normalizedPath || adaptedByPath.has(normalizedPath)) continue
adaptedByPath.set(normalizedPath, {
path: normalizedPath,
originalPath: detail.originalPath ? normalizeGitChangePath(detail.originalPath) : null,
additions: 0,
deletions: 0,
status: detail.unstagedStatus ?? detail.stagedStatus ?? "modified",
stagedStatus: detail.stagedStatus,
unstagedStatus: detail.unstagedStatus,
stagedAdditions: detail.stagedAdditions,
stagedDeletions: detail.stagedDeletions,
unstagedAdditions: detail.unstagedAdditions,
unstagedDeletions: detail.unstagedDeletions,
})
}
return Array.from(adaptedByPath.values()).filter((entry) => entry.path.length > 0)
}
function buildGitChangeListItemId(section: GitChangeSection, path: string): string {
return `${section}:${path}`
}
function splitGitChangePath(path: string) {
const normalized = normalizeGitChangePath(path)
const lastSlash = normalized.lastIndexOf("/")
if (lastSlash === -1) {
return { displayName: normalized, parentPath: "" }
}
return {
displayName: normalized.slice(lastSlash + 1),
parentPath: normalized.slice(0, lastSlash),
}
}
export function buildGitChangeListItems(entries: GitChangeEntry[] | null | undefined): GitChangeListItem[] {
if (!Array.isArray(entries)) return []
const items: GitChangeListItem[] = []
for (const entry of entries) {
const pathParts = splitGitChangePath(entry.path)
if (entry.stagedStatus) {
items.push({
id: buildGitChangeListItemId("staged", entry.path),
path: entry.path,
originalPath: entry.originalPath ?? null,
section: "staged",
status: entry.stagedStatus,
additions: entry.stagedAdditions ?? 0,
deletions: entry.stagedDeletions ?? 0,
entry,
displayName: pathParts.displayName,
parentPath: pathParts.parentPath,
})
}
if (entry.unstagedStatus) {
items.push({
id: buildGitChangeListItemId("unstaged", entry.path),
path: entry.path,
originalPath: entry.originalPath ?? null,
section: "unstaged",
status: entry.unstagedStatus,
additions: entry.unstagedAdditions ?? entry.additions,
deletions: entry.unstagedDeletions ?? entry.deletions,
entry,
displayName: pathParts.displayName,
parentPath: pathParts.parentPath,
})
}
if (!entry.stagedStatus && !entry.unstagedStatus) {
items.push({
id: buildGitChangeListItemId("unstaged", entry.path),
path: entry.path,
originalPath: entry.originalPath ?? null,
section: "unstaged",
status: entry.status,
additions: entry.additions,
deletions: entry.deletions,
entry,
displayName: pathParts.displayName,
parentPath: pathParts.parentPath,
})
}
}
return items.sort((a, b) => {
if (a.section !== b.section) return a.section.localeCompare(b.section)
return a.path.localeCompare(b.path)
})
}

View File

@@ -115,23 +115,22 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<Suspense <Suspense
fallback={ fallback={
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span> <span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div> </div>
} }
> >
<LazyMonacoDiffViewer <LazyMonacoDiffViewer
scopeKey={scopeKey()} scopeKey={scopeKey()}
path={String(file().file || "")} path={String(file().file || "")}
before={String((file() as any).before || "")} patch={String((file() as any).patch || "")}
after={String((file() as any).after || "")} viewMode={props.diffViewMode()}
viewMode={props.diffViewMode()} contextMode={props.diffContextMode()}
contextMode={props.diffContextMode()} wordWrap={props.diffWordWrapMode()}
wordWrap={props.diffWordWrapMode()} />
/> </Suspense>
</Suspense>
)} )}
</Show> </Show>
</div> </div>

View File

@@ -1,11 +1,20 @@
import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js" import {
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" For,
Show,
Suspense,
createMemo,
lazy,
type Accessor,
type Component,
type JSX,
} from "solid-js"
import { RefreshCw } from "lucide-solid" import { ChevronDown, ChevronRight, GitBranch, RefreshCw } from "lucide-solid"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, GitChangeEntry, GitChangeListItem } from "../types"
import { buildGitChangeListItems } from "../git-changes-model"
const LazyMonacoDiffViewer = lazy(() => const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })), import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
@@ -16,16 +25,17 @@ interface GitChangesTabProps {
activeSessionId: Accessor<string | null> activeSessionId: Accessor<string | null>
entries: Accessor<GitFileStatus[] | null> entries: Accessor<GitChangeEntry[] | null>
statusLoading: Accessor<boolean> statusLoading: Accessor<boolean>
statusError: Accessor<string | null> statusError: Accessor<string | null>
selectedPath: Accessor<string | null> selectedItemId: Accessor<string | null>
selectedBulkItemIds: Accessor<Set<string>>
selectedLoading: Accessor<boolean> selectedLoading: Accessor<boolean>
selectedError: Accessor<string | null> selectedError: Accessor<string | null>
selectedBefore: Accessor<string | null> selectedBefore: Accessor<string | null>
selectedAfter: Accessor<string | null> selectedAfter: Accessor<string | null>
mostChangedPath: Accessor<string | null> mostChangedItemId: Accessor<string | null>
scopeKey: Accessor<string> scopeKey: Accessor<string>
@@ -36,8 +46,21 @@ interface GitChangesTabProps {
onContextModeChange: (mode: DiffContextMode) => void onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void onWordWrapModeChange: (mode: DiffWordWrapMode) => void
onOpenFile: (path: string) => void onRowClick: (item: GitChangeListItem, event: MouseEvent) => void
onRefresh: () => void onRefresh: () => void
onInsertContext: (item: GitChangeListItem, selection: { startLine: number; endLine: number }) => void
onStageFile: (item: GitChangeListItem) => void
onUnstageFile: (item: GitChangeListItem) => void
commitMessage: Accessor<string>
commitSubmitting: Accessor<boolean>
onCommitMessageInput: (value: string) => void
onSubmitCommit: () => void
branchLabel: Accessor<string | null>
stagedOpen: Accessor<boolean>
unstagedOpen: Accessor<boolean>
onToggleStagedOpen: () => void
onToggleUnstagedOpen: () => void
listOpen: Accessor<boolean> listOpen: Accessor<boolean>
onToggleList: () => void onToggleList: () => void
@@ -52,48 +75,54 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info")) const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
const entries = createMemo(() => (hasSession() ? props.entries() : null)) const entries = createMemo(() => (hasSession() ? props.entries() : null))
const sorted = createMemo<GitFileStatus[]>(() => { const sorted = createMemo<GitChangeEntry[]>(() => {
const list = entries() const list = entries()
if (!Array.isArray(list)) return [] if (!Array.isArray(list)) return []
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
}) })
const listItems = createMemo<GitChangeListItem[]>(() => buildGitChangeListItems(sorted()))
const totals = createMemo(() => { const totals = createMemo(() => {
return sorted().reduce( return listItems().reduce(
(acc, item) => { (acc, item) => {
acc.additions += typeof item.added === "number" ? item.added : 0 acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.removed === "number" ? item.removed : 0 acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
return acc return acc
}, },
{ additions: 0, deletions: 0 }, { additions: 0, deletions: 0 },
) )
}) })
const stagedItems = createMemo(() => listItems().filter((item) => item.section === "staged"))
const unstagedItems = createMemo(() => listItems().filter((item) => item.section === "unstaged"))
const canCommit = createMemo(() => stagedItems().length > 0 && props.commitMessage().trim().length > 0 && !props.commitSubmitting())
const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted")) const selectedEntry = createMemo<GitChangeEntry | null>(() => {
const list = listItems()
const selectedEntry = createMemo<GitFileStatus | null>(() => { const selectedId = props.selectedItemId()
const list = sorted() const fallbackId = props.mostChangedItemId()
const selectedPath = props.selectedPath()
const fallbackPath = props.mostChangedPath()
const found = const found =
list.find((item) => item.path === selectedPath) || list.find((item) => item.id === selectedId) ||
(fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined) (fallbackId ? list.find((item) => item.id === fallbackId) : undefined)
return found ?? null return found?.entry ?? null
}) })
const emptyViewerMessage = createMemo(() => { const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected") if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
const currentEntries = entries() const currentEntries = entries()
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading") if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty") if (listItems().length === 0) return props.t("instanceShell.gitChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty") return props.t("instanceShell.filesShell.viewerEmpty")
}) })
const binaryViewerActive = createMemo(() => props.selectedError() === props.t("instanceShell.gitChanges.binaryViewer"))
const renderContent = (): JSX.Element => { const renderContent = (): JSX.Element => {
const totalsValue = totals() const totalsValue = totals()
const selected = selectedEntry() const selected = selectedEntry()
const sortedList = sorted() const allItems = listItems()
const nonDeletedList = nonDeleted() const stagedList = stagedItems()
const unstagedList = unstagedItems()
const renderViewer = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
@@ -109,7 +138,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
selected && selected &&
props.selectedBefore() !== null && props.selectedBefore() !== null &&
props.selectedAfter() !== null && props.selectedAfter() !== null &&
selected.status !== "deleted" true
? { ? {
path: selected.path, path: selected.path,
before: props.selectedBefore() as string, before: props.selectedBefore() as string,
@@ -139,6 +168,14 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()} wordWrap={props.diffWordWrapMode()}
insertContextLabel={props.t("instanceShell.gitChanges.actions.insertContext")}
onRequestInsertContext={binaryViewerActive() ? undefined : (selection) => {
const selectedId = props.selectedItemId()
if (!selectedId) return
const item = listItems().find((entry) => entry.id === selectedId)
if (!item) return
props.onInsertContext(item, selection)
}}
/> />
</Suspense> </Suspense>
)} )}
@@ -163,66 +200,149 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div> const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
const renderListPanel = () => ( const renderListItem = (item: GitChangeListItem) => {
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}> const isBulkSelected = createMemo(() => props.selectedBulkItemIds().has(item.id))
<For each={sortedList}> const actionLabel =
{(item) => ( item.section === "staged"
<div ? props.t("instanceShell.gitChanges.actions.unstage")
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`} : props.t("instanceShell.gitChanges.actions.stage")
onClick={() => {
props.onOpenFile(item.path) const triggerAction = () => {
}} if (item.section === "staged") props.onUnstageFile(item)
> else props.onStageFile(item)
<div class="file-list-item-content"> }
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span> return (
</div> <div
<div class="file-list-item-stats"> class={`file-list-item git-change-list-item ${props.selectedItemId() === item.id ? "file-list-item-active" : ""} ${isBulkSelected() ? "git-change-list-item-bulk-selected" : ""}`}
<Show when={item.status === "deleted"}> onMouseDown={(event) => {
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span> if (event.shiftKey || event.ctrlKey || event.metaKey) {
</Show> event.preventDefault()
<Show when={item.status !== "deleted"}> }
<> }}
<span class="file-list-item-additions">+{item.added}</span> onClick={(event) => props.onRowClick(item, event)}
<span class="file-list-item-deletions">-{item.removed}</span> title={item.path}
</> >
</Show> <div class="file-list-item-content" title={item.path}>
</div> <div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span>
</div>
<div class="git-change-list-item-right">
<div class="file-list-item-stats">
<span class="file-list-item-additions">+{item.additions}</span>
<span class="file-list-item-deletions">-{item.deletions}</span>
</div> </div>
</div> </div>
)} </div>
</For> <div class="git-change-list-item-actions-zone">
</Show> <div class="git-change-list-item-actions">
<button
type="button"
class="git-change-row-action"
title={actionLabel}
aria-label={actionLabel}
onClick={(event) => {
event.stopPropagation()
triggerAction()
}}
>
<span
class={`git-change-row-action-glyph ${item.section === "staged" ? "git-change-row-action-glyph-minus" : "git-change-row-action-glyph-plus"}`}
aria-hidden="true"
>
<span class="git-change-row-action-bar git-change-row-action-bar-horizontal" />
<Show when={item.section !== "staged"}>
<span class="git-change-row-action-bar git-change-row-action-bar-vertical" />
</Show>
</span>
</button>
</div>
</div>
</div>
)
}
const renderSection = (
title: string,
items: GitChangeListItem[],
isOpen: boolean,
onToggle: () => void,
) => (
<div class="git-change-section">
<button type="button" class="git-change-section-header" onClick={onToggle}>
<span class="git-change-section-header-main">
<span class="git-change-section-chevron">
{isOpen ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
</span>
<span class="git-change-section-title">{title}</span>
</span>
<span class="git-change-section-count">{items.length}</span>
</button>
<Show when={isOpen}>
<div class="git-change-section-items">
<For each={items}>{(item) => renderListItem(item)}</For>
</div>
</Show>
</div>
) )
const renderListOverlay = () => ( const renderGroupedList = () => (
<Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}> <Show when={allItems.length > 0} fallback={renderEmptyList()}>
<For each={sortedList}> <div class="git-change-sections">
{(item) => ( <div class="git-change-section">
<div <button type="button" class="git-change-section-header" onClick={props.onToggleStagedOpen}>
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`} <span class="git-change-section-header-main">
onClick={() => props.onOpenFile(item.path)} <span class="git-change-section-chevron">
title={item.path} {props.stagedOpen() ? <ChevronDown class="h-3.5 w-3.5" /> : <ChevronRight class="h-3.5 w-3.5" />}
> </span>
<div class="file-list-item-content"> <span class="git-change-section-title-row">
<div class="file-list-item-path" title={item.path}> <span class="git-change-section-title">{props.t("instanceShell.gitChanges.sections.staged")}</span>
<span class="file-path-text">{item.path}</span> <Show when={props.branchLabel()}>
</div> {(label) => (
<div class="file-list-item-stats"> <span class="status-indicator session-status-list worktree-indicator git-change-section-badge" title={`Branch: ${label()}`}>
<Show when={item.status === "deleted"}> <GitBranch class="w-3.5 h-3.5" aria-hidden="true" />
<span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span> <span class="worktree-indicator-label">{label()}</span>
</Show> </span>
<Show when={item.status !== "deleted"}> )}
<>
<span class="file-list-item-additions">+{item.added}</span>
<span class="file-list-item-deletions">-{item.removed}</span>
</>
</Show> </Show>
</span>
</span>
<span class="git-change-section-count">{stagedList.length}</span>
</button>
<Show when={props.stagedOpen()}>
<div class="git-change-section-items">
<div class="git-change-commit-box">
<div class="git-change-commit-input-wrap">
<textarea
class="git-change-commit-input"
value={props.commitMessage()}
rows={1}
placeholder={props.t("instanceShell.gitChanges.commit.placeholder")}
onInput={(event) => props.onCommitMessageInput(event.currentTarget.value)}
/>
<button
type="button"
class="git-change-commit-button git-change-commit-button-overlay"
disabled={!canCommit()}
onClick={() => props.onSubmitCommit()}
>
{props.commitSubmitting()
? props.t("instanceShell.gitChanges.commit.submitting")
: props.t("instanceShell.gitChanges.commit.submit")}
</button>
</div>
</div> </div>
<For each={stagedList}>{(item) => renderListItem(item)}</For>
</div> </div>
</div> </Show>
</div>
{renderSection(
props.t("instanceShell.gitChanges.sections.unstaged"),
unstagedList,
props.unstagedOpen(),
props.onToggleUnstagedOpen,
)} )}
</For> </div>
</Show> </Show>
) )
@@ -266,7 +386,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
/> />
</> </>
} }
list={{ panel: renderListPanel, overlay: renderListOverlay }} list={{ panel: renderGroupedList, overlay: renderGroupedList }}
viewer={renderViewer()} viewer={renderViewer()}
listOpen={props.listOpen()} listOpen={props.listOpen()}
onToggleList={props.onToggleList} onToggleList={props.onToggleList}

View File

@@ -4,7 +4,7 @@ import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip" import { Tooltip } from "@kobalte/core/tooltip"
import Switch from "@suid/material/Switch" import Switch from "@suid/material/Switch"
import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import { BellRing, ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import type { Instance } from "../../../../../types/instance" import type { Instance } from "../../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../../server/src/api-types" import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
@@ -187,6 +187,24 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<div class="status-process-header"> <div class="status-process-header">
<span class="status-process-title">{process.title}</span> <span class="status-process-title">{process.title}</span>
<div class="status-process-meta"> <div class="status-process-meta">
<span
classList={{
"text-success": Boolean(process.notifyEnabled),
"text-tertiary": !process.notifyEnabled,
}}
aria-label={props.t(
process.notifyEnabled
? "instanceShell.backgroundProcesses.notify.enabled"
: "instanceShell.backgroundProcesses.notify.disabled",
)}
title={props.t(
process.notifyEnabled
? "instanceShell.backgroundProcesses.notify.enabled"
: "instanceShell.backgroundProcesses.notify.disabled",
)}
>
<BellRing class="h-3.5 w-3.5" />
</span>
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span> <span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}> <Show when={typeof process.outputSizeBytes === "number"}>
<span> <span>

View File

@@ -5,3 +5,40 @@ export type DiffViewMode = "split" | "unified"
export type DiffContextMode = "expanded" | "collapsed" export type DiffContextMode = "expanded" | "collapsed"
export type DiffWordWrapMode = "on" | "off" export type DiffWordWrapMode = "on" | "off"
export type GitChangeStatus = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | string
export interface GitChangeEntry {
path: string
originalPath?: string | null
additions: number
deletions: number
status: GitChangeStatus
stagedStatus?: GitChangeStatus | null
unstagedStatus?: GitChangeStatus | null
stagedAdditions?: number
stagedDeletions?: number
unstagedAdditions?: number
unstagedDeletions?: number
}
export type GitChangeSection = "staged" | "unstaged"
export interface GitChangeListItem {
id: string
path: string
originalPath?: string | null
section: GitChangeSection
status: GitChangeStatus
additions: number
deletions: number
entry: GitChangeEntry
displayName: string
parentPath: string
}
export interface GitSelectionDescriptor {
itemId: string | null
path: string | null
section: GitChangeSection | null
}

View File

@@ -0,0 +1,470 @@
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import type { PromptInputApi } from "../../../prompt-input/types"
import type { GitChangeEntry, GitChangeListItem, GitSelectionDescriptor, RightPanelTab } from "./types"
import { getOrCreateWorktreeClient } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api"
import { serverApi } from "../../../../lib/api-client"
import { serverEvents } from "../../../../lib/server-events"
import { showToastNotification } from "../../../../lib/notifications"
import { adaptSdkGitStatusEntries, buildGitChangeListItems } from "./git-changes-model"
type UseGitChangesOptions = {
t: (key: string, vars?: Record<string, any>) => string
instanceId: string
rightPanelTab: Accessor<RightPanelTab>
worktreeSlug: Accessor<string>
isPhoneLayout: Accessor<boolean>
promptInputApi: Accessor<PromptInputApi | null>
closeGitList: () => void
}
export function useGitChanges(options: UseGitChangesOptions) {
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitChangeEntry[] | null>(null)
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
const [gitSelectedItemId, setGitSelectedItemId] = createSignal<string | null>(null)
const [gitBulkSelectedItemIds, setGitBulkSelectedItemIds] = createSignal<Set<string>>(new Set())
const [gitBulkSelectionAnchorId, setGitBulkSelectionAnchorId] = createSignal<string | null>(null)
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
const [gitCommitMessage, setGitCommitMessage] = createSignal("")
const [gitCommitSubmitting, setGitCommitSubmitting] = createSignal(false)
let gitStatusRequestVersion = 0
let gitDiffRequestVersion = 0
let passiveGitRefreshInFlight = false
let pendingGitPassiveRefreshOptions: { forceReloadSelectedDiff?: boolean } | null = null
let previousGitChangesActivationKey: string | null = null
const gitListItems = createMemo(() => buildGitChangeListItems(gitStatusEntries()))
const clearGitBulkSelection = () => {
setGitBulkSelectedItemIds((current) => (current.size === 0 ? current : new Set<string>()))
setGitBulkSelectionAnchorId(null)
}
const toggleGitBulkSelection = (itemId: string) => {
setGitBulkSelectedItemIds((current) => {
const next = new Set(current)
if (next.has(itemId)) next.delete(itemId)
else next.add(itemId)
return next
})
}
const addGitBulkRange = (anchorId: string, itemId: string) => {
const items = gitListItems()
const anchorIndex = items.findIndex((entry) => entry.id === anchorId)
const itemIndex = items.findIndex((entry) => entry.id === itemId)
if (anchorIndex < 0 || itemIndex < 0) {
setGitBulkSelectedItemIds((current) => {
const next = new Set(current)
next.add(itemId)
return next
})
return
}
const start = Math.min(anchorIndex, itemIndex)
const end = Math.max(anchorIndex, itemIndex)
const rangeIds = items.slice(start, end + 1).map((entry) => entry.id)
setGitBulkSelectedItemIds((current) => {
const next = new Set(current)
for (const rangeId of rangeIds) {
next.add(rangeId)
}
return next
})
}
const describeGitSelection = (itemId: string | null): GitSelectionDescriptor => {
if (!itemId) {
return { itemId: null, path: null, section: null }
}
const match = gitListItems().find((item) => item.id === itemId) ?? null
return {
itemId,
path: match?.path ?? null,
section: match?.section ?? null,
}
}
const gitMostChangedItemId = createMemo<string | null>(() => {
const items = gitListItems()
if (items.length === 0) return null
const candidates = items.filter((item) => item.status !== "deleted")
if (candidates.length === 0) return null
const best = candidates.reduce((currentBest, item) => {
const bestScore = (currentBest?.additions ?? 0) + (currentBest?.deletions ?? 0)
const score = (item.additions ?? 0) + (item.deletions ?? 0)
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.id || "").localeCompare(String(currentBest?.id || "")) < 0 ? item : currentBest
}, candidates[0])
return typeof best?.id === "string" ? best.id : null
})
const resolveValidGitSelection = (selection: GitSelectionDescriptor): string | null => {
const items = gitListItems()
if (items.length === 0) return null
if (selection.itemId && items.some((item) => item.id === selection.itemId)) return selection.itemId
if (selection.path && selection.section) {
const oppositeSection = selection.section === "staged" ? "unstaged" : "staged"
const moved = items.find((item) => item.path === selection.path && item.section === oppositeSection)
if (moved) return moved.id
const samePath = items.find((item) => item.path === selection.path)
if (samePath) return samePath.id
}
return gitMostChangedItemId()
}
const describeGitSelectionFingerprint = (itemId: string | null) => {
if (!itemId) return null
const item = gitListItems().find((entry) => entry.id === itemId) ?? null
if (!item) return null
return `${item.path}::${item.originalPath ?? ""}::${item.section}::${item.status}::${item.additions}::${item.deletions}`
}
const clearSelectedGitDiff = () => {
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
}
const clearSelectedGitDiffAndSelection = () => {
setGitSelectedItemId(null)
clearGitBulkSelection()
setGitSelectedLoading(false)
clearSelectedGitDiff()
}
const pruneGitBulkSelection = () => {
const validIds = new Set(gitListItems().map((item) => item.id))
setGitBulkSelectedItemIds((current) => {
if (current.size === 0) return current
const next = new Set<string>()
for (const itemId of current) {
if (validIds.has(itemId)) next.add(itemId)
}
return next.size === current.size ? current : next
})
const anchorId = gitBulkSelectionAnchorId()
if (anchorId && !validIds.has(anchorId)) {
setGitBulkSelectionAnchorId(null)
}
}
createEffect(() => {
gitListItems()
pruneGitBulkSelection()
})
const loadGitStatus = async (force = false) => {
if (!force && gitStatusEntries() !== null) return
const slug = options.worktreeSlug()
const client = getOrCreateWorktreeClient(options.instanceId, slug)
const requestVersion = ++gitStatusRequestVersion
setGitStatusLoading(true)
setGitStatusError(null)
try {
const sdkStatusPromise = requestData<GitFileStatus[]>(client.file.status(), "file.status")
const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug)
if (requestVersion !== gitStatusRequestVersion) return
if (slug !== options.worktreeSlug()) return
const sdkResult = await Promise.race([
sdkStatusPromise.then((value) => ({ kind: "fulfilled" as const, value })),
new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 1500)),
]).catch(() => null)
const sdkList = sdkResult && sdkResult.kind === "fulfilled" ? sdkResult.value : null
setGitStatusEntries(adaptSdkGitStatusEntries(sdkList, detailList))
} catch (error) {
if (requestVersion !== gitStatusRequestVersion) return
if (slug !== options.worktreeSlug()) return
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
setGitStatusEntries([])
} finally {
if (requestVersion !== gitStatusRequestVersion) return
if (slug !== options.worktreeSlug()) return
setGitStatusLoading(false)
}
}
async function openGitFile(itemId: string) {
const requestVersion = ++gitDiffRequestVersion
setGitSelectedItemId(itemId)
setGitSelectedLoading(true)
clearSelectedGitDiff()
const item = gitListItems().find((entry) => entry.id === itemId) || null
if (!item) {
if (requestVersion !== gitDiffRequestVersion) return
clearSelectedGitDiffAndSelection()
return
}
if (options.isPhoneLayout()) {
options.closeGitList()
}
try {
const diff = await serverApi.fetchWorktreeGitDiff(options.instanceId, options.worktreeSlug(), {
path: item.path,
originalPath: item.originalPath ?? null,
scope: item.section,
})
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
if (diff.isBinary) {
setGitSelectedError(options.t("instanceShell.gitChanges.binaryViewer"))
return
}
setGitSelectedBefore(diff.before)
setGitSelectedAfter(diff.after)
} catch (error) {
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
} finally {
if (requestVersion !== gitDiffRequestVersion || gitSelectedItemId() !== itemId) return
setGitSelectedLoading(false)
}
}
const passiveRefreshGitStatus = async (optionsArg?: { forceReloadSelectedDiff?: boolean }) => {
if (options.rightPanelTab() !== "git-changes") return
if (passiveGitRefreshInFlight) {
pendingGitPassiveRefreshOptions = {
forceReloadSelectedDiff:
pendingGitPassiveRefreshOptions?.forceReloadSelectedDiff || optionsArg?.forceReloadSelectedDiff || false,
}
return
}
if (gitCommitSubmitting()) return
passiveGitRefreshInFlight = true
const refreshSelectionId = gitSelectedItemId()
const previousSelection = describeGitSelection(gitSelectedItemId())
const previousFingerprint = describeGitSelectionFingerprint(previousSelection.itemId)
const hadSelectedDiff =
previousSelection.itemId !== null &&
(gitSelectedBefore() !== null || gitSelectedAfter() !== null || gitSelectedError() !== null)
try {
await loadGitStatus(true)
if (gitSelectedItemId() !== refreshSelectionId) return
const nextSelection = resolveValidGitSelection(previousSelection)
setGitSelectedItemId(nextSelection)
if (!nextSelection) {
clearSelectedGitDiff()
return
}
const nextFingerprint = describeGitSelectionFingerprint(nextSelection)
const shouldReloadSelectedDiff =
optionsArg?.forceReloadSelectedDiff ||
!hadSelectedDiff ||
previousFingerprint !== nextFingerprint ||
previousSelection.itemId === nextSelection
if (shouldReloadSelectedDiff) {
await openGitFile(nextSelection)
}
} finally {
passiveGitRefreshInFlight = false
if (pendingGitPassiveRefreshOptions) {
const nextOptions = pendingGitPassiveRefreshOptions
pendingGitPassiveRefreshOptions = null
void passiveRefreshGitStatus(nextOptions)
}
}
}
const mutateGitFile = async (item: GitChangeListItem, action: "stage" | "unstage") => {
const currentSelection = describeGitSelection(gitSelectedItemId())
const fallbackSelection = currentSelection.path === item.path ? currentSelection : describeGitSelection(item.id)
const selectedIds = gitBulkSelectedItemIds()
const selectedItems = gitListItems().filter((candidate) => selectedIds.has(candidate.id))
const bulkTargets = selectedItems.filter((candidate) => candidate.section === item.section)
const targetItems = bulkTargets.some((candidate) => candidate.id === item.id) ? bulkTargets : [item]
const targetPaths = Array.from(new Set(targetItems.map((candidate) => candidate.path)))
try {
if (action === "stage") {
await serverApi.stageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
} else {
await serverApi.unstageWorktreeGitPaths(options.instanceId, options.worktreeSlug(), { paths: targetPaths })
}
await loadGitStatus(true)
clearGitBulkSelection()
const nextSelection = resolveValidGitSelection(fallbackSelection)
setGitSelectedItemId(nextSelection)
if (nextSelection) {
await openGitFile(nextSelection)
} else {
clearSelectedGitDiff()
}
} catch (error) {
showToastNotification({
message: error instanceof Error ? error.message : `Failed to ${action} file`,
variant: "error",
})
}
}
const handleGitRowClick = (item: GitChangeListItem, event: MouseEvent) => {
if (event.shiftKey) {
event.preventDefault()
const anchorId = gitBulkSelectionAnchorId() ?? item.id
addGitBulkRange(anchorId, item.id)
return
}
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
toggleGitBulkSelection(item.id)
setGitBulkSelectionAnchorId(item.id)
return
}
clearGitBulkSelection()
setGitBulkSelectionAnchorId(item.id)
void openGitFile(item.id)
}
const submitGitCommit = async () => {
const message = gitCommitMessage().trim()
if (!message || gitCommitSubmitting()) return
setGitCommitSubmitting(true)
try {
await serverApi.commitWorktreeGitChanges(options.instanceId, options.worktreeSlug(), { message })
setGitCommitMessage("")
await loadGitStatus(true)
const nextSelection = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
setGitSelectedItemId(nextSelection)
if (nextSelection) {
await openGitFile(nextSelection)
} else {
clearSelectedGitDiff()
}
showToastNotification({
message: options.t("instanceShell.gitChanges.commit.success"),
variant: "success",
})
} catch (error) {
showToastNotification({
message: error instanceof Error ? error.message : options.t("instanceShell.gitChanges.commit.error"),
variant: "error",
})
} finally {
setGitCommitSubmitting(false)
}
}
const refreshGitStatus = async () => {
await loadGitStatus(true)
const selected = resolveValidGitSelection(describeGitSelection(gitSelectedItemId()))
setGitSelectedItemId(selected)
if (selected) {
void openGitFile(selected)
} else {
clearSelectedGitDiff()
}
}
const insertGitChangeContext = (item: GitChangeListItem, selection: { startLine: number; endLine: number } | null) => {
const startLine = selection?.startLine ?? 1
const endLine = selection?.endLine ?? startLine
options.promptInputApi()?.insertComment(`Git Diff: File: ${item.path} : ${startLine}-${endLine}`)
}
createEffect(() => {
options.worktreeSlug()
gitStatusRequestVersion += 1
gitDiffRequestVersion += 1
passiveGitRefreshInFlight = false
pendingGitPassiveRefreshOptions = null
setGitStatusEntries(null)
setGitStatusError(null)
setGitStatusLoading(false)
setGitSelectedItemId(null)
clearGitBulkSelection()
setGitSelectedLoading(false)
clearSelectedGitDiff()
setGitCommitMessage("")
setGitCommitSubmitting(false)
})
createEffect(() => {
if (options.rightPanelTab() !== "git-changes") return
const items = gitListItems()
if (gitStatusEntries() === null) return
if (items.length === 0) return
if (gitSelectedItemId()) return
const next = gitMostChangedItemId()
if (!next) return
void openGitFile(next)
})
createEffect(() => {
const activationKey = options.rightPanelTab() === "git-changes" ? `${options.instanceId}:${options.worktreeSlug()}` : null
if (!activationKey) {
previousGitChangesActivationKey = null
return
}
if (previousGitChangesActivationKey === activationKey) return
previousGitChangesActivationKey = activationKey
void passiveRefreshGitStatus()
})
createEffect(() => {
if (options.rightPanelTab() !== "git-changes") return
const unsubscribe = serverEvents.on("instance.event", (event) => {
if (event.type !== "instance.event") return
if (event.instanceId !== options.instanceId) return
const eventType = (event.event as { type?: unknown } | undefined)?.type
if (eventType !== "session.updated" && eventType !== "session.diff") return
void passiveRefreshGitStatus({ forceReloadSelectedDiff: true })
})
onCleanup(() => {
unsubscribe()
})
})
createEffect(() => {
if (options.rightPanelTab() === "git-changes") return
setGitSelectedBefore(null)
setGitSelectedAfter(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
})
return {
gitStatusEntries,
gitStatusLoading,
gitStatusError,
gitSelectedItemId,
gitBulkSelectedItemIds,
gitSelectedLoading,
gitSelectedError,
gitSelectedBefore,
gitSelectedAfter,
gitCommitMessage,
gitCommitSubmitting,
gitMostChangedItemId,
setGitCommitMessage,
handleGitRowClick,
refreshGitStatus,
insertGitChangeContext,
submitGitCommit,
stageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "stage"),
unstageGitFile: (item: GitChangeListItem) => void mutateGitFile(item, "unstage"),
}
}

View File

@@ -21,6 +21,10 @@ export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1" export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1" export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1" export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_STAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-staged-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_UNSTAGED_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-unstaged-open-phone-v1"
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1" export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1" export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1" export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"

View File

@@ -1,9 +1,9 @@
import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js" import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message" import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message" import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
@@ -16,6 +16,7 @@ import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover" import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech" import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button" import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
function DeleteUpToIcon() { function DeleteUpToIcon() {
return ( return (
@@ -29,6 +30,7 @@ const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)" const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)" const TOOL_BORDER_COLOR = "var(--message-tool-border)"
const REASONING_SCROLL_SENTINEL_MARGIN_PX = 48
const LazyToolCall = lazy(() => import("./tool-call")) const LazyToolCall = lazy(() => import("./tool-call"))
@@ -229,6 +231,12 @@ function isContentPartType(type: unknown): boolean {
return type === "text" || type === "file" return type === "text" || type === "file"
} }
function isVisibleContentPart(part: ClientPart): boolean {
if (!part || !isContentPartType((part as any).type)) return false
if (isHiddenSyntheticTextPart(part)) return false
return partHasRenderableText(part)
}
function MessageContentItem(props: MessageContentItemProps) { function MessageContentItem(props: MessageContentItemProps) {
const record = createMemo(() => props.store().getMessage(props.messageId)) const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -262,13 +270,15 @@ function MessageContentItem(props: MessageContentItemProps) {
return resolved return resolved
}) })
const visibleParts = createMemo(() => parts().filter((part) => isVisibleContentPart(part)))
const showAgentMeta = createMemo(() => { const showAgentMeta = createMemo(() => {
const current = record() const current = record()
if (!current) return false if (!current) return false
if (current.role !== "assistant") return false if (current.role !== "assistant") return false
const currentParts = parts() const currentParts = parts()
if (!currentParts.some((part) => partHasRenderableText(part))) { if (visibleParts().length === 0) {
return false return false
} }
@@ -284,10 +294,10 @@ function MessageContentItem(props: MessageContentItemProps) {
if (!isSupportedPartType(part)) continue if (!isSupportedPartType(part)) continue
if (!isContentPartType((part as any).type)) continue if (!isContentPartType((part as any).type)) continue
if (partHasRenderableText(part)) { if (isVisibleContentPart(part)) {
return false return false
}
} }
}
return true return true
}) })
@@ -298,7 +308,7 @@ function MessageContentItem(props: MessageContentItemProps) {
<MessageItem <MessageItem
record={resolvedRecord()} record={resolvedRecord()}
messageInfo={messageInfo()} messageInfo={messageInfo()}
parts={parts()} parts={visibleParts()}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
isQueued={isQueued()} isQueued={isQueued()}
@@ -619,13 +629,12 @@ export default function MessageBlock(props: MessageBlockProps) {
const lastAssistantIdx = props.lastAssistantIndex() const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx) const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
// Intentionally untracked: messageInfoVersion updates should not trigger const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
// a full message block rebuild; record revision is the invalidation key.
const info = untrack(messageInfo)
const cacheSignature = [ const cacheSignature = [
current.id, current.id,
current.revision, current.revision,
messageInfoVersion,
isQueued ? 1 : 0, isQueued ? 1 : 0,
props.showThinking() ? 1 : 0, props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0, props.thinkingDefaultExpanded() ? 1 : 0,
@@ -637,6 +646,9 @@ export default function MessageBlock(props: MessageBlockProps) {
return cachedBlock.block return cachedBlock.block
} }
// Only capture info after cache check fails - ensures fresh data on version bump
const info = untrack(messageInfo)
const { orderedParts } = buildRecordDisplayData(props.instanceId, current) const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
const items: MessageBlockItem[] = [] const items: MessageBlockItem[] = []
const blockContentKeys: string[] = [] const blockContentKeys: string[] = []
@@ -803,19 +815,19 @@ export default function MessageBlock(props: MessageBlockProps) {
data-message-id={resolvedBlock().record.id} data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined} data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
> >
<For each={resolvedBlock().items}> <Index each={resolvedBlock().items}>
{(item, index) => ( {(item, index) => (
<Switch> <Switch>
<Match when={item.type === "content"}> <Match when={item().type === "content"}>
<MessageContentItem <MessageContentItem
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
store={props.store} store={props.store}
messageId={(item as ContentDisplayItem).messageId} messageId={(item() as ContentDisplayItem).messageId}
startPartId={(item as ContentDisplayItem).startPartId} startPartId={(item() as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex} messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex} lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
@@ -825,18 +837,18 @@ export default function MessageBlock(props: MessageBlockProps) {
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</Match> </Match>
<Match when={item.type === "tool"}> <Match when={item().type === "tool"}>
{(() => { {(() => {
const toolItem = item as ToolDisplayItem const toolItem = item() as ToolDisplayItem
return ( return (
<div class="tool-call-message" data-key={toolItem.key}> <div class="tool-call-message" data-key={toolItem.key}>
<ToolCallItem <ToolCallItem
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
store={props.store} store={props.store}
messageId={toolItem.messageId} messageId={toolItem.messageId}
partId={toolItem.partId} partId={toolItem.partId}
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
deleteHover={props.deleteHover} deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
@@ -849,13 +861,13 @@ export default function MessageBlock(props: MessageBlockProps) {
) )
})()} })()}
</Match> </Match>
<Match when={item.type === "step-start"}> <Match when={item().type === "step-start"}>
<StepCard <StepCard
kind="start" kind="start"
part={(item as StepDisplayItem).part} part={(item() as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo} messageInfo={(item() as StepDisplayItem).messageInfo}
showAgentMeta showAgentMeta
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={props.messageId} messageId={props.messageId}
@@ -865,14 +877,14 @@ export default function MessageBlock(props: MessageBlockProps) {
onToggleSelectedMessage={props.onToggleSelectedMessage} onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "step-finish"}> <Match when={item().type === "step-finish"}>
<StepCard <StepCard
kind="finish" kind="finish"
part={(item as StepDisplayItem).part} part={(item() as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo} messageInfo={(item() as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()} showUsage={props.showUsageMetrics()}
borderColor={(item as StepDisplayItem).accentColor} borderColor={(item() as StepDisplayItem).accentColor}
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={props.messageId} messageId={props.messageId}
@@ -882,31 +894,31 @@ export default function MessageBlock(props: MessageBlockProps) {
onToggleSelectedMessage={props.onToggleSelectedMessage} onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "compaction"}> <Match when={item().type === "compaction"}>
<CompactionCard <CompactionCard
part={(item as CompactionDisplayItem).part} part={(item() as CompactionDisplayItem).part}
messageInfo={(item as CompactionDisplayItem).messageInfo} messageInfo={(item() as CompactionDisplayItem).messageInfo}
borderColor={(item as CompactionDisplayItem).accentColor} borderColor={(item() as CompactionDisplayItem).accentColor}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as CompactionDisplayItem).messageId} messageId={(item() as CompactionDisplayItem).messageId}
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds} selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage} onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "reasoning"}> <Match when={item().type === "reasoning"}>
<ReasoningCard <ReasoningCard
part={(item as ReasoningDisplayItem).part} part={(item() as ReasoningDisplayItem).part}
messageInfo={(item as ReasoningDisplayItem).messageInfo} messageInfo={(item() as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as ReasoningDisplayItem).messageId} messageId={(item() as ReasoningDisplayItem).messageId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta} showAgentMeta={(item() as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded} defaultExpanded={(item() as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index() === 0} showDeleteMessage={index === 0}
onDeleteHoverChange={props.onDeleteHoverChange} onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds} selectedMessageIds={props.selectedMessageIds}
@@ -916,7 +928,7 @@ export default function MessageBlock(props: MessageBlockProps) {
</Match> </Match>
</Switch> </Switch>
)} )}
</For> </Index>
</div> </div>
)} )}
</Show> </Show>
@@ -1098,17 +1110,23 @@ function StepCard(props: StepCardProps) {
return null return null
} }
const info = props.messageInfo const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.tokens) { const part = props.part as any
// step-finish parts have tokens embedded; also check messageInfo
const partTokens = part?.tokens
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
const tokens = partTokens ?? infoTokens
if (!tokens) {
return null return null
} }
const tokens = info.tokens
return { return {
input: tokens.input ?? 0, input: tokens.input ?? 0,
output: tokens.output ?? 0, output: tokens.output ?? 0,
reasoning: tokens.reasoning ?? 0, reasoning: tokens.reasoning ?? 0,
cacheRead: tokens.cache?.read ?? 0, cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0, cacheWrite: tokens.cache?.write ?? 0,
cost: info.cost ?? 0, cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0,
} }
} }
@@ -1293,14 +1311,23 @@ interface ReasoningCardProps {
onContentRendered?: () => void onContentRendered?: () => void
} }
function ReasoningCard(props: ReasoningCardProps) { function ReasoningStreamOutput(props: {
const { t } = useI18n() text: Accessor<string>
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) scrollTopSnapshot: Accessor<number>
const [deletingMessage, setDeletingMessage] = createSignal(false) setScrollTopSnapshot: (next: number) => void
const [deletingUpTo, setDeletingUpTo] = createSignal(false) onContentRendered?: () => void
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) ariaLabel: string
}) {
let preRef: HTMLPreElement | undefined
let pendingRenderNotificationFrame: number | null = null let pendingRenderNotificationFrame: number | null = null
const followScroll = createFollowScroll({
getScrollTopSnapshot: props.scrollTopSnapshot,
setScrollTopSnapshot: props.setScrollTopSnapshot,
sentinelMarginPx: REASONING_SCROLL_SENTINEL_MARGIN_PX,
sentinelClassName: "reasoning-scroll-sentinel",
})
const notifyContentRendered = () => { const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) { if (pendingRenderNotificationFrame !== null) {
@@ -1312,6 +1339,15 @@ function ReasoningCard(props: ReasoningCardProps) {
}) })
} }
createEffect(() => {
const nextText = props.text()
if (preRef && preRef.textContent !== nextText) {
preRef.textContent = nextText
}
followScroll.restoreAfterRender()
notifyContentRendered()
})
onCleanup(() => { onCleanup(() => {
if (pendingRenderNotificationFrame !== null) { if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame) cancelAnimationFrame(pendingRenderNotificationFrame)
@@ -1319,6 +1355,37 @@ function ReasoningCard(props: ReasoningCardProps) {
} }
}) })
return (
<div
ref={followScroll.registerContainer}
class="message-reasoning-output"
role="region"
aria-label={props.ariaLabel}
onScroll={followScroll.handleScroll}
>
<pre
ref={(element) => {
preRef = element || undefined
if (preRef) {
preRef.textContent = props.text() || ""
}
}}
class="message-reasoning-text"
dir="auto"
/>
{followScroll.renderSentinel()}
</div>
)
}
function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const [scrollTopSnapshot, setScrollTopSnapshot] = createSignal(0)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
createEffect(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
}) })
@@ -1393,12 +1460,6 @@ function ReasoningCard(props: ReasoningCardProps) {
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech() const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
createEffect(() => {
if (!expanded()) return
reasoningText()
notifyContentRendered()
})
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => { const handleDeleteMessage = async (event: MouseEvent) => {
@@ -1553,9 +1614,13 @@ function ReasoningCard(props: ReasoningCardProps) {
<Show when={expanded()}> <Show when={expanded()}>
<div class="message-reasoning-expanded"> <div class="message-reasoning-expanded">
<div class="message-reasoning-body"> <div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}> <ReasoningStreamOutput
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre> text={reasoningText}
</div> scrollTopSnapshot={scrollTopSnapshot}
setScrollTopSnapshot={setScrollTopSnapshot}
onContentRendered={props.onContentRendered}
ariaLabel={t("messageBlock.reasoning.detailsAriaLabel")}
/>
</div> </div>
</div> </div>
</Show> </Show>

View File

@@ -2,7 +2,7 @@ import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid" import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message" import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
import { partHasRenderableText } from "../types/message" import { isHiddenSyntheticTextPart, partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part" import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
@@ -290,9 +290,9 @@ export default function MessageItem(props: MessageItemProps) {
const getRawContent = () => { const getRawContent = () => {
return props.parts return props.parts
.filter(part => part.type === "text") .filter((part) => part.type === "text" && !isHiddenSyntheticTextPart(part))
.map(part => (part as { text?: string }).text || "") .map((part) => (part as { text?: string }).text || "")
.filter(text => text.trim().length > 0) .filter((text) => text.trim().length > 0)
.join("\n\n") .join("\n\n")
} }
@@ -338,7 +338,7 @@ export default function MessageItem(props: MessageItemProps) {
} }
} }
if (!isUser() && !hasContent() && !isGenerating()) { if (!hasContent() && !isGenerating()) {
return null return null
} }

View File

@@ -33,19 +33,7 @@ export default function MessagePart(props: MessagePartProps) {
const shouldHideTextPart = () => { const shouldHideTextPart = () => {
const part = props.part const part = props.part
if (!part || part.type !== "text") return false if (!part || part.type !== "text") return false
return Boolean((part as any).synthetic)
const isSynthetic = Boolean((part as any).synthetic)
if (!isSynthetic) return false
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
if (props.messageType === "user") {
const primaryId = props.primaryUserTextPartId
if (!primaryId) return false
return part.id !== primaryId
}
// Hide synthetic assistant text.
return true
} }

View File

@@ -1,5 +1,5 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js" import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
import { MoreHorizontal, Trash, X } from "lucide-solid" import { MoreHorizontal, Pause, Trash, X } from "lucide-solid"
import Kbd from "./kbd" import Kbd from "./kbd"
import MessageBlock from "./message-block" import MessageBlock from "./message-block"
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors" import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
@@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessage, deleteMessagePart } from "../stores/session-actions" import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover" import type { DeleteHoverState } from "../types/delete-hover"
import { partHasRenderableText } from "../types/message"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils" import { getPartCharCount } from "../lib/token-utils"
const SCROLL_SENTINEL_MARGIN_PX = 8 const SCROLL_SENTINEL_MARGIN_PX = 8
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
const QUOTE_SELECTION_MAX_LENGTH = 2000 const QUOTE_SELECTION_MAX_LENGTH = 2000
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
export interface MessageSectionProps { export interface MessageSectionProps {
@@ -40,12 +42,40 @@ export interface MessageSectionProps {
} }
export default function MessageSection(props: MessageSectionProps) { export default function MessageSection(props: MessageSectionProps) {
const { preferences } = useConfig() const { preferences, updatePreferences } = useConfig()
const { t } = useI18n() const { t } = useI18n()
const showUsagePreference = () => preferences().showUsageMetrics ?? true const showUsagePreference = () => preferences().showUsageMetrics ?? true
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? true
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId)) const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId)) const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
const visibleMessageIds = createMemo(() => {
const resolvedStore = store()
return messageIds().filter((messageId) => {
const record = resolvedStore.getMessage(messageId)
if (!record) return false
if (buildTimelineSegments(props.instanceId, record, t).length > 0) {
return true
}
if (record.role !== "assistant") {
return false
}
const info = resolvedStore.getMessageInfo(messageId)
if (!info || info.role !== "assistant") {
return false
}
if (info.error) {
return true
}
const timeInfo = info.time as { created: number; end?: number } | undefined
return Boolean(timeInfo && (timeInfo.end === undefined || timeInfo.end === 0))
})
})
const scrollCache = useScrollCache({ const scrollCache = useScrollCache({
instanceId: props.instanceId, instanceId: props.instanceId,
@@ -129,6 +159,8 @@ export default function MessageSection(props: MessageSectionProps) {
return map return map
}) })
const lastAssistantMessageId = createMemo(() => store().getLastAssistantMessageId(props.sessionId))
const lastCompactionIndex = createMemo(() => { const lastCompactionIndex = createMemo(() => {
// Depend on a single session revision signal (not every message/part read) // Depend on a single session revision signal (not every message/part read)
// to keep reactive overhead small. // to keep reactive overhead small.
@@ -315,15 +347,9 @@ export default function MessageSection(props: MessageSectionProps) {
} }
const lastAssistantIndex = createMemo(() => { const lastAssistantIndex = createMemo(() => {
const ids = messageIds() const messageId = lastAssistantMessageId()
const resolvedStore = store() if (!messageId) return -1
for (let index = ids.length - 1; index >= 0; index--) { return messageIndexById().get(messageId) ?? -1
const record = resolvedStore.getMessage(ids[index])
if (record?.role === "assistant") {
return index
}
}
return -1
}) })
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([]) const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
@@ -571,7 +597,10 @@ export default function MessageSection(props: MessageSectionProps) {
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>() const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>() const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`) // Only preferences should force a follow-token re-anchor. Message/session
// revision churn at the end of a turn (message.updated, session.idle, etc.)
// should not trigger an immediate scroll-to-bottom.
const followToken = createMemo(() => preferenceSignature())
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE)) const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true) const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
@@ -601,6 +630,35 @@ export default function MessageSection(props: MessageSectionProps) {
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null) const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
const lastVisibleMessageId = createMemo(() => {
const ids = visibleMessageIds()
return ids[ids.length - 1] ?? null
})
const autoPinHoldTargetKey = createMemo(() => {
if (!holdLongAssistantRepliesEnabled()) return null
const messageId = lastVisibleMessageId()
return isAssistantTextMessage(messageId) ? messageId : null
})
function toggleHoldLongAssistantReplies() {
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
}
function isAssistantTextMessage(messageId: string | null | undefined) {
if (!messageId) return false
const resolvedStore = store()
const record = resolvedStore.getMessage(messageId)
if (!record || record.role !== "assistant") return false
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
return orderedParts.some((part) => {
if ((part as any)?.type !== "text") return false
if (partHasRenderableText(part)) return true
return typeof (part as { text?: unknown }).text === "string"
})
}
createEffect(() => { createEffect(() => {
const api = listApi() const api = listApi()
if (!api) return if (!api) return
@@ -615,7 +673,7 @@ export default function MessageSection(props: MessageSectionProps) {
const api = listApi() const api = listApi()
if (!element || !api) return if (!element || !api) return
if (props.loading) return if (props.loading) return
if (messageIds().length === 0) return if (visibleMessageIds().length === 0) return
if (didRestoreScroll()) return if (didRestoreScroll()) return
scrollCache.restore(element, { scrollCache.restore(element, {
@@ -734,88 +792,93 @@ export default function MessageSection(props: MessageSectionProps) {
const loading = Boolean(props.loading) const loading = Boolean(props.loading)
const ids = messageIds() const ids = messageIds()
if (loading) { // Wrap all iteration of the store-proxied `ids` array in untrack()
handleClearTimelineSelection() // to prevent O(n) per-element reactive subscriptions. The effect
previousTimelineIds = [] // only needs to re-run when `messageIds` (memo) changes.
setTimelineSegments([]) untrack(() => {
seenTimelineMessageIds.clear() if (loading) {
seenTimelineSegmentKeys.clear() handleClearTimelineSelection()
timelinePartCountsByMessageId.clear() previousTimelineIds = []
pendingTimelineMessagePartUpdates.clear() setTimelineSegments([])
if (pendingTimelinePartUpdateFrame !== null) { seenTimelineMessageIds.clear()
cancelAnimationFrame(pendingTimelinePartUpdateFrame) seenTimelineSegmentKeys.clear()
pendingTimelinePartUpdateFrame = null timelinePartCountsByMessageId.clear()
} pendingTimelineMessagePartUpdates.clear()
return if (pendingTimelinePartUpdateFrame !== null) {
} cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
if (previousTimelineIds.length === 0 && ids.length > 0) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length < previousTimelineIds.length) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length === previousTimelineIds.length) {
let changedIndex = -1
let changeCount = 0
for (let index = 0; index < ids.length; index++) {
if (ids[index] !== previousTimelineIds[index]) {
changedIndex = index
changeCount += 1
if (changeCount > 1) break
} }
return
} }
if (changeCount === 1 && changedIndex >= 0) {
const oldId = previousTimelineIds[changedIndex]
const newId = ids[changedIndex]
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
seenTimelineMessageIds.delete(oldId)
seenTimelineMessageIds.add(newId)
setTimelineSegments((prev) => {
const next = prev.map((segment) => {
if (segment.messageId !== oldId) return segment
const updatedId = segment.id.replace(oldId, newId)
return { ...segment, messageId: newId, id: updatedId }
})
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
// Keep part count tracking in sync with id replacement. if (previousTimelineIds.length === 0 && ids.length > 0) {
const existingPartCount = timelinePartCountsByMessageId.get(oldId) seedTimeline()
if (existingPartCount !== undefined) { previousTimelineIds = [...ids]
timelinePartCountsByMessageId.delete(oldId) return
timelinePartCountsByMessageId.set(newId, existingPartCount) }
if (ids.length < previousTimelineIds.length) {
seedTimeline()
previousTimelineIds = [...ids]
return
}
if (ids.length === previousTimelineIds.length) {
let changedIndex = -1
let changeCount = 0
for (let index = 0; index < ids.length; index++) {
if (ids[index] !== previousTimelineIds[index]) {
changedIndex = index
changeCount += 1
if (changeCount > 1) break
} }
}
if (changeCount === 1 && changedIndex >= 0) {
const oldId = previousTimelineIds[changedIndex]
const newId = ids[changedIndex]
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
seenTimelineMessageIds.delete(oldId)
seenTimelineMessageIds.add(newId)
setTimelineSegments((prev) => {
const next = prev.map((segment) => {
if (segment.messageId !== oldId) return segment
const updatedId = segment.id.replace(oldId, newId)
return { ...segment, messageId: newId, id: updatedId }
})
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
previousTimelineIds = ids.slice() // Keep part count tracking in sync with id replacement.
return const existingPartCount = timelinePartCountsByMessageId.get(oldId)
if (existingPartCount !== undefined) {
timelinePartCountsByMessageId.delete(oldId)
timelinePartCountsByMessageId.set(newId, existingPartCount)
}
previousTimelineIds = [...ids]
return
}
} }
} }
}
const newIds: string[] = [] const newIds: string[] = []
ids.forEach((id) => { ids.forEach((id) => {
if (!seenTimelineMessageIds.has(id)) { if (!seenTimelineMessageIds.has(id)) {
newIds.push(id) newIds.push(id)
} }
})
if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
}) })
}
previousTimelineIds = ids.slice() if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
})
}
previousTimelineIds = [...ids]
})
}) })
function clearPendingTimelinePartUpdateFrame() { function clearPendingTimelinePartUpdateFrame() {
@@ -886,36 +949,49 @@ export default function MessageSection(props: MessageSectionProps) {
createEffect(() => { createEffect(() => {
if (props.loading) return if (props.loading) return
const ids = messageIds() const ids = messageIds()
const resolvedStore = store() // Also re-run when sessionRevision bumps (covers part additions within
// existing messages) but read individual records inside untrack() to
// avoid creating O(n) fine-grained subscriptions.
sessionRevision()
let hasChanges = false // Wrap the iteration in untrack() so that accessing individual elements
for (const messageId of ids) { // of the store-proxied `ids` array does not create O(n) per-element
const record = resolvedStore.getMessage(messageId) // reactive subscriptions. We only need to re-run when the memo
const partCount = record?.partIds.length ?? 0 // (messageIds) or sessionRevision changes — not per-element.
const previousCount = timelinePartCountsByMessageId.get(messageId) untrack(() => {
const resolvedStore = store()
const idsSet = new Set(ids)
let hasChanges = false
if (previousCount === undefined) { for (const messageId of ids) {
timelinePartCountsByMessageId.set(messageId, partCount) const record = resolvedStore.getMessage(messageId)
continue const partCount = record?.partIds.length ?? 0
const previousCount = timelinePartCountsByMessageId.get(messageId)
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
}
} }
if (previousCount !== partCount) { // Drop tracking for ids that are no longer present.
timelinePartCountsByMessageId.set(messageId, partCount) // Use the Set for O(1) lookups instead of ids.includes() which is O(n).
pendingTimelineMessagePartUpdates.add(messageId) for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
hasChanges = true if (!idsSet.has(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
}
} }
}
// Drop tracking for ids that are no longer present. if (hasChanges) {
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) { scheduleTimelinePartUpdateFlush()
if (!ids.includes(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
} }
} })
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
}
}) })
createEffect(() => { createEffect(() => {
@@ -989,7 +1065,7 @@ export default function MessageSection(props: MessageSectionProps) {
data-scroll-buttons={scrollButtonsCount()} data-scroll-buttons={scrollButtonsCount()}
> >
<VirtualFollowList <VirtualFollowList
items={messageIds} items={visibleMessageIds}
getKey={(messageId) => messageId} getKey={(messageId) => messageId}
getAnchorId={getMessageAnchorId} getAnchorId={getMessageAnchorId}
getKeyFromAnchorId={getMessageIdFromAnchorId} getKeyFromAnchorId={getMessageIdFromAnchorId}
@@ -1003,6 +1079,12 @@ export default function MessageSection(props: MessageSectionProps) {
initialAutoScroll={initialAutoScroll} initialAutoScroll={initialAutoScroll}
resetKey={() => props.sessionId} resetKey={() => props.sessionId}
followToken={followToken} followToken={followToken}
autoPinHoldTargetKey={autoPinHoldTargetKey}
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
resolveAutoPinHoldElement={(itemWrapper, key) => {
const candidates = Array.from(itemWrapper.querySelectorAll<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`))
return candidates[candidates.length - 1] ?? null
}}
onScroll={() => { onScroll={() => {
clearQuoteSelection() clearQuoteSelection()
scrollCache.persist(streamElement()) scrollCache.persist(streamElement())
@@ -1033,9 +1115,55 @@ export default function MessageSection(props: MessageSectionProps) {
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")} scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
registerApi={(api) => setListApi(api)} registerApi={(api) => setListApi(api)}
registerState={(state) => setListState(state)} registerState={(state) => setListState(state)}
renderControls={(state, api) => (
<div class="message-scroll-button-wrapper">
<button
type="button"
class="message-scroll-button"
data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"}
onClick={toggleHoldLongAssistantReplies}
aria-label={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
title={
holdLongAssistantRepliesEnabled()
? t("messageSection.scroll.disableHoldAriaLabel")
: t("messageSection.scroll.enableHoldAriaLabel")
}
>
<Pause class="message-scroll-icon message-scroll-icon--toggle w-4 h-4" aria-hidden="true" />
</button>
<Show when={state.showScrollTopButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => api.scrollToTop()}
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
<Show when={state.showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => api.scrollToBottom()}
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
</div>
)}
renderBeforeItems={() => ( renderBeforeItems={() => (
<> <>
<Show when={!props.loading && messageIds().length === 0}> <Show when={!props.loading && visibleMessageIds().length === 0}>
<div class="empty-state"> <div class="empty-state">
<div class="empty-state-content"> <div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6"> <div class="flex flex-col items-center gap-3 mb-6">

View File

@@ -1,7 +1,10 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
import { Portal } from "solid-js/web"
import MessagePreview from "./message-preview" import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message" import type { ClientPart } from "../types/message"
import { isHiddenSyntheticTextPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils" import { getPartCharCount } from "../lib/token-utils"
@@ -53,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220
const LONG_PRESS_MS = 500 const LONG_PRESS_MS = 500
const JITTER_THRESHOLD = 10 const JITTER_THRESHOLD = 10
const ABSOLUTE_TOKEN_CAP = 10000 const ABSOLUTE_TOKEN_CAP = 10000
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -65,6 +69,13 @@ interface PendingSegment {
hasPrimaryText: boolean hasPrimaryText: boolean
} }
interface TimelineSegmentState {
deleteHovered: boolean
deleteSelected: boolean
hasActivePermission: boolean
hidden: boolean
}
function truncateText(value: string): string { function truncateText(value: string): string {
if (value.length <= MAX_TOOLTIP_LENGTH) { if (value.length <= MAX_TOOLTIP_LENGTH) {
return value return value
@@ -105,6 +116,7 @@ function collectReasoningText(part: ClientPart): string {
function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string { function collectTextFromPart(part: ClientPart, t: (key: string, params?: Record<string, unknown>) => string): string {
if (!part) return "" if (!part) return ""
if (isHiddenSyntheticTextPart(part)) return ""
if (typeof (part as any).text === "string") { if (typeof (part as any).text === "string") {
return (part as any).text as string return (part as any).text as string
} }
@@ -349,6 +361,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
} }
const clearHoverPreview = () => {
clearHoverTimer()
clearCloseTimer()
setHoveredSegment(null)
setHoverAnchorRect(null)
}
const scheduleClose = () => { const scheduleClose = () => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
clearHoverTimer() clearHoverTimer()
@@ -356,8 +375,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
// Small delay so the pointer can travel from the segment to the tooltip. // Small delay so the pointer can travel from the segment to the tooltip.
closeTimer = window.setTimeout(() => { closeTimer = window.setTimeout(() => {
closeTimer = null closeTimer = null
setHoveredSegment(null) clearHoverPreview()
setHoverAnchorRect(null)
}, 160) }, 160)
} }
@@ -397,8 +415,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}) })
onCleanup(() => { onCleanup(() => {
clearHoverTimer() clearHoverPreview()
clearCloseTimer()
}) })
// --- Selection & histogram rib state --- // --- Selection & histogram rib state ---
@@ -416,6 +433,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
// on activation, resize, or expansion — NOT on every scroll frame. // on activation, resize, or expansion — NOT on every scroll frame.
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({}) const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200) const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
let xrayOverlayRef: HTMLDivElement | undefined let xrayOverlayRef: HTMLDivElement | undefined
@@ -447,6 +466,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
const handleScroll = () => { const handleScroll = () => {
if (renderVirtualizedTimeline()) {
if (hoveredSegment()) {
clearHoverPreview()
}
return
}
if (!isSelectionActive()) return if (!isSelectionActive()) return
if (!scrollContainerRef || !xrayOverlayRef) return if (!scrollContainerRef || !xrayOverlayRef) return
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`) xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
@@ -475,6 +500,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
}) })
const renderVirtualizedTimeline = createMemo(() => !isSelectionActive())
createEffect(on(renderVirtualizedTimeline, () => {
clearHoverPreview()
}))
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5)) const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
// Compute fresh char counts from the store. segment.totalChars can be stale for // Compute fresh char counts from the store. segment.totalChars can be stale for
@@ -577,7 +608,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
wasLongPress = true wasLongPress = true
// Scroll anchoring: preserve visual position of the pressed badge. // Scroll anchoring: preserve visual position of the pressed badge.
const btn = buttonRefs.get(segment.id) const btn = renderVirtualizedTimeline() ? null : buttonRefs.get(segment.id)
let anchorOffset: number | null = null let anchorOffset: number | null = null
if (btn && scrollContainerRef) { if (btn && scrollContainerRef) {
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
@@ -629,9 +660,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
createEffect(on(() => props.activeSegmentId, (activeId) => { createEffect(on(() => props.activeSegmentId, (activeId) => {
if (!activeId) return if (!activeId) return
const element = buttonRefs.get(activeId)
if (!element) return
const timer = typeof window !== "undefined" ? window.setTimeout(() => { const timer = typeof window !== "undefined" ? window.setTimeout(() => {
if (renderVirtualizedTimeline()) {
const index = segmentIndexById().get(activeId)
if (index !== undefined) {
virtualizerHandle()?.scrollToIndex(index, { align: "nearest", smooth: true })
}
return
}
const element = buttonRefs.get(activeId)
if (!element) return
element.scrollIntoView({ block: "nearest", behavior: "smooth" }) element.scrollIntoView({ block: "nearest", behavior: "smooth" })
}, 120) : null }, 120) : null
onCleanup(() => { onCleanup(() => {
@@ -682,60 +721,239 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
return map return map
}) })
const segmentIndexById = createMemo(() => {
const map = new Map<string, number>()
for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i)
return map
})
const segmentStates = createMemo(() => {
const hover = deleteHover()
const selectedMessages = props.selectedMessageIds?.()
const expandedMessages = props.expandedMessageIds?.()
const resolvedStore = store()
const indexMap = messageIdToSessionIndex()
const selectionActive = isSelectionActive()
const result = new Map<string, TimelineSegmentState>()
for (const segment of props.segments) {
let deleteHovered = false
if (hover.kind === "message") {
deleteHovered = hover.messageId === segment.messageId
} else if (hover.kind === "deleteUpTo") {
const targetIndex = indexMap.get(hover.messageId)
const segmentIndex = indexMap.get(segment.messageId)
deleteHovered = targetIndex !== undefined && segmentIndex !== undefined && segmentIndex >= targetIndex
}
const deleteSelected = selectedMessages?.has(segment.messageId) ?? false
let hasActivePermission = false
if (segment.type === "tool") {
const partIds = segment.toolPartIds ?? []
for (const partId of partIds) {
const permissionState = resolvedStore.getPermissionState(segment.messageId, partId)
if (permissionState?.active) {
hasActivePermission = true
break
}
}
}
const hidden = segment.type === "tool" && !(
showTools()
|| expandedMessages?.has(segment.messageId)
|| selectionActive
|| props.activeSegmentId === segment.id
|| hasActivePermission
|| deleteHovered
|| deleteSelected
)
result.set(segment.id, {
deleteHovered,
deleteSelected,
hasActivePermission,
hidden,
})
}
return result
})
const segmentStateFor = (segmentId: string): TimelineSegmentState => {
return segmentStates().get(segmentId) ?? {
deleteHovered: false,
deleteSelected: false,
hasActivePermission: false,
hidden: false,
}
}
const segmentSpacerHeights = createMemo(() => {
const states = segmentStates()
const result = new Map<string, string>()
let previousVisible: TimelineSegment | null = null
for (let index = 0; index < props.segments.length; index += 1) {
const segment = props.segments[index]
const state = states.get(segment.id)
if (state?.hidden) {
result.set(segment.id, "0")
continue
}
if (!previousVisible) {
result.set(segment.id, "0")
previousVisible = segment
continue
}
const previousRaw = index > 0 ? props.segments[index - 1] : null
const startsVisibleToolGroup = segment.type === "tool"
&& (previousVisible.type !== "tool" || previousVisible.messageId !== segment.messageId)
const startsCollapsedToolGroup = segment.type === "assistant"
&& previousVisible.messageId !== segment.messageId
&& messagesWithTools().has(segment.messageId)
&& previousRaw?.type === "tool"
&& previousRaw.messageId === segment.messageId
const followsVisibleGroupParent = (segment.type === "user" || segment.type === "compaction")
&& previousVisible.type === "assistant"
&& messagesWithTools().has(previousVisible.messageId)
const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0)
result.set(
segment.id,
gapUnits === 1
? "var(--message-timeline-segment-gap)"
: "calc(var(--message-timeline-segment-gap) * 2)",
)
previousVisible = segment
}
return result
})
return ( return (
<div class="message-timeline-container"> <div class="message-timeline-container">
<div <div
ref={scrollContainerRef} ref={(element) => {
scrollContainerRef = element
setScrollElement(element)
}}
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`} class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
role="navigation" role="navigation"
aria-label={t("messageTimeline.ariaLabel")} aria-label={t("messageTimeline.ariaLabel")}
onScroll={handleScroll} onScroll={handleScroll}
> >
<For each={props.segments}> <Show
{(segment, segIndex) => { when={renderVirtualizedTimeline()}
onCleanup(() => buttonRefs.delete(segment.id)) fallback={(
<For each={props.segments}>
{(segment, segIndex) => {
onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeSegmentId === segment.id
const isSelected = () => props.selectedIds?.().has(segment.id)
const state = () => segmentStateFor(segment.id)
const isDeleteHovered = () => state().deleteHovered
const isDeleteSelected = () => state().deleteSelected
const hasActivePermission = () => state().hasActivePermission
const isHidden = () => state().hidden
const groupRole = (): "child" | "parent" | "none" => {
if (segment.type === "tool") return "child"
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
return "none"
}
const shortLabelContent = () => {
if (segment.type === "tool") {
if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
}
return segment.shortLabel ?? getToolIcon("tool")
}
if (segment.type === "compaction") {
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
}
if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
}
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
}
return (
<div class="message-timeline-item">
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
<button
ref={(el) => registerButtonRef(segment.id, el)}
type="button"
data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}
onClick={(event) => {
if (wasLongPress) {
wasLongPress = false
return
}
const btn = buttonRefs.get(segment.id)
const stableBtn = renderVirtualizedTimeline() ? null : btn
let anchorOffset: number | null = null
if (stableBtn && scrollContainerRef) {
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
}
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
if (event.shiftKey) {
props.onSelectRange?.(segment.id)
} else if (event.ctrlKey || event.metaKey) {
props.onToggleSelection?.(segment.id)
} else if (isMultiSelectActive) {
props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
const desired = stableBtn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
}
}
}}
onPointerDown={(e) => handlePointerDown(segment, e)}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onContextMenu={handleContextMenu}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button>
</div>
)
}}
</For>
)}
>
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
{(segment, index) => {
const segIndex = () => index()
const isActive = () => props.activeSegmentId === segment.id const isActive = () => props.activeSegmentId === segment.id
const isSelected = () => props.selectedIds?.().has(segment.id) const isSelected = () => props.selectedIds?.().has(segment.id)
const state = () => segmentStateFor(segment.id)
const isDeleteHovered = () => { const isDeleteHovered = () => state().deleteHovered
const hover = deleteHover() as DeleteHoverState const isDeleteSelected = () => state().deleteSelected
if (hover.kind === "message") { const hasActivePermission = () => state().hasActivePermission
return hover.messageId === segment.messageId const isHidden = () => state().hidden
}
if (hover.kind === "deleteUpTo") {
const indexMap = messageIdToSessionIndex()
const targetIndex = indexMap.get(hover.messageId)
if (targetIndex === undefined) return false
const segmentIndex = indexMap.get(segment.messageId)
if (segmentIndex === undefined) return false
return segmentIndex >= targetIndex
}
return false
}
const isDeleteSelected = () => {
const selected = props.selectedMessageIds?.()
if (!selected) return false
return selected.has(segment.messageId)
}
const hasActivePermission = () => {
if (segment.type !== "tool") return false
const partIds = segment.toolPartIds ?? []
if (partIds.length === 0) return false
for (const partId of partIds) {
const permissionState = store().getPermissionState(segment.messageId, partId)
if (permissionState?.active) return true
}
return false
}
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
const isHidden = () =>
segment.type === "tool" &&
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
// Group visual indicators: tools belong to the same message as their // Group visual indicators: tools belong to the same message as their
// assistant. Uses messageId for correctness (not positional adjacency). // assistant. Uses messageId for correctness (not positional adjacency).
@@ -744,18 +962,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent" if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
return "none" return "none"
} }
const isGroupStart = () => {
if (segment.type !== "tool") return false
const idx = segIndex()
const prev = idx > 0 ? props.segments[idx - 1] : null
// First tool in the message's run: either nothing before, or previous
// segment is from a different message or is not a tool.
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
}
const shortLabelContent = () => { const shortLabelContent = () => {
if (segment.type === "tool") { if (segment.type === "tool") {
if (hasActivePermission()) { if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" /> return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
} }
return segment.shortLabel ?? getToolIcon("tool") return segment.shortLabel ?? getToolIcon("tool")
@@ -765,95 +975,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
} }
if (segment.type === "user") { if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" /> return <UserIcon class="message-timeline-icon" aria-hidden="true" />
} }
return <BotIcon class="message-timeline-icon" aria-hidden="true" /> return <BotIcon class="message-timeline-icon" aria-hidden="true" />
} }
return ( return (
<button <div class="message-timeline-item">
ref={(el) => registerButtonRef(segment.id, el)} <div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
type="button" <button
data-variant={segment.variant} type="button"
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`} data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined} data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined}
aria-current={isActive() ? "true" : undefined} aria-hidden={isHidden() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined} onClick={(event) => {
onClick={(event) => { if (wasLongPress) {
if (wasLongPress) { wasLongPress = false
wasLongPress = false return
return
}
// Capture scroll anchor before selection changes may toggle
// tool segment visibility, which shifts timeline layout.
const btn = buttonRefs.get(segment.id)
let anchorOffset: number | null = null
if (btn && scrollContainerRef) {
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
}
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
if (event.shiftKey) {
props.onSelectRange?.(segment.id)
} else if (event.ctrlKey || event.metaKey) {
props.onToggleSelection?.(segment.id)
} else if (isMultiSelectActive) {
// In selection mode, plain click scrolls to the message
// instead of clearing. Selection is cleared by clicking
// anywhere inside the chat container or pressing Esc.
props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
// Restore scroll anchor: keep the clicked badge at the same
// visual position after hidden tools appear or disappear.
if (anchorOffset !== null && btn && scrollContainerRef) {
const desired = btn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
} }
}
}} const btn = buttonRefs.get(segment.id)
onPointerDown={(e) => handlePointerDown(segment, e)} const stableBtn = renderVirtualizedTimeline() ? null : btn
onPointerUp={handlePointerUp} let anchorOffset: number | null = null
onPointerCancel={handlePointerUp} if (stableBtn && scrollContainerRef) {
onPointerMove={handlePointerMove} anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
onContextMenu={handleContextMenu} }
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave} const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span> if (event.shiftKey) {
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span> props.onSelectRange?.(segment.id)
</button> } else if (event.ctrlKey || event.metaKey) {
) props.onToggleSelection?.(segment.id)
}} } else if (isMultiSelectActive) {
</For> props.onSegmentClick?.(segment)
} else {
props.onSegmentClick?.(segment)
}
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
const desired = stableBtn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
}
}
}}
onPointerDown={(e) => handlePointerDown(segment, e)}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onPointerMove={handlePointerMove}
onContextMenu={handleContextMenu}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button>
</div>
)
}}
</Virtualizer>
</Show>
<Show when={previewData()}> <Show when={previewData()}>
{(data) => { {(data) => {
onCleanup(() => setTooltipElement(null)) onCleanup(() => setTooltipElement(null))
return ( return (
<div <Portal>
ref={(element) => setTooltipElement(element)} <div
class="message-timeline-tooltip" ref={(element) => setTooltipElement(element)}
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }} class="message-timeline-tooltip"
onMouseEnter={() => clearCloseTimer()} style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
onMouseLeave={() => scheduleClose()} onMouseEnter={() => clearCloseTimer()}
> onMouseLeave={() => scheduleClose()}
<MessagePreview >
messageId={data().messageId} <MessagePreview
instanceId={props.instanceId} messageId={data().messageId}
sessionId={props.sessionId} instanceId={props.instanceId}
store={store} sessionId={props.sessionId}
deleteHover={props.deleteHover} store={store}
onDeleteHoverChange={props.onDeleteHoverChange} deleteHover={props.deleteHover}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteHoverChange={props.onDeleteHoverChange}
selectedMessageIds={props.selectedMessageIds} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
/> selectedMessageIds={props.selectedMessageIds}
</div> />
</div>
</Portal>
) )
}} }}
</Show> </Show>

View File

@@ -120,6 +120,11 @@ export default function PromptInput(props: PromptInputProps) {
insertQuotedSelection(text) insertQuotedSelection(text)
} }
}, },
insertComment: (text: string) => {
const normalized = (text ?? "").replace(/\r/g, "").trim()
if (!normalized) return
insertBlockContent(`${normalized}\n\n`)
},
expandTextAttachment: (attachmentId: string) => { expandTextAttachment: (attachmentId: string) => {
const attachment = attachments().find((a) => a.id === attachmentId) const attachment = attachments().find((a) => a.id === attachmentId)
if (!attachment) return if (!attachment) return
@@ -540,6 +545,10 @@ export default function PromptInput(props: PromptInputProps) {
mode={pickerMode()} mode={pickerMode()}
onClose={handlePickerClose} onClose={handlePickerClose}
onSelect={handlePickerSelect} onSelect={handlePickerSelect}
onSubmitWithoutSelection={() => {
handlePickerClose()
void handleSend()
}}
agents={instanceAgents()} agents={instanceAgents()}
commands={getCommands(props.instanceId)} commands={getCommands(props.instanceId)}
instanceClient={instance()!.client} instanceClient={instance()!.client}

View File

@@ -7,6 +7,7 @@ export type PromptInsertMode = "quote" | "code"
export interface PromptInputApi { export interface PromptInputApi {
insertSelection(text: string, mode: PromptInsertMode): void insertSelection(text: string, mode: PromptInsertMode): void
insertComment(text: string): void
expandTextAttachment(attachmentId: string): void expandTextAttachment(attachmentId: string): void
removeAttachment(attachmentId: string): void removeAttachment(attachmentId: string): void
setPromptText(text: string, opts?: { focus?: boolean }): void setPromptText(text: string, opts?: { focus?: boolean }): void

View File

@@ -324,28 +324,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const pos = atPosition() const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) { if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos)) setIgnoredAtPositions((prev) => new Set(prev).add(pos))
// Remove the partial @mention text from the textarea when ESC is pressed
const textarea = options.getTextarea()
if (textarea) {
const currentPrompt = options.prompt()
const cursorPos = textarea.selectionStart
// Remove text from @ position to cursor position
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
options.setPrompt(before + after)
// Restore cursor position to where @ was
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
nextTextarea.setSelectionRange(pos, pos)
}
}, 0)
// Clear ignoredAtPositions so typing @ again will work
setIgnoredAtPositions(new Set<number>())
}
} }
setShowPicker(false) setShowPicker(false)
setAtPosition(null) setAtPosition(null)

View File

@@ -169,18 +169,25 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
const textarea = options.getTextarea() const textarea = options.getTextarea()
const start = textarea ? textarea.selectionStart : current.length const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length const end = textarea ? textarea.selectionEnd : current.length
const wasCursorAtEnd = end === current.length
const wasScrolledToBottom = textarea
? textarea.scrollHeight - (textarea.scrollTop + textarea.clientHeight) <= 4
: false
const before = current.slice(0, start) const before = current.slice(0, start)
const after = current.slice(end) const after = current.slice(end)
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : "" const prefix = ""
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : "" const suffix = after.length > 0 ? (/^\s/.test(after) ? "" : " ") : " "
const nextValue = `${before}${prefix}${text}${suffix}${after}` const nextValue = `${before}${prefix}${text}${suffix}${after}`
const cursor = before.length + prefix.length + text.length const cursor = before.length + prefix.length + text.length + suffix.length
options.setPrompt(nextValue) options.setPrompt(nextValue)
if (textarea) { if (textarea) {
setTimeout(() => { setTimeout(() => {
textarea.focus() textarea.focus()
textarea.setSelectionRange(cursor, cursor) textarea.setSelectionRange(cursor, cursor)
if (wasCursorAtEnd || wasScrolledToBottom) {
textarea.scrollTop = textarea.scrollHeight
}
}, 0) }, 0)
} }
} }

View File

@@ -36,6 +36,7 @@ interface SessionViewProps {
onSidebarToggle?: () => void onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean forceCompactStatusLayout?: boolean
isActive?: boolean isActive?: boolean
registerSessionPromptApi?: (sessionId: string, api: PromptInputApi | null) => void
} }
export const SessionView: Component<SessionViewProps> = (props) => { export const SessionView: Component<SessionViewProps> = (props) => {
@@ -79,11 +80,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
requestAnimationFrame(() => scrollToBottomHandle?.()) requestAnimationFrame(() => scrollToBottomHandle?.())
}) })
} }
createEffect(() => { createEffect(
if (!props.isActive) return on(
if (!shouldScrollToBottomOnActivate()) return () => props.isActive,
scheduleScrollToBottom() (isActive, wasActive) => {
}) if (!isActive) return
if (wasActive === true) return
if (!shouldScrollToBottomOnActivate()) return
scheduleScrollToBottom()
},
),
)
createEffect( createEffect(
on( on(
@@ -143,6 +150,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
function registerPromptInputApi(api: PromptInputApi) { function registerPromptInputApi(api: PromptInputApi) {
promptInputApi = api promptInputApi = api
props.registerSessionPromptApi?.(props.sessionId, api)
if (pendingPromptText) { if (pendingPromptText) {
api.setPromptText(pendingPromptText, { focus: true }) api.setPromptText(pendingPromptText, { focus: true })
@@ -157,6 +165,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
return () => { return () => {
if (promptInputApi === api) { if (promptInputApi === api) {
promptInputApi = null promptInputApi = null
props.registerSessionPromptApi?.(props.sessionId, null)
} }
} }
} }
@@ -332,16 +341,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
loading={messagesLoading()} loading={messagesLoading()}
onRevert={handleRevert} onRevert={handleRevert}
onDeleteMessagesUpTo={handleDeleteMessagesUpTo} onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
onFork={handleFork} onFork={handleFork}
isActive={props.isActive} isActive={props.isActive}
registerScrollToBottom={(fn) => { registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn scrollToBottomHandle = fn
if (props.isActive) { }}
if (shouldScrollToBottomOnActivate()) {
scheduleScrollToBottom()
}
}
}}

View File

@@ -334,7 +334,7 @@ const Field: Component<{
<div class="settings-toggle-title">{props.label}</div> <div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div> <div class="settings-toggle-caption">{props.caption}</div>
</div> </div>
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full"> <div class="flex items-center gap-2 w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
{props.icon} {props.icon}
<input <input
type={props.type ?? "text"} type={props.type ?? "text"}
@@ -361,7 +361,7 @@ const SelectField: Component<{
<div class="settings-toggle-title">{props.label}</div> <div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div> <div class="settings-toggle-caption">{props.caption}</div>
</div> </div>
<div class="min-w-[18rem] max-w-[24rem] w-full"> <div class="w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full"> <select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For> <For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select> </select>

View File

@@ -1,4 +1,4 @@
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js" import { createSignal, Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid" import { ArrowRightSquare, Check, Copy, Hourglass, Loader2, XCircle } from "lucide-solid"
import { stringify as stringifyYaml } from "yaml" import { stringify as stringifyYaml } from "yaml"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
@@ -44,6 +44,7 @@ import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { useSpeech } from "../lib/hooks/use-speech" import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button" import SpeechActionButton from "./speech-action-button"
import { createFollowScroll } from "../lib/follow-scroll"
const log = getLogger("session") const log = getLogger("session")
@@ -51,8 +52,6 @@ type ToolState = import("@opencode-ai/sdk/v2").ToolState
const TOOL_CALL_CACHE_SCOPE = "tool-call" const TOOL_CALL_CACHE_SCOPE = "tool-call"
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48 const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
const TOOL_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
function makeRenderCacheKey( function makeRenderCacheKey(
toolCallId?: string | null, toolCallId?: string | null,
@@ -82,6 +81,27 @@ interface ToolCallProps {
forceCollapsed?: boolean forceCollapsed?: boolean
} }
function ToolStatusIndicator(props: { status: Accessor<string> }) {
const isVisible = (value: string) => props.status() === value
return (
<span class="tool-call-header-status" aria-hidden="true" data-status={props.status() || "pending"}>
<span style={{ display: isVisible("pending") ? "inline-flex" : "none" }}>
<Hourglass class="w-4 h-4" />
</span>
<span style={{ display: isVisible("running") ? "inline-flex" : "none" }}>
<Loader2 class="w-4 h-4 animate-spin" />
</span>
<span style={{ display: isVisible("completed") ? "inline-flex" : "none" }}>
<Check class="w-4 h-4" />
</span>
<span style={{ display: isVisible("error") ? "inline-flex" : "none" }}>
<XCircle class="w-4 h-4" />
</span>
</span>
)
}
function ToolCallDetails(props: { function ToolCallDetails(props: {
toolCallMemo: () => ToolCallPart toolCallMemo: () => ToolCallPart
toolState: () => ToolState | undefined toolState: () => ToolState | undefined
@@ -166,179 +186,25 @@ function ToolCallDetails(props: {
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false) const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
const [permissionError, setPermissionError] = createSignal<string | null>(null) const [permissionError, setPermissionError] = createSignal<string | null>(null)
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>() const followScroll = createFollowScroll({
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null) getScrollTopSnapshot: props.scrollTopSnapshot,
const [autoScroll, setAutoScroll] = createSignal(true) setScrollTopSnapshot: props.setScrollTopSnapshot,
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) sentinelMarginPx: TOOL_SCROLL_SENTINEL_MARGIN_PX,
sentinelClassName: "tool-call-scroll-sentinel",
let scrollContainerRef: HTMLDivElement | undefined })
let detachScrollIntentListeners: (() => void) | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let userScrollIntentUntil = 0
let lastKnownScrollTop = props.scrollTopSnapshot()
function restoreScrollPosition(forceBottom = false) {
const container = scrollContainerRef
if (!container) return
if (forceBottom) {
container.scrollTop = container.scrollHeight
lastKnownScrollTop = container.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
} else {
container.scrollTop = lastKnownScrollTop
}
}
const persistScrollSnapshot = (element?: HTMLElement | null) => {
if (!element) return
lastKnownScrollTop = element.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
}
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + TOOL_SCROLL_INTENT_WINDOW_MS
}
function hasUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
const handlePointerIntent = () => markUserScrollIntent()
const handleKeyIntent = (event: KeyboardEvent) => {
if (TOOL_SCROLL_INTENT_KEYS.has(event.key)) {
markUserScrollIntent()
}
}
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handlePointerIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
}
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
const sentinel = bottomSentinel()
const container = scrollContainerRef
if (!sentinel || !container) return
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
const containerRect = container.getBoundingClientRect()
const sentinelRect = sentinel.getBoundingClientRect()
const delta = sentinelRect.bottom - containerRect.bottom + TOOL_SCROLL_SENTINEL_MARGIN_PX
if (Math.abs(delta) > 1) {
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
}
lastKnownScrollTop = container.scrollTop
props.setScrollTopSnapshot(lastKnownScrollTop)
})
}
function handleScroll() {
const container = scrollContainer()
if (!container) return
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
const isUserScroll = hasUserScrollIntent()
pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null
const atBottom = bottomSentinelVisible()
if (isUserScroll) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
})
}
const handleScrollEvent = (event: Event & { currentTarget: HTMLDivElement }) => {
handleScroll()
persistScrollSnapshot(event.currentTarget)
}
const handleScrollRendered = () => {
requestAnimationFrame(() => {
restoreScrollPosition(autoScroll())
scheduleAnchorScroll(true)
})
}
const initializeScrollContainer = (element: HTMLDivElement | null | undefined) => {
const next = element || undefined
if (next === scrollContainerRef) {
return
}
scrollContainerRef = next
setScrollContainer(scrollContainerRef)
if (scrollContainerRef) {
// Refresh our snapshot on mount (e.g. when remounting after collapse)
lastKnownScrollTop = props.scrollTopSnapshot()
restoreScrollPosition(autoScroll())
}
}
const scrollHelpers: ToolScrollHelpers = { const scrollHelpers: ToolScrollHelpers = {
registerContainer: (element, options) => { registerContainer: (element, options) => {
if (options?.disableTracking) return followScroll.registerContainer(element, options)
initializeScrollContainer(element)
},
handleScroll: handleScrollEvent,
renderSentinel: (options) => {
if (options?.disableTracking) return null
return <div ref={setBottomSentinel} aria-hidden="true" class="tool-call-scroll-sentinel" style={{ height: "1px" }} />
}, },
handleScroll: followScroll.handleScroll,
renderSentinel: followScroll.renderSentinel,
restoreAfterRender: followScroll.restoreAfterRender,
} }
createEffect(() => { const handleScrollRendered = () => {
const container = scrollContainer() scrollHelpers.restoreAfterRender()
if (!container) return }
attachScrollIntentListeners(container)
onCleanup(() => {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
})
createEffect(() => {
const container = scrollContainer()
const sentinel = bottomSentinel()
if (!container || !sentinel) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.target === sentinel) {
setBottomSentinelVisible(entry.isIntersecting)
}
})
},
{ root: container, threshold: 0, rootMargin: `0px 0px ${TOOL_SCROLL_SENTINEL_MARGIN_PX}px 0px` },
)
observer.observe(sentinel)
onCleanup(() => observer.disconnect())
})
createEffect(() => { createEffect(() => {
const permission = permissionDetails() const permission = permissionDetails()
@@ -564,11 +430,13 @@ function ToolCallDetails(props: {
partVersion={options.partVersion} partVersion={options.partVersion}
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={options.sessionId} sessionId={options.sessionId}
onContentRendered={props.onContentRendered}
forceCollapsed={options.forceCollapsed} forceCollapsed={options.forceCollapsed}
/> />
) )
}, },
scrollHelpers, scrollHelpers,
onContentRendered: props.onContentRendered,
} }
let previousPartVersion: number | undefined let previousPartVersion: number | undefined
@@ -581,12 +449,12 @@ function ToolCallDetails(props: {
return return
} }
previousPartVersion = version previousPartVersion = version
scheduleAnchorScroll(true) scrollHelpers.restoreAfterRender()
}) })
createEffect(() => { createEffect(() => {
if (autoScroll()) { if (followScroll.autoScroll()) {
scheduleAnchorScroll(true) scrollHelpers.restoreAfterRender()
} }
}) })
@@ -634,21 +502,6 @@ function ToolCallDetails(props: {
/> />
) )
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
return ( return (
<div class="tool-call-details"> <div class="tool-call-details">
<Show <Show
@@ -850,24 +703,6 @@ export default function ToolCall(props: ToolCallProps) {
return !current return !current
}) })
} }
const statusIcon = () => {
const status = toolState()?.status || ""
switch (status) {
case "pending":
return <Hourglass class="w-4 h-4" />
case "running":
return <Loader2 class="w-4 h-4 animate-spin" />
case "completed":
return <Check class="w-4 h-4" />
case "error":
return <XCircle class="w-4 h-4" />
default:
return ""
}
}
const statusClass = () => { const statusClass = () => {
const status = toolState()?.status || "pending" const status = toolState()?.status || "pending"
return `tool-call-status-${status}` return `tool-call-status-${status}`
@@ -1051,9 +886,7 @@ export default function ToolCall(props: ToolCallProps) {
/> />
</Show> </Show>
<span class="tool-call-header-status" aria-hidden="true"> <ToolStatusIndicator status={status} />
{statusIcon()}
</span>
</div> </div>
<Show when={expanded()}> <Show when={expanded()}>

View File

@@ -1,4 +1,4 @@
import type { Accessor, JSXElement } from "solid-js" import { createEffect, onCleanup, type Accessor, type JSXElement } from "solid-js"
import type { RenderCache } from "../../types/message" import type { RenderCache } from "../../types/message"
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi" import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
import { escapeHtml } from "../../lib/text-render-utils" import { escapeHtml } from "../../lib/text-render-utils"
@@ -11,6 +11,97 @@ type CacheHandle = {
set(value: unknown): void set(value: unknown): void
} }
export interface StableAnsiStreamUpdater {
update: (element: HTMLElement, content: string) => void
reset: () => void
}
export function createStableAnsiStreamUpdater(): StableAnsiStreamUpdater {
const renderer = createAnsiStreamRenderer()
let previousContent = ""
let ansiActive = false
return {
update(element: HTMLElement, content: string) {
const resetStreaming = !previousContent || !content.startsWith(previousContent)
if (resetStreaming) {
ansiActive = hasAnsi(content)
renderer.reset()
element.innerHTML = ansiActive ? renderer.render(content) : escapeHtml(content)
previousContent = content
return
}
const delta = content.slice(previousContent.length)
if (delta.length === 0) {
return
}
if (!ansiActive && hasAnsi(delta)) {
ansiActive = true
renderer.reset()
element.innerHTML = renderer.render(content)
previousContent = content
return
}
if (ansiActive) {
const htmlChunk = renderer.render(delta)
if (htmlChunk.length > 0) {
element.insertAdjacentHTML("beforeend", htmlChunk)
}
} else {
const escapedDelta = escapeHtml(delta)
if (escapedDelta.length > 0) {
element.insertAdjacentHTML("beforeend", escapedDelta)
}
}
previousContent = content
},
reset() {
previousContent = ""
ansiActive = false
renderer.reset()
},
}
}
function StreamingAnsiContent(props: {
html: string
htmlChunk?: string
updateMode: "replace" | "append" | "noop"
}) {
let preRef: HTMLPreElement | undefined
createEffect(() => {
const element = preRef
if (!element) return
if (props.updateMode === "noop") return
if (props.updateMode === "append") {
if (element.innerHTML.length === 0) {
element.innerHTML = props.html
return
}
const chunk = props.htmlChunk ?? ""
if (chunk.length > 0) {
element.insertAdjacentHTML("beforeend", chunk)
}
return
}
if (element.innerHTML !== props.html) {
element.innerHTML = props.html
}
})
onCleanup(() => {
preRef = undefined
})
return <pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
}
export function createAnsiContentRenderer(params: { export function createAnsiContentRenderer(params: {
ansiRunningCache: CacheHandle ansiRunningCache: CacheHandle
ansiFinalCache: CacheHandle ansiFinalCache: CacheHandle
@@ -46,6 +137,8 @@ export function createAnsiContentRenderer(params: {
const isRunningVariant = options.variant === "running" const isRunningVariant = options.variant === "running"
const disableScrollTracking = !isRunningVariant const disableScrollTracking = !isRunningVariant
const registerRef = disableScrollTracking ? registerUntracked : registerTracked const registerRef = disableScrollTracking ? registerUntracked : registerTracked
let updateMode: "replace" | "append" | "noop" = "replace"
let htmlChunk = ""
let nextCache: AnsiRenderCache let nextCache: AnsiRenderCache
@@ -54,6 +147,7 @@ export function createAnsiContentRenderer(params: {
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
if (resetStreaming) { if (resetStreaming) {
updateMode = "replace"
const detectedAnsi = hasAnsi(content) const detectedAnsi = hasAnsi(content)
if (detectedAnsi) { if (detectedAnsi) {
runningAnsiRenderer.reset() runningAnsiRenderer.reset()
@@ -66,15 +160,21 @@ export function createAnsiContentRenderer(params: {
} else { } else {
const delta = content.slice(cached.text.length) const delta = content.slice(cached.text.length)
if (delta.length === 0) { if (delta.length === 0) {
updateMode = "noop"
nextCache = { ...cached, mode } nextCache = { ...cached, mode }
} else if (!cached.hasAnsi && hasAnsi(delta)) { } else if (!cached.hasAnsi && hasAnsi(delta)) {
updateMode = "replace"
runningAnsiRenderer.reset() runningAnsiRenderer.reset()
const html = runningAnsiRenderer.render(content) const html = runningAnsiRenderer.render(content)
nextCache = { text: content, html, mode, hasAnsi: true } nextCache = { text: content, html, mode, hasAnsi: true }
} else if (cached.hasAnsi) { } else if (cached.hasAnsi) {
const htmlChunk = runningAnsiRenderer.render(delta) const appendedHtml = runningAnsiRenderer.render(delta)
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true } updateMode = "append"
htmlChunk = appendedHtml
nextCache = { text: content, html: `${cached.html}${appendedHtml}`, mode, hasAnsi: true }
} else { } else {
updateMode = "append"
htmlChunk = escapeHtml(delta)
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false } nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
} }
} }
@@ -98,7 +198,7 @@ export function createAnsiContentRenderer(params: {
return ( return (
<div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}> <div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} /> <StreamingAnsiContent html={nextCache.html} htmlChunk={htmlChunk} updateMode={updateMode} />
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )

View File

@@ -129,9 +129,7 @@ export function createDiffContentRenderer(params: {
const copyPatchTitle = () => params.t("toolCall.diff.copyPatch") const copyPatchTitle = () => params.t("toolCall.diff.copyPatch")
const handleDiffRendered = () => { const handleDiffRendered = () => {
if (!disableScrollTracking) { params.handleScrollRendered()
params.handleScrollRendered()
}
params.onContentRendered?.() params.onContentRendered?.()
} }

View File

@@ -1,6 +1,107 @@
import type { ToolRenderer } from "../types" import { Show, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer, ToolScrollHelpers } from "../types"
import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils" import { ensureMarkdownContent, formatUnknown, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, readToolStatePayload } from "../utils"
import { tGlobal } from "../../../lib/i18n" import { tGlobal } from "../../../lib/i18n"
import { createStableAnsiStreamUpdater } from "../ansi-render"
import { ansiToHtml, hasAnsi } from "../../../lib/ansi"
function RunningBashOutput(props: {
content: Accessor<string>
scrollHelpers?: ToolScrollHelpers
}) {
let preRef: HTMLPreElement | undefined
const updater = createStableAnsiStreamUpdater()
createEffect(() => {
const element = preRef
if (!element) return
updater.update(element, props.content())
})
onCleanup(() => {
preRef = undefined
updater.reset()
})
return (
<div
class="message-text tool-call-markdown"
ref={props.scrollHelpers?.registerContainer}
onScroll={props.scrollHelpers ? (event) => props.scrollHelpers!.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
>
<pre ref={preRef} class="tool-call-content tool-call-ansi" dir="auto" />
{props.scrollHelpers?.renderSentinel?.()}
</div>
)
}
function BashToolBody(props: {
toolState: Accessor<ToolState | undefined>
renderMarkdown: (options: { content: string }) => ReturnType<ToolRenderer["renderBody"]>
scrollHelpers?: ToolScrollHelpers
}) {
const state = createMemo(() => props.toolState())
const joinedContent = createMemo(() => {
const current = state()
if (!current || current.status === "pending") return ""
const { input, metadata } = readToolStatePayload(current)
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
const outputResult = formatUnknown(
isToolStateCompleted(current)
? current.output
: (isToolStateRunning(current) || isToolStateError(current)) && metadata.output
? metadata.output
: undefined,
)
return [command, outputResult?.text].filter(Boolean).join("\n")
})
const finalMarkdown = createMemo(() => {
const current = state()
const content = joinedContent()
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
return null
}
if (hasAnsi(content)) {
return null
}
return ensureMarkdownContent(content, "bash", true)
})
const finalAnsiHtml = createMemo(() => {
const current = state()
const content = joinedContent()
if (!current || current.status === "pending" || current.status === "running" || content.length === 0) {
return null
}
if (!hasAnsi(content)) {
return null
}
return ansiToHtml(content)
})
return (
<Show when={state() && joinedContent().length > 0}>
<Show
when={state()?.status === "running"}
fallback={
<Show when={finalAnsiHtml()} fallback={finalMarkdown() ? props.renderMarkdown({ content: finalMarkdown()! as string }) : null}>
{(html) => (
<div class="message-text tool-call-markdown" ref={props.scrollHelpers?.registerContainer}>
<pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={html()} />
</div>
)}
</Show>
}
>
<RunningBashOutput content={joinedContent} scrollHelpers={props.scrollHelpers} />
</Show>
</Show>
)
}
export const bashRenderer: ToolRenderer = { export const bashRenderer: ToolRenderer = {
tools: ["bash"], tools: ["bash"],
@@ -21,35 +122,7 @@ export const bashRenderer: ToolRenderer = {
const timeoutLabel = `${timeout}ms` const timeoutLabel = `${timeout}ms`
return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}` return `${baseTitle} · ${tGlobal("toolCall.renderer.bash.title.timeout", { timeout: timeoutLabel })}`
}, },
renderBody({ toolState, renderMarkdown, renderAnsi }) { renderBody({ toolState, renderMarkdown, scrollHelpers }) {
const state = toolState() return <BashToolBody toolState={toolState} renderMarkdown={renderMarkdown as any} scrollHelpers={scrollHelpers} />
if (!state || state.status === "pending") return null
const { input, metadata } = readToolStatePayload(state)
const command = typeof input.command === "string" && input.command.length > 0 ? `$ ${input.command}` : ""
const outputResult = formatUnknown(
isToolStateCompleted(state)
? state.output
: (isToolStateRunning(state) || isToolStateError(state)) && metadata.output
? metadata.output
: undefined,
)
const parts = [command, outputResult?.text].filter(Boolean)
if (parts.length === 0) return null
const joined = parts.join("\n")
if (state.status === "running") {
return renderAnsi({ content: joined, variant: "running" })
}
const ansiBody = renderAnsi({ content: joined, requireAnsi: true, variant: "final" })
if (ansiBody) {
return ansiBody
}
const content = ensureMarkdownContent(joined, "bash", true)
if (!content) return null
return renderMarkdown({ content })
}, },
} }

View File

@@ -1,4 +1,4 @@
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" import { For, Index, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer } from "../types" import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
@@ -145,7 +145,7 @@ export const taskRenderer: ToolRenderer = {
const { input } = readToolStatePayload(state) const { input } = readToolStatePayload(state)
return describeTaskTitle(input) return describeTaskTitle(input)
}, },
renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) { renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t, onContentRendered }) {
const store = messageStoreBus.getOrCreate(instanceId) const store = messageStoreBus.getOrCreate(instanceId)
const [requestedChildLoad, setRequestedChildLoad] = createSignal(false) const [requestedChildLoad, setRequestedChildLoad] = createSignal(false)
@@ -360,6 +360,14 @@ export const taskRenderer: ToolRenderer = {
}) })
}) })
createEffect(() => {
const childCount = childToolKeys().length
const legacyCount = legacyItems().length
if (childCount === 0 && legacyCount === 0) return
scrollHelpers?.restoreAfterRender()
onContentRendered?.()
})
return ( return (
<div class="tool-call-task-sections"> <div class="tool-call-task-sections">
<Show when={promptContent()}> <Show when={promptContent()}>
@@ -443,12 +451,12 @@ export const taskRenderer: ToolRenderer = {
} }
> >
<div class="tool-call-task-summary"> <div class="tool-call-task-summary">
<For each={childToolKeys()}> <Index each={childToolKeys()}>
{(key) => ( {(key) => (
<Show when={renderToolCall}> <Show when={renderToolCall}>
{(render) => ( {(render) => (
<TaskToolCallRow <TaskToolCallRow
toolKey={key} toolKey={key()}
store={store} store={store}
sessionId={childSessionId()} sessionId={childSessionId()}
renderToolCall={render()} renderToolCall={render()}
@@ -456,7 +464,7 @@ export const taskRenderer: ToolRenderer = {
)} )}
</Show> </Show>
)} )}
</For> </Index>
</div> </div>
{scrollHelpers?.renderSentinel?.()} {scrollHelpers?.renderSentinel?.()}
</div> </div>

View File

@@ -47,6 +47,7 @@ export interface ToolScrollHelpers {
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
restoreAfterRender(): void
} }
export interface ToolRendererContext { export interface ToolRendererContext {
@@ -74,6 +75,7 @@ export interface ToolRendererContext {
forceCollapsed?: boolean forceCollapsed?: boolean
}) => JSXElement | null }) => JSXElement | null
scrollHelpers?: ToolScrollHelpers scrollHelpers?: ToolScrollHelpers
onContentRendered?: () => void
} }
export interface ToolRenderer { export interface ToolRenderer {

View File

@@ -79,6 +79,7 @@ interface UnifiedPickerProps {
mode?: "mention" | "command" mode?: "mention" | "command"
onSelect: (item: PickerItem, action: PickerSelectAction) => void onSelect: (item: PickerItem, action: PickerSelectAction) => void
onClose: () => void onClose: () => void
onSubmitWithoutSelection?: () => void
agents: Agent[] agents: Agent[]
commands?: SDKCommand[] commands?: SDKCommand[]
instanceClient: OpencodeClient | null instanceClient: OpencodeClient | null
@@ -404,6 +405,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
if (selected) { if (selected) {
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter" const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
props.onSelect(selected, action) props.onSelect(selected, action)
} else if (e.key === "Enter" && mode() === "mention") {
props.onSubmitWithoutSelection?.()
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
e.preventDefault() e.preventDefault()

View File

@@ -2,6 +2,8 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor,
import { Virtualizer, type VirtualizerHandle } from "virtua/solid" import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48 const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
const USER_SCROLL_INTENT_WINDOW_MS = 600 const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"]) const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
@@ -85,6 +87,28 @@ export interface VirtualFollowListProps<T> {
*/ */
followToken?: Accessor<string | number> followToken?: Accessor<string | number>
/**
* Optional item key whose geometry can temporarily hold auto-follow when the
* rendered item grows taller than the viewport and reaches the top edge.
*/
autoPinHoldTargetKey?: Accessor<string | null>
/**
* Optional resolver for the specific element inside an item wrapper that
* should be measured for hold-target geometry.
*/
resolveAutoPinHoldElement?: (itemWrapper: HTMLDivElement, key: string) => HTMLElement | null | undefined
/**
* Top-edge threshold for the hold target in pixels.
*/
autoPinHoldTopThresholdPx?: number
/**
* Temporarily suppress automatic bottom pinning while keeping follow mode enabled.
*/
suspendAutoPinToBottom?: Accessor<boolean>
/** /**
* Optional hooks to render content inside the scroll container. * Optional hooks to render content inside the scroll container.
* Useful for empty/loading states that should scroll with the list. * Useful for empty/loading states that should scroll with the list.
@@ -130,13 +154,19 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true) const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true) const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true) const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
const externalSuspendAutoPinToBottom = () => (props.suspendAutoPinToBottom ? props.suspendAutoPinToBottom() : false)
const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null)
const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll())) const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [activeKey, setActiveKey] = createSignal<string | null>(null) const [activeKey, setActiveKey] = createSignal<string | null>(null)
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null)
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0)) const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const itemElements = new Map<string, HTMLDivElement>()
let userScrollIntentUntil = 0 let userScrollIntentUntil = 0
let lastUserScrollIntentDirection: "up" | "down" | null = null let lastUserScrollIntentDirection: "up" | "down" | null = null
@@ -220,6 +250,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Sync autoScroll state based on scroll position if it was a user scroll // Sync autoScroll state based on scroll position if it was a user scroll
if (hasUserScrollIntent()) { if (hasUserScrollIntent()) {
if (atBottom && heldItemCount() !== null) {
setHeldItemCount(null)
}
if (atBottom && !autoScroll()) { if (atBottom && !autoScroll()) {
setAutoScroll(true) setAutoScroll(true)
} else if (!atBottom && autoScroll()) { } else if (!atBottom && autoScroll()) {
@@ -253,6 +286,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
} }
updateScrollButtons() updateScrollButtons()
updateAutoPinHold()
props.onScroll?.() props.onScroll?.()
// Find active key (roughly the first visible item) // Find active key (roughly the first visible item)
@@ -270,6 +304,68 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
} }
function registerItemElement(key: string, element: HTMLDivElement | null | undefined) {
if (!element) {
itemElements.delete(key)
return
}
itemElements.set(key, element)
}
function getAnchorIdForKey(key: string) {
return props.getAnchorId ? props.getAnchorId(key) : key
}
function updateAutoPinHold() {
const element = scrollElement()
const itemCount = props.items().length
const heldCount = heldItemCount()
if (!element) return
if (heldCount !== null) {
if (itemCount > heldCount) {
setHeldItemCount(null)
if (autoScroll()) {
requestAnimationFrame(() => {
if (!autoScroll()) return
scrollToBottom(false)
})
}
return
}
if (itemCount < heldCount) {
setHeldItemCount(null)
return
}
return
}
if (!autoScroll()) return
if (externalSuspendAutoPinToBottom()) return
const targetKey = holdTargetKey()
if (!targetKey) return
const itemWrapper = itemElements.get(targetKey)
if (!itemWrapper) return
const target = props.resolveAutoPinHoldElement?.(itemWrapper, targetKey) ?? itemWrapper
const containerRect = element.getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const relativeTop = targetRect.top - containerRect.top
const exceedsViewport = targetRect.height > element.clientHeight
if (
exceedsViewport &&
relativeTop <= holdTargetTopThresholdPx() &&
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
) {
setHeldItemCount(itemCount)
}
}
const api: VirtualFollowListApi = { const api: VirtualFollowListApi = {
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true), scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }), scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
@@ -281,7 +377,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" }) virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
}, },
notifyContentRendered: () => { notifyContentRendered: () => {
if (autoScroll()) { updateAutoPinHold()
if (heldItemCount() !== null) return
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true) scrollToBottom(true)
} }
}, },
@@ -294,9 +392,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
createEffect(() => props.registerApi?.(api)) createEffect(() => props.registerApi?.(api))
createEffect(() => props.registerState?.(state)) createEffect(() => props.registerState?.(state))
createEffect(on(() => props.resetKey?.(), () => {
itemElements.clear()
setHeldItemCount(null)
}))
// Handle autoScroll (Follow) on items change // Handle autoScroll (Follow) on items change
createEffect(on(() => props.items().length, (len, prevLen) => { createEffect(on(() => props.items().length, (len, prevLen) => {
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) { updateAutoPinHold()
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
requestAnimationFrame(() => scrollToBottom(true)) requestAnimationFrame(() => scrollToBottom(true))
} }
suppressAutoScrollOnce = false suppressAutoScrollOnce = false
@@ -304,11 +408,16 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
// Handle followToken change // Handle followToken change
createEffect(on(() => props.followToken?.(), () => { createEffect(on(() => props.followToken?.(), () => {
if (autoScroll()) { updateAutoPinHold()
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
scrollToBottom(true) scrollToBottom(true)
} }
}, { defer: true })) }, { defer: true }))
createEffect(on(() => holdTargetKey(), () => {
updateAutoPinHold()
}, { defer: true }))
// Reset state on resetKey change // Reset state on resetKey change
createEffect(on(() => props.resetKey?.(), (nextKey) => { createEffect(on(() => props.resetKey?.(), (nextKey) => {
if (nextKey === lastResetKey) return if (nextKey === lastResetKey) return
@@ -331,6 +440,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
} }
}) })
createEffect(() => {
if (typeof window === "undefined") return
const handleResize = () => updateAutoPinHold()
window.addEventListener("resize", handleResize)
onCleanup(() => window.removeEventListener("resize", handleResize))
})
return ( return (
<div class="virtual-follow-list-shell" ref={shellElement => { <div class="virtual-follow-list-shell" ref={shellElement => {
setShellElement(shellElement) setShellElement(shellElement)
@@ -356,7 +472,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
bufferSize={props.overscanPx ?? 400} bufferSize={props.overscanPx ?? 400}
onScroll={handleScroll} onScroll={handleScroll}
> >
{(item, index) => props.renderItem(item, index())} {(item, index) => {
const key = props.getKey(item, index())
const anchorId = getAnchorIdForKey(key)
return (
<div id={anchorId} data-virtual-follow-key={key} ref={(element) => registerItemElement(key, element)}>
{props.renderItem(item, index())}
</div>
)
}}
</Virtualizer> </Virtualizer>
</div> </div>

View File

@@ -15,6 +15,11 @@ import type {
RemoteServerProbeRequest, RemoteServerProbeRequest,
RemoteServerProbeResponse, RemoteServerProbeResponse,
VoiceModeStateResponse, VoiceModeStateResponse,
WorktreeGitCommitRequest,
WorktreeGitCommitResponse,
WorktreeGitDiffRequest,
WorktreeGitMutationResponse,
WorktreeGitPathsRequest,
WorkspaceCreateRequest, WorkspaceCreateRequest,
WorkspaceDescriptor, WorkspaceDescriptor,
WorkspaceFileResponse, WorkspaceFileResponse,
@@ -26,6 +31,8 @@ import type {
WorktreeListResponse, WorktreeListResponse,
WorktreeMap, WorktreeMap,
WorktreeCreateRequest, WorktreeCreateRequest,
WorktreeGitDiffResponse,
WorktreeGitStatusResponse,
} from "../../../server/src/api-types" } from "../../../server/src/api-types"
import { getClientIdentity } from "./client-identity" import { getClientIdentity } from "./client-identity"
import { getLogger } from "./logger" import { getLogger } from "./logger"
@@ -98,6 +105,25 @@ function logHttp(message: string, context?: Record<string, unknown>) {
httpLogger.info(message) httpLogger.info(message)
} }
async function readErrorMessage(response: Response): Promise<string> {
const text = await response.text()
if (!text) return `Request failed with ${response.status}`
try {
const parsed = JSON.parse(text) as { error?: unknown; message?: unknown }
if (typeof parsed?.error === "string" && parsed.error.trim()) {
return parsed.error
}
if (typeof parsed?.message === "string" && parsed.message.trim()) {
return parsed.message
}
} catch {
// Keep the original body for plain-text responses.
}
return text
}
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const url = API_BASE ? new URL(path, API_BASE).toString() : path const url = API_BASE ? new URL(path, API_BASE).toString() : path
const headers = normalizeHeaders(init?.headers) const headers = normalizeHeaders(init?.headers)
@@ -112,7 +138,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
try { try {
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" }) const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) { if (!response.ok) {
const message = await response.text() const message = await readErrorMessage(response)
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message }) logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
throw new Error(message || `Request failed with ${response.status}`) throw new Error(message || `Request failed with ${response.status}`)
} }
@@ -141,7 +167,7 @@ async function requestRaw(path: string, init?: RequestInit): Promise<Response> {
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" }) const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) { if (!response.ok) {
const message = await response.text() const message = await readErrorMessage(response)
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message }) logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
throw new Error(message || `Request failed with ${response.status}`) throw new Error(message || `Request failed with ${response.status}`)
} }
@@ -282,6 +308,47 @@ export const serverApi = {
}, },
) )
}, },
fetchWorktreeGitStatus(id: string, slug: string): Promise<WorktreeGitStatusResponse> {
return request<WorktreeGitStatusResponse>(
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-status`,
)
},
fetchWorktreeGitDiff(id: string, slug: string, requestPayload: WorktreeGitDiffRequest): Promise<WorktreeGitDiffResponse> {
const params = new URLSearchParams({ path: requestPayload.path, scope: requestPayload.scope })
if (requestPayload.originalPath) {
params.set("originalPath", requestPayload.originalPath)
}
return request<WorktreeGitDiffResponse>(
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-diff?${params.toString()}`,
)
},
stageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
return request<WorktreeGitMutationResponse>(
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-stage`,
{
method: "POST",
body: JSON.stringify(payload),
},
)
},
unstageWorktreeGitPaths(id: string, slug: string, payload: WorktreeGitPathsRequest): Promise<WorktreeGitMutationResponse> {
return request<WorktreeGitMutationResponse>(
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-unstage`,
{
method: "POST",
body: JSON.stringify(payload),
},
)
},
commitWorktreeGitChanges(id: string, slug: string, payload: WorktreeGitCommitRequest): Promise<WorktreeGitCommitResponse> {
return request<WorktreeGitCommitResponse>(
`/api/workspaces/${encodeURIComponent(id)}/worktrees/${encodeURIComponent(slug)}/git-commit`,
{
method: "POST",
body: JSON.stringify(payload),
},
)
},
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> { fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`) return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)

View File

@@ -2,6 +2,7 @@ const HUNK_PATTERN = /(^|\n)@@/m
const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/ const FILE_MARKER_PATTERN = /(^|\n)(diff --git |--- |\+\+\+)/
const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/ const BEGIN_PATCH_PATTERN = /^\*\*\* (Begin|End) Patch/
const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/ const UPDATE_FILE_PATTERN = /^\*\*\* Update File: (.+)$/
const HUNK_HEADER_PATTERN = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
function stripCodeFence(value: string): string { function stripCodeFence(value: string): string {
const trimmed = value.trim() const trimmed = value.trim()
@@ -48,3 +49,48 @@ export function isRenderableDiffText(raw?: string | null): raw is string {
if (!normalized) return false if (!normalized) return false
return HUNK_PATTERN.test(normalized) return HUNK_PATTERN.test(normalized)
} }
export function parsePatchToBeforeAfter(patch: string): { before: string; after: string } {
if (!patch || patch.trim().length === 0) {
return { before: "", after: "" }
}
const lines = patch.replace(/\r\n/g, "\n").split("\n")
const beforeLines: string[] = []
const afterLines: string[] = []
for (const line of lines) {
if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("diff --git")) {
continue
}
if (HUNK_HEADER_PATTERN.test(line)) {
continue
}
if (line.startsWith("-") && !line.startsWith("---")) {
beforeLines.push(line.slice(1))
} else if (line.startsWith("+") && !line.startsWith("+++")) {
afterLines.push(line.slice(1))
} else if (line.startsWith(" ")) {
beforeLines.push(line.slice(1))
afterLines.push(line.slice(1))
} else if (line === "") {
beforeLines.push("")
afterLines.push("")
} else {
beforeLines.push(line)
afterLines.push(line)
}
}
while (beforeLines.length > 0 && beforeLines[beforeLines.length - 1] === "") {
beforeLines.pop()
}
while (afterLines.length > 0 && afterLines[afterLines.length - 1] === "") {
afterLines.pop()
}
return {
before: beforeLines.join("\n"),
after: afterLines.join("\n"),
}
}

View File

@@ -0,0 +1,262 @@
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
const DEFAULT_SCROLL_INTENT_WINDOW_MS = 600
const DEFAULT_SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
interface FollowScrollOptions {
getScrollTopSnapshot: Accessor<number>
setScrollTopSnapshot: (next: number) => void
sentinelMarginPx: number
sentinelClassName: string
intentWindowMs?: number
intentKeys?: ReadonlySet<string>
}
export interface FollowScrollHelpers {
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
restoreAfterRender: () => void
autoScroll: Accessor<boolean>
}
export function createFollowScroll(options: FollowScrollOptions): FollowScrollHelpers {
const [scrollContainer, setScrollContainer] = createSignal<HTMLDivElement | undefined>()
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
const [autoScroll, setAutoScroll] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
let scrollContainerRef: HTMLDivElement | undefined
let detachScrollIntentListeners: (() => void) | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let userScrollIntentUntil = 0
let lastKnownScrollTop = options.getScrollTopSnapshot()
let pointerInteractionActive = false
let suppressNextScrollHandling = false
function restoreScrollPosition(forceBottom = false) {
const container = scrollContainerRef
if (!container) return
suppressNextScrollHandling = true
if (forceBottom) {
container.scrollTop = container.scrollHeight
lastKnownScrollTop = container.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
} else {
container.scrollTop = lastKnownScrollTop
}
}
function persistScrollSnapshot(element?: HTMLElement | null) {
if (!element) return
lastKnownScrollTop = element.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
}
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + (options.intentWindowMs ?? DEFAULT_SCROLL_INTENT_WINDOW_MS)
}
function hasUserScrollIntent() {
if (pointerInteractionActive) {
return true
}
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
const intentKeys = options.intentKeys ?? DEFAULT_SCROLL_INTENT_KEYS
const handlePointerIntent = () => {
pointerInteractionActive = true
markUserScrollIntent()
}
const clearPointerIntent = () => {
pointerInteractionActive = false
}
const handleKeyIntent = (event: KeyboardEvent) => {
if (intentKeys.has(event.key)) {
markUserScrollIntent()
}
}
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
if (typeof window !== "undefined") {
window.addEventListener("pointerup", clearPointerIntent)
window.addEventListener("pointercancel", clearPointerIntent)
window.addEventListener("mouseup", clearPointerIntent)
window.addEventListener("touchend", clearPointerIntent)
window.addEventListener("touchcancel", clearPointerIntent)
}
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handlePointerIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
if (typeof window !== "undefined") {
window.removeEventListener("pointerup", clearPointerIntent)
window.removeEventListener("pointercancel", clearPointerIntent)
window.removeEventListener("mouseup", clearPointerIntent)
window.removeEventListener("touchend", clearPointerIntent)
window.removeEventListener("touchcancel", clearPointerIntent)
}
pointerInteractionActive = false
}
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
const sentinel = bottomSentinel()
const container = scrollContainerRef
if (!sentinel || !container) return
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
const containerRect = container.getBoundingClientRect()
const sentinelRect = sentinel.getBoundingClientRect()
const delta = sentinelRect.bottom - containerRect.bottom + options.sentinelMarginPx
if (Math.abs(delta) > 1) {
suppressNextScrollHandling = true
container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" })
}
lastKnownScrollTop = container.scrollTop
options.setScrollTopSnapshot(lastKnownScrollTop)
})
}
function isAtBottom(container: HTMLDivElement) {
return container.scrollHeight - (container.scrollTop + container.clientHeight) <= options.sentinelMarginPx
}
function updateFollowModeFromScroll(containerOverride?: HTMLDivElement) {
const container = containerOverride ?? scrollContainer()
if (!container) return
if (suppressNextScrollHandling) {
suppressNextScrollHandling = false
return
}
const isUserScroll = hasUserScrollIntent()
const atBottomFromScroll = isAtBottom(container)
const atBottom = atBottomFromScroll || bottomSentinelVisible()
if (isUserScroll || !atBottom) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
updateFollowModeFromScroll(event.currentTarget)
persistScrollSnapshot(event.currentTarget)
}
const registerContainer = (element: HTMLDivElement | null | undefined, config?: { disableTracking?: boolean }) => {
const next = element || undefined
if (next === scrollContainerRef) {
return
}
scrollContainerRef = next
setScrollContainer(scrollContainerRef)
if (scrollContainerRef) {
lastKnownScrollTop = options.getScrollTopSnapshot()
restoreScrollPosition(autoScroll())
}
}
const renderSentinel = (config?: { disableTracking?: boolean }) => {
if (config?.disableTracking) return null
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
}
const restoreAfterRender = () => {
const container = scrollContainerRef
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
if (autoScroll()) {
setAutoScroll(false)
}
requestAnimationFrame(() => {
restoreScrollPosition(false)
})
return
}
// Never let a render-time caller force follow mode back on after the user
// has already escaped it. Staying pinned should depend on the current
// follow state, not on a caller opting into forceBottom.
const shouldFollow = autoScroll()
requestAnimationFrame(() => {
restoreScrollPosition(shouldFollow)
if (shouldFollow) {
scheduleAnchorScroll(true)
}
})
}
createEffect(() => {
const container = scrollContainer()
if (!container) return
attachScrollIntentListeners(container)
onCleanup(() => {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
})
createEffect(() => {
const container = scrollContainer()
const sentinel = bottomSentinel()
if (!container || !sentinel) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.target === sentinel) {
setBottomSentinelVisible(entry.isIntersecting)
}
})
},
{ root: container, threshold: 0, rootMargin: `0px 0px ${options.sentinelMarginPx}px 0px` },
)
observer.observe(sentinel)
onCleanup(() => observer.disconnect())
})
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
})
return {
registerContainer,
handleScroll,
renderSentinel,
restoreAfterRender,
autoScroll,
}
}

View File

@@ -131,6 +131,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Loading git changes...", "instanceShell.gitChanges.loading": "Loading git changes...",
"instanceShell.gitChanges.empty": "No git changes yet.", "instanceShell.gitChanges.empty": "No git changes yet.",
"instanceShell.gitChanges.deleted": "Deleted", "instanceShell.gitChanges.deleted": "Deleted",
"instanceShell.gitChanges.binaryViewer": "Binary file cannot be displayed",
"instanceShell.gitChanges.sections.staged": "Staged Changes",
"instanceShell.gitChanges.sections.unstaged": "Changes",
"instanceShell.gitChanges.actions.insertContext": "Add to prompt",
"instanceShell.gitChanges.actions.stage": "Stage file",
"instanceShell.gitChanges.actions.unstage": "Unstage file",
"instanceShell.gitChanges.commit.placeholder": "Enter commit message",
"instanceShell.gitChanges.commit.submit": "Commit",
"instanceShell.gitChanges.commit.submitting": "Committing...",
"instanceShell.gitChanges.commit.success": "Commit created successfully",
"instanceShell.gitChanges.commit.error": "Failed to create commit",
"instanceShell.filesShell.fileListTitle": "File list", "instanceShell.filesShell.fileListTitle": "File list",
"instanceShell.filesShell.mobileSelectorLabel": "Select file", "instanceShell.filesShell.mobileSelectorLabel": "Select file",
@@ -160,6 +171,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "No background processes.", "instanceShell.backgroundProcesses.empty": "No background processes.",
"instanceShell.backgroundProcesses.status": "Status: {status}", "instanceShell.backgroundProcesses.status": "Status: {status}",
"instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Output: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Completion notification enabled",
"instanceShell.backgroundProcesses.notify.disabled": "Completion notification disabled",
"instanceShell.backgroundProcesses.actions.output": "Output", "instanceShell.backgroundProcesses.actions.output": "Output",
"instanceShell.backgroundProcesses.actions.stop": "Stop", "instanceShell.backgroundProcesses.actions.stop": "Stop",
"instanceShell.backgroundProcesses.actions.terminate": "Terminate", "instanceShell.backgroundProcesses.actions.terminate": "Terminate",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Loading messages...", "messageSection.loading.messages": "Loading messages...",
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message", "messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message", "messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
"messageSection.scroll.enableHoldAriaLabel": "Enable hold for long assistant replies",
"messageSection.scroll.disableHoldAriaLabel": "Disable hold for long assistant replies",
"messageSection.quote.addAsQuote": "Add as quote", "messageSection.quote.addAsQuote": "Add as quote",
"messageSection.quote.addAsCode": "Add as code", "messageSection.quote.addAsCode": "Add as code",
"messageSection.quote.copy": "Copy", "messageSection.quote.copy": "Copy",

View File

@@ -130,6 +130,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Cargando cambios de Git...", "instanceShell.gitChanges.loading": "Cargando cambios de Git...",
"instanceShell.gitChanges.empty": "Aún no hay cambios de Git.", "instanceShell.gitChanges.empty": "Aún no hay cambios de Git.",
"instanceShell.gitChanges.deleted": "Eliminado", "instanceShell.gitChanges.deleted": "Eliminado",
"instanceShell.gitChanges.binaryViewer": "No se puede mostrar un archivo binario",
"instanceShell.gitChanges.sections.staged": "Cambios preparados",
"instanceShell.gitChanges.sections.unstaged": "Cambios",
"instanceShell.gitChanges.actions.insertContext": "Agregar al prompt",
"instanceShell.gitChanges.actions.stage": "Preparar archivo",
"instanceShell.gitChanges.actions.unstage": "Quitar del área preparada",
"instanceShell.gitChanges.commit.placeholder": "Escribe el mensaje del commit",
"instanceShell.gitChanges.commit.submit": "Commit",
"instanceShell.gitChanges.commit.submitting": "Confirmando...",
"instanceShell.gitChanges.commit.success": "Commit creado correctamente",
"instanceShell.gitChanges.commit.error": "No se pudo crear el commit",
"instanceShell.filesShell.fileListTitle": "Lista de archivos", "instanceShell.filesShell.fileListTitle": "Lista de archivos",
"instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo", "instanceShell.filesShell.mobileSelectorLabel": "Seleccionar archivo",
@@ -150,6 +161,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.", "instanceShell.backgroundProcesses.empty": "No hay procesos en segundo plano.",
"instanceShell.backgroundProcesses.status": "Estado: {status}", "instanceShell.backgroundProcesses.status": "Estado: {status}",
"instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB", "instanceShell.backgroundProcesses.output": "Salida: {sizeKb} KB",
"instanceShell.backgroundProcesses.notify.enabled": "Notificacion de finalizacion activada",
"instanceShell.backgroundProcesses.notify.disabled": "Notificacion de finalizacion desactivada",
"instanceShell.backgroundProcesses.actions.output": "Salida", "instanceShell.backgroundProcesses.actions.output": "Salida",
"instanceShell.backgroundProcesses.actions.stop": "Detener", "instanceShell.backgroundProcesses.actions.stop": "Detener",
"instanceShell.backgroundProcesses.actions.terminate": "Terminar", "instanceShell.backgroundProcesses.actions.terminate": "Terminar",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Cargando mensajes...", "messageSection.loading.messages": "Cargando mensajes...",
"messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje", "messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje",
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje", "messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
"messageSection.scroll.enableHoldAriaLabel": "Activar pausa para respuestas largas del asistente",
"messageSection.scroll.disableHoldAriaLabel": "Desactivar pausa para respuestas largas del asistente",
"messageSection.quote.addAsQuote": "Añadir como cita", "messageSection.quote.addAsQuote": "Añadir como cita",
"messageSection.quote.addAsCode": "Añadir como código", "messageSection.quote.addAsCode": "Añadir como código",
"messageSection.quote.copy": "Copiar", "messageSection.quote.copy": "Copiar",

View File

@@ -130,6 +130,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Chargement des changements Git...", "instanceShell.gitChanges.loading": "Chargement des changements Git...",
"instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.", "instanceShell.gitChanges.empty": "Aucun changement Git pour l'instant.",
"instanceShell.gitChanges.deleted": "Supprimé", "instanceShell.gitChanges.deleted": "Supprimé",
"instanceShell.gitChanges.binaryViewer": "Impossible d'afficher un fichier binaire",
"instanceShell.gitChanges.sections.staged": "Changements indexés",
"instanceShell.gitChanges.sections.unstaged": "Changements",
"instanceShell.gitChanges.actions.insertContext": "Ajouter au prompt",
"instanceShell.gitChanges.actions.stage": "Indexer le fichier",
"instanceShell.gitChanges.actions.unstage": "Retirer de l'index",
"instanceShell.gitChanges.commit.placeholder": "Saisissez le message du commit",
"instanceShell.gitChanges.commit.submit": "Valider",
"instanceShell.gitChanges.commit.submitting": "Validation...",
"instanceShell.gitChanges.commit.success": "Commit créé avec succès",
"instanceShell.gitChanges.commit.error": "Impossible de créer le commit",
"instanceShell.filesShell.fileListTitle": "Liste des fichiers", "instanceShell.filesShell.fileListTitle": "Liste des fichiers",
"instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier", "instanceShell.filesShell.mobileSelectorLabel": "Sélectionner un fichier",
@@ -150,6 +161,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.", "instanceShell.backgroundProcesses.empty": "Aucun processus en arrière-plan.",
"instanceShell.backgroundProcesses.status": "Statut : {status}", "instanceShell.backgroundProcesses.status": "Statut : {status}",
"instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Sortie : {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Notification de fin activee",
"instanceShell.backgroundProcesses.notify.disabled": "Notification de fin desactivee",
"instanceShell.backgroundProcesses.actions.output": "Sortie", "instanceShell.backgroundProcesses.actions.output": "Sortie",
"instanceShell.backgroundProcesses.actions.stop": "Arrêter", "instanceShell.backgroundProcesses.actions.stop": "Arrêter",
"instanceShell.backgroundProcesses.actions.terminate": "Terminer", "instanceShell.backgroundProcesses.actions.terminate": "Terminer",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Chargement des messages...", "messageSection.loading.messages": "Chargement des messages...",
"messageSection.scroll.toFirstAriaLabel": "Aller au premier message", "messageSection.scroll.toFirstAriaLabel": "Aller au premier message",
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message", "messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
"messageSection.scroll.enableHoldAriaLabel": "Activer le maintien pour les longues réponses de l'assistant",
"messageSection.scroll.disableHoldAriaLabel": "Désactiver le maintien pour les longues réponses de l'assistant",
"messageSection.quote.addAsQuote": "Ajouter en citation", "messageSection.quote.addAsQuote": "Ajouter en citation",
"messageSection.quote.addAsCode": "Ajouter en code", "messageSection.quote.addAsCode": "Ajouter en code",
"messageSection.quote.copy": "Copier", "messageSection.quote.copy": "Copier",

View File

@@ -138,6 +138,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.", "instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
"instanceShell.gitChanges.loading": "טוען שינויי Git…", "instanceShell.gitChanges.loading": "טוען שינויי Git…",
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.", "instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",
"instanceShell.gitChanges.binaryViewer": "לא ניתן להציג קובץ בינארי",
"instanceShell.gitChanges.sections.staged": "שינויים שנשמרו ל-staging",
"instanceShell.gitChanges.sections.unstaged": "שינויים",
"instanceShell.gitChanges.actions.insertContext": "הוסף לפרומפט",
"instanceShell.gitChanges.actions.stage": "העבר ל-staging",
"instanceShell.gitChanges.actions.unstage": "הוצא מ-staging",
"instanceShell.gitChanges.commit.placeholder": "הזן הודעת commit",
"instanceShell.gitChanges.commit.submit": "Commit",
"instanceShell.gitChanges.commit.submitting": "מבצע commit...",
"instanceShell.gitChanges.commit.success": "ה-commit נוצר בהצלחה",
"instanceShell.gitChanges.commit.error": "יצירת ה-commit נכשלה",
"instanceShell.diff.hideUnchanged": "הסתר אזורים ללא שינוי", "instanceShell.diff.hideUnchanged": "הסתר אזורים ללא שינוי",
"instanceShell.diff.showFull": "הצג קובץ מלא", "instanceShell.diff.showFull": "הצג קובץ מלא",
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת", "instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
@@ -158,6 +169,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.", "instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
"instanceShell.backgroundProcesses.status": "סטטוס: {status}", "instanceShell.backgroundProcesses.status": "סטטוס: {status}",
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "התראת סיום פעילה",
"instanceShell.backgroundProcesses.notify.disabled": "התראת סיום כבויה",
"instanceShell.backgroundProcesses.actions.output": "פלט", "instanceShell.backgroundProcesses.actions.output": "פלט",
"instanceShell.backgroundProcesses.actions.stop": "עצור", "instanceShell.backgroundProcesses.actions.stop": "עצור",
"instanceShell.backgroundProcesses.actions.terminate": "סיים", "instanceShell.backgroundProcesses.actions.terminate": "סיים",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "טוען הודעות...", "messageSection.loading.messages": "טוען הודעות...",
"messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה", "messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה",
"messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה", "messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה",
"messageSection.scroll.enableHoldAriaLabel": "הפעל עצירה לתגובות עוזר ארוכות",
"messageSection.scroll.disableHoldAriaLabel": "כבה עצירה לתגובות עוזר ארוכות",
"messageSection.quote.addAsQuote": "הוסף כציטוט", "messageSection.quote.addAsQuote": "הוסף כציטוט",
"messageSection.quote.addAsCode": "הוסף כקוד", "messageSection.quote.addAsCode": "הוסף כקוד",
"messageSection.quote.copy": "העתק", "messageSection.quote.copy": "העתק",

View File

@@ -130,6 +130,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Git の変更を読み込み中...", "instanceShell.gitChanges.loading": "Git の変更を読み込み中...",
"instanceShell.gitChanges.empty": "Git の変更はまだありません。", "instanceShell.gitChanges.empty": "Git の変更はまだありません。",
"instanceShell.gitChanges.deleted": "削除済み", "instanceShell.gitChanges.deleted": "削除済み",
"instanceShell.gitChanges.binaryViewer": "バイナリファイルは表示できません",
"instanceShell.gitChanges.sections.staged": "ステージ済みの変更",
"instanceShell.gitChanges.sections.unstaged": "変更",
"instanceShell.gitChanges.actions.insertContext": "プロンプトに追加",
"instanceShell.gitChanges.actions.stage": "ファイルをステージ",
"instanceShell.gitChanges.actions.unstage": "ステージ解除",
"instanceShell.gitChanges.commit.placeholder": "コミットメッセージを入力",
"instanceShell.gitChanges.commit.submit": "コミット",
"instanceShell.gitChanges.commit.submitting": "コミット中...",
"instanceShell.gitChanges.commit.success": "コミットを作成しました",
"instanceShell.gitChanges.commit.error": "コミットを作成できませんでした",
"instanceShell.filesShell.fileListTitle": "ファイル一覧", "instanceShell.filesShell.fileListTitle": "ファイル一覧",
"instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択", "instanceShell.filesShell.mobileSelectorLabel": "ファイルを選択",
@@ -150,6 +161,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。", "instanceShell.backgroundProcesses.empty": "バックグラウンドプロセスはありません。",
"instanceShell.backgroundProcesses.status": "状態: {status}", "instanceShell.backgroundProcesses.status": "状態: {status}",
"instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "出力: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "完了通知が有効",
"instanceShell.backgroundProcesses.notify.disabled": "完了通知が無効",
"instanceShell.backgroundProcesses.actions.output": "出力", "instanceShell.backgroundProcesses.actions.output": "出力",
"instanceShell.backgroundProcesses.actions.stop": "停止", "instanceShell.backgroundProcesses.actions.stop": "停止",
"instanceShell.backgroundProcesses.actions.terminate": "終了", "instanceShell.backgroundProcesses.actions.terminate": "終了",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "メッセージを読み込み中...", "messageSection.loading.messages": "メッセージを読み込み中...",
"messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール", "messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール",
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール", "messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
"messageSection.scroll.enableHoldAriaLabel": "長いアシスタント返信の保持を有効にする",
"messageSection.scroll.disableHoldAriaLabel": "長いアシスタント返信の保持を無効にする",
"messageSection.quote.addAsQuote": "引用として追加", "messageSection.quote.addAsQuote": "引用として追加",
"messageSection.quote.addAsCode": "コードとして追加", "messageSection.quote.addAsCode": "コードとして追加",
"messageSection.quote.copy": "コピー", "messageSection.quote.copy": "コピー",

View File

@@ -130,6 +130,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "Загрузка изменений Git...", "instanceShell.gitChanges.loading": "Загрузка изменений Git...",
"instanceShell.gitChanges.empty": "Изменений Git пока нет.", "instanceShell.gitChanges.empty": "Изменений Git пока нет.",
"instanceShell.gitChanges.deleted": "Удалено", "instanceShell.gitChanges.deleted": "Удалено",
"instanceShell.gitChanges.binaryViewer": "Невозможно показать бинарный файл",
"instanceShell.gitChanges.sections.staged": "Подготовленные изменения",
"instanceShell.gitChanges.sections.unstaged": "Изменения",
"instanceShell.gitChanges.actions.insertContext": "Добавить в промпт",
"instanceShell.gitChanges.actions.stage": "Подготовить файл",
"instanceShell.gitChanges.actions.unstage": "Убрать из staging",
"instanceShell.gitChanges.commit.placeholder": "Введите сообщение коммита",
"instanceShell.gitChanges.commit.submit": "Commit",
"instanceShell.gitChanges.commit.submitting": "Создание commit...",
"instanceShell.gitChanges.commit.success": "Commit успешно создан",
"instanceShell.gitChanges.commit.error": "Не удалось создать commit",
"instanceShell.filesShell.fileListTitle": "Список файлов", "instanceShell.filesShell.fileListTitle": "Список файлов",
"instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл", "instanceShell.filesShell.mobileSelectorLabel": "Выбрать файл",
@@ -150,6 +161,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.", "instanceShell.backgroundProcesses.empty": "Нет фоновых процессов.",
"instanceShell.backgroundProcesses.status": "Статус: {status}", "instanceShell.backgroundProcesses.status": "Статус: {status}",
"instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB", "instanceShell.backgroundProcesses.output": "Вывод: {sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "Уведомление о завершении включено",
"instanceShell.backgroundProcesses.notify.disabled": "Уведомление о завершении выключено",
"instanceShell.backgroundProcesses.actions.output": "Вывод", "instanceShell.backgroundProcesses.actions.output": "Вывод",
"instanceShell.backgroundProcesses.actions.stop": "Остановить", "instanceShell.backgroundProcesses.actions.stop": "Остановить",
"instanceShell.backgroundProcesses.actions.terminate": "Завершить", "instanceShell.backgroundProcesses.actions.terminate": "Завершить",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "Загрузка сообщений…", "messageSection.loading.messages": "Загрузка сообщений…",
"messageSection.scroll.toFirstAriaLabel": "Прокрутить к первому сообщению", "messageSection.scroll.toFirstAriaLabel": "Прокрутить к первому сообщению",
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению", "messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
"messageSection.scroll.enableHoldAriaLabel": "Включить удержание для длинных ответов ассистента",
"messageSection.scroll.disableHoldAriaLabel": "Выключить удержание для длинных ответов ассистента",
"messageSection.quote.addAsQuote": "Добавить как цитату", "messageSection.quote.addAsQuote": "Добавить как цитату",
"messageSection.quote.addAsCode": "Добавить как код", "messageSection.quote.addAsCode": "Добавить как код",
"messageSection.quote.copy": "Копировать", "messageSection.quote.copy": "Копировать",

View File

@@ -130,6 +130,17 @@ export const instanceMessages = {
"instanceShell.gitChanges.loading": "正在加载 Git 更改...", "instanceShell.gitChanges.loading": "正在加载 Git 更改...",
"instanceShell.gitChanges.empty": "暂无 Git 更改。", "instanceShell.gitChanges.empty": "暂无 Git 更改。",
"instanceShell.gitChanges.deleted": "已删除", "instanceShell.gitChanges.deleted": "已删除",
"instanceShell.gitChanges.binaryViewer": "无法显示二进制文件",
"instanceShell.gitChanges.sections.staged": "已暂存的更改",
"instanceShell.gitChanges.sections.unstaged": "更改",
"instanceShell.gitChanges.actions.insertContext": "添加到提示词",
"instanceShell.gitChanges.actions.stage": "暂存文件",
"instanceShell.gitChanges.actions.unstage": "取消暂存",
"instanceShell.gitChanges.commit.placeholder": "输入提交信息",
"instanceShell.gitChanges.commit.submit": "提交",
"instanceShell.gitChanges.commit.submitting": "正在提交...",
"instanceShell.gitChanges.commit.success": "提交已成功创建",
"instanceShell.gitChanges.commit.error": "无法创建提交",
"instanceShell.filesShell.fileListTitle": "文件列表", "instanceShell.filesShell.fileListTitle": "文件列表",
"instanceShell.filesShell.mobileSelectorLabel": "选择文件", "instanceShell.filesShell.mobileSelectorLabel": "选择文件",
@@ -150,6 +161,8 @@ export const instanceMessages = {
"instanceShell.backgroundProcesses.empty": "没有后台进程。", "instanceShell.backgroundProcesses.empty": "没有后台进程。",
"instanceShell.backgroundProcesses.status": "状态:{status}", "instanceShell.backgroundProcesses.status": "状态:{status}",
"instanceShell.backgroundProcesses.output": "输出:{sizeKb}KB", "instanceShell.backgroundProcesses.output": "输出:{sizeKb}KB",
"instanceShell.backgroundProcesses.notify.enabled": "已启用完成通知",
"instanceShell.backgroundProcesses.notify.disabled": "已禁用完成通知",
"instanceShell.backgroundProcesses.actions.output": "输出", "instanceShell.backgroundProcesses.actions.output": "输出",
"instanceShell.backgroundProcesses.actions.stop": "停止", "instanceShell.backgroundProcesses.actions.stop": "停止",
"instanceShell.backgroundProcesses.actions.terminate": "终止", "instanceShell.backgroundProcesses.actions.terminate": "终止",

View File

@@ -18,6 +18,8 @@ export const messagingMessages = {
"messageSection.loading.messages": "正在加载消息...", "messageSection.loading.messages": "正在加载消息...",
"messageSection.scroll.toFirstAriaLabel": "滚动到第一条消息", "messageSection.scroll.toFirstAriaLabel": "滚动到第一条消息",
"messageSection.scroll.toLatestAriaLabel": "滚动到最新消息", "messageSection.scroll.toLatestAriaLabel": "滚动到最新消息",
"messageSection.scroll.enableHoldAriaLabel": "启用长助手回复保持",
"messageSection.scroll.disableHoldAriaLabel": "禁用长助手回复保持",
"messageSection.quote.addAsQuote": "作为引用添加", "messageSection.quote.addAsQuote": "作为引用添加",
"messageSection.quote.addAsCode": "作为代码添加", "messageSection.quote.addAsCode": "作为代码添加",
"messageSection.quote.copy": "复制", "messageSection.quote.copy": "复制",

View File

@@ -33,6 +33,7 @@ function createInitialState(instanceId: string): InstanceMessageState {
sessions: {}, sessions: {},
sessionOrder: [], sessionOrder: [],
messages: {}, messages: {},
lastAssistantMessageIds: {},
messageInfoVersion: {}, messageInfoVersion: {},
pendingParts: {}, pendingParts: {},
sessionRevisions: {}, sessionRevisions: {},
@@ -218,6 +219,7 @@ export interface InstanceMessageStore {
getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined
getSessionRevision: (sessionId: string) => number getSessionRevision: (sessionId: string) => number
getSessionMessageIds: (sessionId: string) => string[] getSessionMessageIds: (sessionId: string) => string[]
getLastAssistantMessageId: (sessionId: string) => string | undefined
// Index of the most recent message in the session that contains a compaction part. // Index of the most recent message in the session that contains a compaction part.
// Returns -1 if there has been no compaction. // Returns -1 if there has been no compaction.
getLastCompactionMessageIndex: (sessionId: string) => number getLastCompactionMessageIndex: (sessionId: string) => number
@@ -234,6 +236,21 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
const messageInfoCache = new Map<string, MessageInfo>() const messageInfoCache = new Map<string, MessageInfo>()
function findLastAssistantMessageId(messageIds: readonly string[]): string | undefined {
for (let index = messageIds.length - 1; index >= 0; index -= 1) {
const messageId = messageIds[index]
if (state.messages[messageId]?.role === "assistant") {
return messageId
}
}
return undefined
}
function recomputeLastAssistantMessageId(sessionId: string, messageIds?: readonly string[]) {
if (!sessionId) return
setState("lastAssistantMessageIds", sessionId, findLastAssistantMessageId(messageIds ?? state.sessions[sessionId]?.messageIds ?? []))
}
function getLastCompactionMessageIndex(sessionId: string): number { function getLastCompactionMessageIndex(sessionId: string): number {
if (!sessionId) return -1 if (!sessionId) return -1
const ids = state.sessions[sessionId]?.messageIds ?? [] const ids = state.sessions[sessionId]?.messageIds ?? []
@@ -306,6 +323,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return state.sessionRevisions[sessionId] ?? 0 return state.sessionRevisions[sessionId] ?? 0
} }
function getLastAssistantMessageIdValue(sessionId: string) {
return state.lastAssistantMessageIds[sessionId]
}
function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) { function withUsageState(sessionId: string, updater: (draft: SessionUsageState) => void) {
setState("usage", sessionId, (current) => { setState("usage", sessionId, (current) => {
const draft = current const draft = current
@@ -375,6 +396,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}) })
if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) { if (Array.isArray(input.messageIds) && !areMessageIdListsEqual(previousIds, nextMessageIds)) {
recomputeLastAssistantMessageId(input.id, nextMessageIds)
bumpSessionRevision(input.id) bumpSessionRevision(input.id)
} }
} }
@@ -445,6 +467,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
messageIds: incomingIds, messageIds: incomingIds,
updatedAt: Date.now(), updatedAt: Date.now(),
})) }))
recomputeLastAssistantMessageId(sessionId, incomingIds)
Object.values(normalizedRecords).forEach((record) => { Object.values(normalizedRecords).forEach((record) => {
maybeUpdateLatestTodoFromRecord(record) maybeUpdateLatestTodoFromRecord(record)
@@ -516,6 +539,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
insertMessageIntoSession(input.sessionId, input.id) insertMessageIntoSession(input.sessionId, input.id)
flushPendingParts(input.id) flushPendingParts(input.id)
recomputeLastAssistantMessageId(input.sessionId)
bumpSessionRevision(input.sessionId) bumpSessionRevision(input.sessionId)
} }
@@ -730,6 +754,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
if (state.latestTodos[sessionId]?.messageId === messageId) { if (state.latestTodos[sessionId]?.messageId === messageId) {
clearLatestTodoSnapshot(sessionId) clearLatestTodoSnapshot(sessionId)
} }
recomputeLastAssistantMessageId(sessionId)
bumpSessionRevision(sessionId) bumpSessionRevision(sessionId)
}) })
}) })
@@ -816,7 +841,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
affectedSessions.add(session.id) affectedSessions.add(session.id)
}) })
affectedSessions.forEach((sessionId) => bumpSessionRevision(sessionId)) affectedSessions.forEach((sessionId) => {
recomputeLastAssistantMessageId(sessionId)
bumpSessionRevision(sessionId)
})
const infoEntry = messageInfoCache.get(options.oldId) const infoEntry = messageInfoCache.get(options.oldId)
if (infoEntry) { if (infoEntry) {
@@ -1037,6 +1065,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
removedIds.forEach((id) => removeUsageEntry(draft, id)) removedIds.forEach((id) => removeUsageEntry(draft, id))
}) })
recomputeLastAssistantMessageId(sessionId, keptIds)
bumpSessionRevision(sessionId) bumpSessionRevision(sessionId)
} }
@@ -1128,6 +1157,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
return next return next
}) })
setState("lastAssistantMessageIds", (prev) => {
const next = { ...prev }
delete next[sessionId]
return next
})
setState("scrollState", (prev) => { setState("scrollState", (prev) => {
const next = { ...prev } const next = { ...prev }
const prefix = `${sessionId}:` const prefix = `${sessionId}:`
@@ -1190,16 +1225,17 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setSessionRevert, setSessionRevert,
getSessionRevert, getSessionRevert,
rebuildUsage, rebuildUsage,
getSessionUsage, getSessionUsage,
setScrollSnapshot, setScrollSnapshot,
getScrollSnapshot, getScrollSnapshot,
getSessionRevision: getSessionRevisionValue, getSessionRevision: getSessionRevisionValue,
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [], getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
getLastCompactionMessageIndex, getLastAssistantMessageId: getLastAssistantMessageIdValue,
getMessage: (messageId: string) => state.messages[messageId], getLastCompactionMessageIndex,
getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId], getMessage: (messageId: string) => state.messages[messageId],
clearSession, getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
clearInstance, clearSession,
} clearInstance,
} }
}

View File

@@ -116,18 +116,11 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
// Prefer explicit input limits when provided by the API. // Prefer explicit input limits when provided by the API.
// This is used by the UI "Avail" chip. // This is used by the UI "Avail" chip.
contextAvailableTokens = modelInputLimit contextAvailableTokens = modelInputLimit
} } else if (contextWindow > 0) {
// When no explicit input limit, show full context window capacity.
if (!contextAvailableFromPrevious && contextAvailableTokens === null) { contextAvailableTokens = contextWindow
if (contextWindow > 0) { } else {
if (latestHasContextUsage && actualUsageTokens > 0) { contextAvailableTokens = null
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)
} else {
contextAvailableTokens = contextWindow
}
} else {
contextAvailableTokens = null
}
} }
setSessionInfoByInstance((prev) => { setSessionInfoByInstance((prev) => {

View File

@@ -113,6 +113,7 @@ export interface InstanceMessageState {
sessions: Record<string, SessionRecord> sessions: Record<string, SessionRecord>
sessionOrder: string[] sessionOrder: string[]
messages: Record<string, MessageRecord> messages: Record<string, MessageRecord>
lastAssistantMessageIds: Record<string, string | undefined>
messageInfoVersion: Record<string, number> messageInfoVersion: Record<string, number>
pendingParts: Record<string, PendingPartEntry[]> pendingParts: Record<string, PendingPartEntry[]>
sessionRevisions: Record<string, number> sessionRevisions: Record<string, number>

View File

@@ -55,6 +55,7 @@ export interface UiSettings {
showKeyboardShortcutHints: boolean showKeyboardShortcutHints: boolean
thinkingBlocksExpansion: ExpansionPreference thinkingBlocksExpansion: ExpansionPreference
showTimelineTools: boolean showTimelineTools: boolean
holdLongAssistantReplies: boolean
promptSubmitOnEnter: boolean promptSubmitOnEnter: boolean
showPromptVoiceInput: boolean showPromptVoiceInput: boolean
locale?: string locale?: string
@@ -133,6 +134,7 @@ const defaultUiSettings: UiSettings = {
showKeyboardShortcutHints: true, showKeyboardShortcutHints: true,
thinkingBlocksExpansion: "expanded", thinkingBlocksExpansion: "expanded",
showTimelineTools: true, showTimelineTools: true,
holdLongAssistantReplies: true,
promptSubmitOnEnter: false, promptSubmitOnEnter: false,
showPromptVoiceInput: true, showPromptVoiceInput: true,
diffViewMode: "split", diffViewMode: "split",
@@ -166,6 +168,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
sanitized.showKeyboardShortcutHints ?? defaultUiSettings.showKeyboardShortcutHints, sanitized.showKeyboardShortcutHints ?? defaultUiSettings.showKeyboardShortcutHints,
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion, thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools, showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
holdLongAssistantReplies: sanitized.holdLongAssistantReplies ?? defaultUiSettings.holdLongAssistantReplies,
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter, promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput, showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput,
locale: sanitized.locale ?? defaultUiSettings.locale, locale: sanitized.locale ?? defaultUiSettings.locale,

View File

@@ -526,14 +526,49 @@
@media (max-width: 640px) { @media (max-width: 640px) {
.settings-screen-frame { .settings-screen-frame {
padding: 0; padding: 0;
overflow: hidden;
} }
.modal-surface.settings-screen-shell { .modal-surface.settings-screen-shell {
width: 100%; width: 100%;
max-width: 100%;
height: 100%; height: 100%;
max-height: none; max-height: none;
min-height: 100%; min-height: 100%;
border-radius: 0; border-radius: 0;
overflow-x: hidden;
}
.modal-surface.settings-screen-shell .settings-screen-nav,
.modal-surface.settings-screen-shell .settings-screen-nav-list,
.modal-surface.settings-screen-shell .settings-screen-content,
.modal-surface.settings-screen-shell .settings-screen-scroll,
.modal-surface.settings-screen-shell .settings-section-stack,
.modal-surface.settings-screen-shell .settings-stack,
.modal-surface.settings-screen-shell .settings-card,
.modal-surface.settings-screen-shell .settings-card-content,
.modal-surface.settings-screen-shell .settings-toggle-row,
.modal-surface.settings-screen-shell .settings-toggle-row > * {
min-width: 0;
}
.modal-surface.settings-screen-shell .selector-trigger,
.modal-surface.settings-screen-shell .selector-input,
.modal-surface.settings-screen-shell .selector-button {
min-width: 0;
max-width: 100%;
}
.modal-surface.settings-screen-shell .settings-toggle-caption,
.modal-surface.settings-screen-shell .settings-inline-note,
.modal-surface.settings-screen-shell .remote-address-url,
.modal-surface.settings-screen-shell code {
overflow-wrap: anywhere;
word-break: break-word;
}
.modal-surface.settings-screen-shell .whitespace-nowrap {
white-space: normal;
} }
.settings-screen-content-header, .settings-screen-content-header,

View File

@@ -242,6 +242,10 @@
color: var(--accent-primary); color: var(--accent-primary);
} }
.message-scroll-button[data-active="false"] .message-scroll-icon--toggle {
color: var(--text-secondary);
}
.message-quote-popover { .message-quote-popover {
position: absolute; position: absolute;
z-index: 5; z-index: 5;

View File

@@ -66,10 +66,11 @@
} }
.message-timeline { .message-timeline {
--message-timeline-segment-gap: 0.35rem;
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0;
padding: 0.25rem; padding: 0.25rem;
overflow-y: auto; overflow-y: auto;
overflow-x: visible; overflow-x: visible;
@@ -114,6 +115,17 @@
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
.message-timeline-item {
display: flex;
flex-direction: column;
width: 100%;
}
.message-timeline-item-spacer {
flex: none;
width: 100%;
}
.message-timeline-segment[data-delete-hover="true"]::before { .message-timeline-segment[data-delete-hover="true"]::before {
content: ""; content: "";
position: absolute; position: absolute;
@@ -319,18 +331,7 @@
border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent); border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
} }
/* Extra spacing before the first tool in a group to separate from the /* Spacing is rendered by the measured item wrapper so virtua can account for it. */
preceding user/assistant badge. */
.message-timeline-group-start {
margin-top: 0.35rem;
}
/* Subtle extra spacing after the group parent (assistant) to separate
from the next user badge below. Uses adjacent sibling targeting. */
.message-timeline-group-parent + .message-timeline-user,
.message-timeline-group-parent + .message-timeline-compaction {
margin-top: 0.35rem;
}
.message-timeline-container { .message-timeline-container {
position: relative; position: relative;

View File

@@ -220,7 +220,7 @@
} }
.file-list-item { .file-list-item {
@apply px-3 py-2.5 border-b cursor-pointer transition-all duration-150; @apply px-2 py-1 border-b cursor-pointer transition-all duration-150;
border-color: var(--border-base); border-color: var(--border-base);
background-color: transparent; background-color: transparent;
} }
@@ -234,14 +234,280 @@
} }
.file-list-item-active { .file-list-item-active {
background-color: var(--surface-base); background-color: color-mix(in srgb, var(--surface-base) 88%, white);
box-shadow: inset 0 0 0 1px var(--accent-primary); box-shadow: inset 0 0 0 1px var(--accent-primary);
} }
.git-change-list-item-bulk-selected {
background-color: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-base));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 45%, transparent);
}
.git-change-list-item-bulk-selected:hover {
background-color: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-base));
}
.git-change-list-item-bulk-selected.file-list-item-active {
background-color: color-mix(in srgb, var(--accent-primary) 18%, var(--surface-base));
box-shadow:
inset 0 0 0 1px var(--accent-primary),
inset 0 0 0 2px color-mix(in srgb, var(--accent-primary) 20%, transparent);
}
.file-list-item-content { .file-list-item-content {
@apply flex items-center justify-between gap-3; @apply flex items-center justify-between gap-3;
} }
.git-change-list-item .file-list-item-content {
gap: 0.5rem;
}
.git-change-sections {
@apply flex flex-col;
}
.git-change-commit-box {
@apply flex flex-col gap-2 px-2 py-2 border-b;
border-color: var(--border-base);
background-color: var(--surface-secondary);
}
.git-change-commit-input-wrap {
position: relative;
}
.git-change-commit-input {
@apply w-full min-h-[32px] px-2 py-1.5 pr-20 text-xs rounded border border-base resize-y;
background-color: var(--surface-base);
color: var(--text-primary);
}
.git-change-commit-input::placeholder {
color: var(--text-muted);
}
.git-change-commit-button {
@apply inline-flex items-center justify-center self-start px-3 py-1.5 text-xs font-medium rounded border border-base transition-colors;
background-color: var(--surface-base);
color: var(--text-primary);
}
.git-change-commit-button-overlay {
position: absolute;
bottom: 8px;
right: 8px;
z-index: 1;
align-self: auto;
padding: 0.25rem 0.5rem;
}
.git-change-commit-button-overlay:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 1px;
}
.git-change-commit-button-overlay:hover {
background-color: var(--surface-hover);
}
.git-change-commit-button:hover:not(:disabled) {
background-color: var(--surface-hover);
}
.git-change-commit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.git-change-section {
@apply border-b last:border-b-0;
border-color: var(--border-base);
}
.git-change-section-header {
@apply w-full flex items-center justify-between gap-2 px-2 py-1 text-left;
background-color: var(--surface-secondary);
color: var(--text-secondary);
}
.git-change-section-header:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.git-change-section-header-main {
@apply flex items-center gap-2 min-w-0;
}
.git-change-section-chevron {
@apply inline-flex items-center justify-center shrink-0;
}
.git-change-section-title {
@apply text-[11px] font-semibold uppercase tracking-wide;
}
.git-change-section-title-row {
@apply inline-flex items-center gap-2 min-w-0 flex-wrap;
}
.git-change-section-badge {
flex-shrink: 1;
min-width: 0;
}
.git-change-section-count {
@apply text-[10px] px-1.5 py-0.5 rounded-full shrink-0;
background-color: var(--surface-base);
color: var(--text-muted);
border: 1px solid var(--border-base);
}
.git-change-section-items {
@apply flex flex-col;
}
.git-change-list-item {
padding-inline-start: 0.25rem;
position: relative;
}
.git-change-list-item-right {
@apply flex items-center shrink-0;
min-width: 0;
}
.git-change-list-item-actions-zone {
@apply flex items-center justify-end;
position: absolute;
inset-block: 0;
inset-inline-end: 0;
width: 34px;
z-index: 2;
}
.git-change-list-item-actions {
@apply flex items-center justify-center;
width: 32px;
}
.git-change-list-item .file-list-item-content {
padding-inline-end: 2rem;
}
.git-change-row-action {
@apply inline-flex items-center justify-center w-5 h-5 rounded border border-base leading-none;
background-color: var(--surface-base);
border-color: var(--border-base);
color: var(--text-secondary);
padding: 0;
position: relative;
overflow: hidden;
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
}
.git-change-row-action:hover {
background-color: var(--surface-hover);
border-color: var(--border-base);
color: var(--text-primary);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary) 10%, transparent);
}
.file-list-item-active .git-change-row-action,
.git-change-list-item-bulk-selected .git-change-row-action {
background-color: color-mix(in srgb, var(--surface-base) 94%, white);
border-color: color-mix(in srgb, var(--accent-primary) 24%, var(--border-base));
}
.file-list-item-active .git-change-row-action:hover,
.git-change-list-item-bulk-selected .git-change-row-action:hover {
background-color: var(--surface-base);
border-color: color-mix(in srgb, var(--accent-primary) 42%, var(--border-base));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 24%, transparent);
}
.git-change-row-action:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 1px;
}
.git-change-row-action-glyph {
position: relative;
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
.git-change-row-action-bar {
position: absolute;
left: 50%;
top: 50%;
display: block;
background-color: currentColor;
border-radius: 999px;
transform: translate(-50%, -50%);
}
.git-change-row-action-bar-horizontal {
width: 12px;
height: 1.5px;
}
.git-change-row-action-bar-vertical {
width: 1.5px;
height: 12px;
}
@media (hover: none), (pointer: coarse) {
.git-change-list-item-actions-zone {
width: 34px;
}
}
.git-change-list-item-name {
@apply text-[12px] leading-4 min-w-0 overflow-hidden whitespace-nowrap;
color: var(--text-primary);
text-overflow: ellipsis;
}
.git-change-list-item-parent {
@apply text-[10px] leading-3 min-w-0 overflow-hidden whitespace-nowrap;
color: var(--text-muted);
text-overflow: ellipsis;
}
.git-change-context-widget-host {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
transform: translate(-50%, -50%);
overflow: visible;
z-index: 20;
pointer-events: auto;
}
.git-change-context-widget {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 999px;
background-color: var(--accent-primary);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 70%, white 30%);
color: white;
font-size: 16px;
line-height: 1;
font-weight: 500;
border: 0;
cursor: pointer;
padding: 0;
}
.file-list-item-path { .file-list-item-path {
@apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap; @apply text-xs font-mono min-w-0 flex-1 overflow-hidden whitespace-nowrap;
color: var(--text-primary); color: var(--text-primary);
@@ -335,6 +601,14 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
direction: ltr; direction: ltr;
position: relative;
}
.git-change-context-overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 30;
} }
.file-viewer-empty { .file-viewer-empty {
@@ -507,6 +781,7 @@
opacity: 0; opacity: 0;
transform: scale(0.95); transform: scale(0.95);
} }
to { to {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);

View File

@@ -78,6 +78,10 @@ export interface TextPart {
export type MessageInfo = SDKMessage export type MessageInfo = SDKMessage
export function isHiddenSyntheticTextPart(part: ClientPart): boolean {
return Boolean(part && part.type === "text" && part.synthetic)
}
function hasTextSegment(segment: string | { text?: string }): boolean { function hasTextSegment(segment: string | { text?: string }): boolean {
if (typeof segment === "string") { if (typeof segment === "string") {
return segment.trim().length > 0 return segment.trim().length > 0
@@ -95,6 +99,10 @@ export function partHasRenderableText(part: ClientPart): boolean {
return false return false
} }
if (isHiddenSyntheticTextPart(part)) {
return false
}
const typedPart = part as SDKPart const typedPart = part as SDKPart
if (typedPart.type === "text" && hasTextSegment(typedPart.text)) { if (typedPart.type === "text" && hasTextSegment(typedPart.text)) {

View File

@@ -4,8 +4,7 @@ import type {
Provider as SDKProvider, Provider as SDKProvider,
Model as SDKModel, Model as SDKModel,
} from "@opencode-ai/sdk" } from "@opencode-ai/sdk"
import type { SessionStatus as SDKSessionStatus } from "@opencode-ai/sdk/v2/client" import type { SessionStatus as SDKSessionStatus, FileDiff } from "@opencode-ai/sdk/v2/client"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
// Export SDK types for external use // Export SDK types for external use
export type { export type {