Compare commits

...

180 Commits

Author SHA1 Message Date
Shantur Rathore
2236350f1b fix(server): prefer LAN remote addresses over public IPs 2026-04-01 22:09:23 +01:00
VooDisss
5f3e9317ca refactor(ui): align password action with status summary
Keep the Change password control on the same row as the username and password-status copy in the settings card, so the summary reads as one horizontal unit instead of stacked text followed by a separate action row.
2026-04-01 17:32:27 +03:00
VooDisss
750e73f540 refactor(ui): let settings modal use full viewport frame
Make the settings dialog fill the height inside a frame that explicitly reserves about five percent of the screen above and below. This removes the old fixed height cap and reduces unnecessary inner scrolling on taller displays.
2026-04-01 17:17:42 +03:00
VooDisss
61e06ef883 refactor(ui): group extra remote addresses behind disclosure
Replace the standalone show/hide button with a full-width collapsible disclosure container in both remote access surfaces. This keeps the hidden address list visually grouped, makes the header easier to scan and click, and centers the label so it reads as structure rather than an unrelated action button.

Keep the first recommended remote address visible by default while preserving access to the full list behind the compact disclosure header.
2026-04-01 17:04:20 +03:00
VooDisss
b0b0a55e14 refactor(server): centralize remote address selection
Unify remote address resolution so startup logging, primary remote URL selection, and /api/meta all consume the same server-side policy. Keep all external addresses user-visible, preserve stable interface ordering, and only de-prioritize link-local addresses when choosing the primary recommended remote URL.

On the UI side, introduce a shared helper that keeps the first remote address visible by default and collapses the remaining addresses behind a reveal action, with i18n coverage and targeted tests for the new selection behavior.
2026-04-01 17:03:41 +03:00
VooDisss
984743f3c7 fix(server): show sane remote URLs for 0.0.0.0 binds
When CodeNomad was started with --host 0.0.0.0, the CLI picked the first external IPv4 address it discovered and advertised only that one as the remote URL. On Windows machines with WSL, Hyper-V, Docker, or other virtual adapters, this often surfaced a virtual 172.x address even though a normal LAN address such as 192.168.x.x was also reachable and usable from other devices.

Tighten remote URL presentation so the startup log reuses the resolved address list, prefers an advertisable external address for the primary Remote Connection URL, and prints additional accessible URLs instead of hiding them. Keep address ordering stable across private RFC1918 ranges instead of hard-coding one subnet family as universally better, while filtering out link-local addresses from the user-facing advertised list to avoid noisy or misleading output.

Add targeted tests around address ordering and advertisability so link-local-first interface enumeration does not regress the primary LAN URL selection again.

# Conflicts:
#	packages/server/src/index.ts
2026-03-31 02:58:42 +03:00
Shantur Rathore
27bccb8d6b Release v0.13.1 - Voice mode, Super speedy streaming, and a lot more (#255)
## Thanks for contributions

- PR [#249](https://github.com/NeuralNomadsAI/CodeNomad/pull/249)
"feat(speech): add prompt voice input" by
[@shantur](https://github.com/shantur)
- PR [#243](https://github.com/NeuralNomadsAI/CodeNomad/pull/243)
"feat(i18n): Hebrew locale + full RTL support" by
[@MusiCode1](https://github.com/MusiCode1)
- PR [#241](https://github.com/NeuralNomadsAI/CodeNomad/pull/241)
"feat(lazy loading): Implement virtual list with virtua" by
[@pixellos](https://github.com/pixellos)
- PR [#240](https://github.com/NeuralNomadsAI/CodeNomad/pull/240)
"fix(tauri): force Windows process tree shutdown" by
[@pascalandr](https://github.com/pascalandr)
- PR [#239](https://github.com/NeuralNomadsAI/CodeNomad/pull/239)
"perf(ui): split right panel and secondary viewer chunks" by
[@pascalandr](https://github.com/pascalandr)
- PR [#238](https://github.com/NeuralNomadsAI/CodeNomad/pull/238)
"perf(ui): defer locale and overlay bundles" by
[@pascalandr](https://github.com/pascalandr)
- PR [#236](https://github.com/NeuralNomadsAI/CodeNomad/pull/236)
"Suppress OS notifications for subagent (child) sessions" by
`@app/codenomadbot`
- PR [#235](https://github.com/NeuralNomadsAI/CodeNomad/pull/235)
"fix(ui): unwrap pasted placeholders in slash commands" by
`@app/codenomadbot`
- PR [#232](https://github.com/NeuralNomadsAI/CodeNomad/pull/232)
"fix(tauri): stop CLI process group on exit" by `@app/codenomadbot`
- PR [#229](https://github.com/NeuralNomadsAI/CodeNomad/pull/229)
"feat(ui): add RTL support for Hebrew/Arabic text" by
[@MusiCode1](https://github.com/MusiCode1)
- PR [#227](https://github.com/NeuralNomadsAI/CodeNomad/pull/227)
"fix(tauri): improve Windows desktop runtime behavior" by
[@pascalandr](https://github.com/pascalandr)
- PR [#226](https://github.com/NeuralNomadsAI/CodeNomad/pull/226)
"fix(tauri): restore desktop menu controls and fullscreen shortcut" by
[@pascalandr](https://github.com/pascalandr)
- PR [#225](https://github.com/NeuralNomadsAI/CodeNomad/pull/225)
"fix(tauri): restore external links in the folder picker" by
[@pascalandr](https://github.com/pascalandr)
- PR [#224](https://github.com/NeuralNomadsAI/CodeNomad/pull/224)
"fix(tauri): sync server UI bundle during prebuild" by
[@pascalandr](https://github.com/pascalandr)
- PR [#215](https://github.com/NeuralNomadsAI/CodeNomad/pull/215)
"perf(ui): lazy-load markdown and defer diff rendering" by
[@pascalandr](https://github.com/pascalandr)

## Highlights

- **Voice-first conversations**: Start prompts with voice input,
configure speech behavior from settings, and listen back to assistant
responses with message playback and conversation playback controls.
- **A complete Hebrew + RTL experience**: CodeNomad now ships with a
full Hebrew locale and much broader right-to-left support, making the
app feel natural for Hebrew users while improving Arabic text rendering
too.
- **A much faster experience in long chats**: The new virtualized
message list, deferred markdown and diff rendering, and more selective
loading for heavy UI surfaces make large sessions feel noticeably
smoother.

## What's Improved

- **More flexible speech controls**: Speech settings and playback modes
now adapt better to different browsers and platform capabilities.
- **Cleaner prompt workflow**: The prompt includes a quick clear action,
a simpler recording indicator, and a more polished mic control layout.
- **Faster startup and lighter heavy views**: Locale bundles, overlays,
right-panel viewers, picker flows, markdown, and diff surfaces all load
more lazily to reduce upfront UI work.
- **Less notification spam**: Subagent sessions no longer fire OS
notifications, so important interruptions are easier to notice.
- **Better RTL behavior across the whole interface**: Session names,
tool outputs, markdown blocks, file views, selectors, and layout
controls behave more consistently in right-to-left contexts.

## Fixes

- **More reliable Windows desktop behavior**: Process cleanup is
stronger during app shutdown, background CLI process trees are
terminated more reliably, desktop identity/metadata is aligned more
cleanly, and stray console windows are hidden during startup and exit.
- **Cleaner shutdown on macOS and Linux**: Desktop quit/close now stops
the spawned CLI process group more reliably, reducing leftover
background processes after exit.
- **Restored desktop actions**: External links in the folder picker work
again, and the desktop View/Window controls plus the fullscreen shortcut
are back.
- **More stable streaming and scrolling**: Reasoning streams stay pinned
more consistently, follow behavior is less jumpy, spacing is cleaner in
virtualized conversations, and session switching retains position more
smoothly.
- **Safer slash command pasting**: Pasted placeholders are resolved
correctly before slash commands run, so long pasted inputs behave like
normal prompts.
- **More dependable desktop packaging**: Tauri prebuild now refreshes
the server UI bundle correctly, which avoids packaged desktop builds
picking up stale UI assets.
- **Clearer speech compatibility handling**: Streaming playback
limitations are surfaced more cleanly instead of failing in a confusing
way.

### Contributors

- [@pascalandr](https://github.com/pascalandr)
- [@MusiCode1](https://github.com/MusiCode1)
- [@pixellos](https://github.com/pixellos)
2026-03-27 19:58:35 +00:00
Shantur Rathore
1b4eff9419 Min version 0.13.1 2026-03-27 19:46:54 +00:00
Shantur Rathore
6c1febf50e Bump to v0.13.1 2026-03-27 19:46:12 +00:00
Shantur Rathore
75622ef366 refactor(ui): simplify prompt recording indicator 2026-03-27 19:45:56 +00:00
Shantur Rathore
864f913e3e feat(ui): add assistant conversation playback mode 2026-03-27 19:17:25 +00:00
Shantur Rathore
b7d4f8f869 feat(ui): add clear action to prompt input 2026-03-26 23:10:02 +00:00
Shantur Rathore
0dc5867fb3 fix(speech): surface streaming playback compatibility 2026-03-26 22:59:30 +00:00
Shantur Rathore
d13ecba322 feat(speech): add configurable TTS playback modes 2026-03-26 20:46:49 +00:00
Shantur Rathore
740f37db86 refactor(ui): use stop-square icon for speech playback 2026-03-26 19:39:37 +00:00
Shantur Rathore
d447b05821 feat(ui): add message text-to-speech controls 2026-03-26 18:29:45 +00:00
Shantur Rathore
1233121a13 feat(speech): add prompt voice input (#249)
## Summary
- add server-backed speech capabilities and transcription endpoints plus
UI settings for speech configuration
- add push-to-talk prompt voice input with microphone controls,
transcription insertion, and browser capability gating
- keep prompt controls aligned by restoring right-side nav placement and
moving the mic beside the expand control
2026-03-25 14:08:11 +00:00
Pascal André
a950d47df0 fix(tauri): force Windows process tree shutdown (#240)
## Summary
- force the Windows CLI process tree shutdown path during normal app
close
- avoid leaving child server processes alive when the direct wrapper
process exits first
- keep the change limited to the Windows shutdown path in cli_manager

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

## Summary

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

## What was done

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

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

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

## New files

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

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

---------

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

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

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

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

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

## Technical notes

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

## Summary

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

## Affected components

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

## Test plan

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

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

---------

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

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

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

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

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

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

Fixes #234.

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

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

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

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

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

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

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

Closes #228

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

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

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

### 🚀 Performance Results

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

### 🛠️ Key Changes

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

### 🎥 UI Verification


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


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

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

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-03-20 22:46:05 +00:00
Shantur Rathore
68407a01a4 ci: post PR artifact comments per build 2026-03-20 18:00:18 +00:00
Shantur Rathore
0283493f2a ci: prefer latest PR build run for artifact comments 2026-03-20 17:56:31 +00:00
Shantur Rathore
e989795de3 ci: move PR artifact comments to trusted workflow 2026-03-20 09:24:27 +00:00
Shantur Rathore
103d2bf1a8 ci: comment PR artifacts from validation run 2026-03-20 07:40:59 +00:00
Shantur Rathore
0ce7a47e03 ci: read PR number from workflow run 2026-03-20 07:22:56 +00:00
Shantur Rathore
5df8809c82 ci: resolve artifact comments by PR head branch 2026-03-20 07:13:04 +00:00
Shantur Rathore
6e22614648 ci: resolve PR number for artifact comment 2026-03-19 21:15:48 +00:00
Shantur Rathore
5d87e1e563 ci: upload PR build artifacts and comment link 2026-03-19 20:52:14 +00:00
Shantur Rathore
d735b189f5 refactor(tauri): use imported event and dialog APIs 2026-03-19 19:38:43 +00:00
Shantur Rathore
3d575f4f68 fix(tauri): align wake lock bridge with v2 API 2026-03-19 19:20:18 +00:00
Shantur Rathore
b58728dc0e add PR branch authorization workflows
Restrict non-dev pull requests to an allowlisted set of actors and skip cross-platform PR builds unless that authorization check passes. Keep dev open for general contributions while guiding other PRs back to the dev branch.
2026-03-19 15:01:36 +00:00
Shantur Rathore
672177f570 add PR build validation workflow
Run the full cross-platform build matrix on pull request creation and updates so build regressions are caught before merge without publishing release artifacts.
2026-03-19 14:52:48 +00:00
Shantur Rathore
6961efde0b Merge pull request #224 from Pagecran/upstream/tauri-prebuild-sync
fix(tauri): sync server UI bundle during prebuild
2026-03-18 20:39:22 +00:00
Shantur Rathore
b3e0233f4b Merge pull request #232 from NeuralNomadsAI/codenomad/issue-231
fix(tauri): stop CLI process group on exit
2026-03-18 20:33:55 +00:00
Pascal André
fcebcb0174 fix(tauri): sync server UI bundle during prebuild
Ensure the Tauri prebuild step refreshes packages/server/public from the current UI renderer bundle so the packaged desktop app does not serve a stale folder-selection UI.
2026-03-18 20:45:08 +01:00
Shantur Rathore
eaab5e2e9f fix(tauri): stop CLI process group on exit 2026-03-18 19:43:41 +00:00
Shantur Rathore
b12825f923 Merge pull request #227 from Pagecran/upstream/tauri-windows-runtime
fix(tauri): improve Windows desktop runtime behavior
2026-03-18 19:37:31 +00:00
Pascal André
8245f474b8 fix(tauri): avoid non-Windows spawn warning 2026-03-18 20:21:40 +01:00
Pascal André
3a15b311a8 fix(tauri): hide taskkill during Windows cleanup 2026-03-18 20:19:10 +01:00
Pascal André
6cb6c0af32 fix(tauri): align desktop bundle identifier 2026-03-18 20:19:10 +01:00
Pascal André
7f631611fd fix(tauri): hide Windows CLI console window
Set CREATE_NO_WINDOW on the spawned local CLI process so the packaged Windows Tauri app does not flash an extra console window when it launches Node.
2026-03-18 20:19:10 +01:00
Pascal André
9d91ecc649 fix(tauri): kill Windows CLI process trees on shutdown
Use taskkill /T /F for the local server process on Windows so child Node/Bun processes do not survive app shutdown or startup timeouts.
2026-03-18 20:19:10 +01:00
Pascal André
87afb06d34 fix(tauri): restore Windows app identity
Set the same explicit AppUserModelID that the legacy Electron app used so Windows taskbar grouping and notification attribution stay consistent in the Tauri desktop build.
2026-03-18 20:18:59 +01:00
Pascal André
4402d9afb0 fix(tauri): align desktop version metadata
Match the Tauri package, Cargo, and bundle version metadata to the current legacy desktop version so About dialogs and installer artifacts stop reporting 0.1.0.
2026-03-18 20:18:07 +01:00
Shantur Rathore
153065d025 Merge pull request #214 from Pagecran/ready/tauri-auth-cookie-isolation
fix(tauri): isolate desktop auth cookies per app
2026-03-15 17:53:06 +00:00
Pascal André
2abda0e6b4 fix(desktop): isolate Electron auth cookies per app
Make the legacy Electron desktop client generate and pass a per-launch auth cookie name too, so parallel desktop instances stop clobbering each other's localhost session cookie just like the Tauri client.
2026-03-15 09:38:00 +01:00
Pascal André
800133361d fix(tauri): remove stray perf emission from auth cookie PR
Drop the startup instrumentation call that leaked into the auth-cookie isolation branch. The helper is not defined on this PR branch, and the PR does not need to serialize the generated cookie name to fix the multi-instance auth collision.
2026-03-15 01:10:05 +01:00
Pascal André
034cb5dea9 fix(tauri): isolate desktop auth cookies per app 2026-03-14 23:31:46 +01:00
Shantur Rathore
d7ab84f245 Merge pull request #213 from NeuralNomadsAI/dev
Release v0.12.3
2026-03-13 21:27:30 +00:00
Shantur Rathore
7c3f808d69 Minium server 0.12.3 2026-03-13 20:06:41 +00:00
Shantur Rathore
a59e929b12 Release v0.12.3 2026-03-13 20:04:20 +00:00
Shantur Rathore
8ff4019839 fix(ui): stabilize prompt async optimistic messages
Reconcile optimistic user messages by replacing the oldest synthetic pending message when the server-backed message arrives. Stop sending prompt part ids and rely on message-level replacement so v1.2.25 validation passes without duplicating optimistic content.
2026-03-13 19:17:55 +00:00
Shantur Rathore
d9068ac8c6 fix(ui): tighten settings content padding
Reduce the Settings scroll area gutter while keeping a consistent inset on all sides.
2026-03-11 11:01:04 +00:00
Shantur Rathore
51f8eff3f7 fix(ui): remove settings rounded corners
Make the Settings screen use square corners across panels, cards, and embedded controls.
2026-03-11 10:55:51 +00:00
Shantur Rathore
627ff2d42b feat(ui): centralize interaction preferences
Expose interaction defaults in Settings and reuse the same registry for command palette actions.
2026-03-11 10:53:28 +00:00
Shantur Rathore
0d9da40102 feat(ui): add unified settings screen 2026-03-11 10:10:58 +00:00
Shantur Rathore
ff94c9714e fix(tauri): align dev CLI args with electron 2026-03-10 22:23:38 +00:00
Shantur Rathore
429825f434 feat(desktop): unify folder drag-and-drop across runtimes 2026-03-10 22:12:23 +00:00
Shantur Rathore
d836d2e62d fix(tauri): remove Windows-only CLI dependency 2026-03-10 20:15:16 +00:00
Shantur Rathore
f77fb1562e fix(ui): stabilize streaming follow mode
Disable follow-mode virtualization churn and simplify reasoning header layout so streaming thinking blocks stop nudging the scroll position while the list is pinned to bottom.
2026-03-10 18:44:55 +00:00
Shantur Rathore
b33421a375 fix(ui): stabilize virtual list rerender measurements
Keep visible rows mounted during follow-up measurements and clear stale refs so async message rendering no longer flickers or measures detached blocks. Coalesce per-item render notifications so content-heavy rows only trigger one remeasurement per frame.
2026-03-10 06:28:11 +00:00
Shantur Rathore
c64a9a03f9 fix(ui): stabilize virtual message list measurements 2026-03-10 06:28:11 +00:00
Shantur Rathore
0d215342e3 Merge pull request #210 from Pagecran/fix/tauri-windows-startup
fix(tauri): restore Windows desktop startup
2026-03-08 17:26:20 +00:00
Pascal André
beb14ea0a2 fix(tauri): restore Windows desktop startup 2026-03-08 16:20:31 +01:00
Shantur Rathore
6a4e548d2c Bump to v0.12.2 2026-03-04 11:08:25 +00:00
Shantur Rathore
201988b97c Merge pull request #205 from NeuralNomadsAI/dev
Release v0.12.1 - Histogram, bulk delete, snappier long sessions and more
2026-03-04 10:42:43 +00:00
Shantur Rathore
ad943b2bd4 Bump v0.12.1 2026-03-04 10:25:20 +00:00
Shantur Rathore
6dac8a6209 fix(ui): show delete overlay for selected timeline segments 2026-03-04 00:42:54 +00:00
Shantur Rathore
bec1af6523 fix(ui): keep delete selection consistent across stream and timeline 2026-03-04 00:41:23 +00:00
Shantur Rathore
1719802c0f fix(ui): show timeline preview tooltip during selection 2026-03-03 23:03:23 +00:00
Shantur Rathore
3719dcecf8 fix(ui): clear timeline selection on stream click 2026-03-03 23:00:44 +00:00
Shantur Rathore
3dae143830 Merge origin/dev into dev 2026-03-03 22:57:43 +00:00
Shantur Rathore
f050273a8e fix(ui): preserve stream scroll on session switch 2026-03-03 22:44:18 +00:00
Shantur Rathore
8f955cf21c fix(ui): stabilize virtual list scroll compensation 2026-03-03 21:23:50 +00:00
Shantur Rathore
a893fca66e Merge pull request #188 from VooDisss/issue-186
[QOL FEATURE]: implement 'Histogram Ribs' context x-ray for bulk selection (#186)
2026-03-03 19:56:06 +00:00
Shantur Rathore
4f8aba5658 chore(ui): tighten and center bulk delete toolbar 2026-03-03 18:52:09 +00:00
Shantur Rathore
219e012c1b chore(i18n): refine bulk delete hint copy 2026-03-03 18:48:22 +00:00
Shantur Rathore
17716a730b chore(ui): use Kbd hints in bulk delete toolbar 2026-03-03 18:28:00 +00:00
Shantur Rathore
c57170d122 perf(ui): compute xray segment chars from referenced parts 2026-03-03 18:13:28 +00:00
Shantur Rathore
24c1b7e8ad fix(ui): treat compacted tool calls as zero tokens 2026-03-03 15:07:49 +00:00
Shantur Rathore
3c76f9776c fix(ui): sync xray overlay with timeline scroll 2026-03-03 15:02:08 +00:00
Shantur Rathore
80a02b68b9 fix(ui): restrict selection and xray to post-compaction 2026-03-03 14:09:48 +00:00
Shantur Rathore
c766b5ab62 fix(ui): exclude tool metadata from token estimate 2026-03-03 13:32:48 +00:00
Shantur Rathore
133e937772 fix(ui): pin follow list to bottom on resize 2026-03-03 10:53:58 +00:00
Shantur Rathore
95df743339 fix(ui): avoid offscreen mounts during initial layout 2026-03-03 10:52:59 +00:00
VooDisss
cd6266757d fix(i18n): align bulk delete copy with mixed deletes
Gatekeeper response: mixed delete is intended (tool selections delete tool parts; non-tool selections delete whole messages). Updated bulk delete copy to use 'items' across locales so UI matches mixed behavior. Aria label already uses 'Selected items'; delete/failure strings now consistent.
2026-03-03 09:48:03 +02:00
VooDisss
ec0bffe0c2 fix(ui): enable tool-part delete and long-press group toggle 2026-03-03 09:26:17 +02:00
VooDisss
ed322a16bf chore(ui): finalize timeline selection audit fixes
Complete re-review of PR #188 (commits 224cab6 feature + 2c27fc5 perf/i18n follow-up). Gatekeeper focus: standards, correctness, perf/complexity, and translation completeness.

What this changes (pre -> post)

Pre: timeline primarily navigation/hover preview; bulk delete selection message-level and token metrics tied to backend assistant output tokens (missing tool payload weight).

Post: segment-level timeline selection + range (Shift) + toggle (Ctrl/Meta) + mobile long-press; histogram ribs overlay showing relative + absolute (~10k cap) token weight; assistant-turn grouping to avoid adjacency bugs; bulk-delete toolbar shows Before / Selection / After token pills.

Code standards / correctness

OK: Solid signal/memo/effect patterns with cleanup; no obvious lifecycle leaks. Grouping avoids adjacency overlap by mapping messageId to turns.

Fix: selection-id stability is mitigated by pruning stale ids after segment rebuilds; long term stable ids from part ids/toolPartIds remain recommended.

Fix: token counts now share getPartCharCount in both x-ray overlay and bulk-delete toolbar, keeping estimates consistent with live store updates.

Performance / complexity

OK: O(n^2) hotspots removed for liveSegmentChars and selectedTokenTotal. groupRole + deleteUpTo hover checks now memoize messageId sets/maps.

Note: getPartCharCount can be heavy for large tool payloads but remains gated behind selection mode.

CSS / UI integration

Fix: x-ray token label now uses theme tokens instead of hard-coded colors. Delete toolbar now uses menu-based controls with selection-mode toggle.

i18n

Fix: selection hint now renders Cmd/Ctrl via localized modifier placeholder; all locales updated.
2026-03-03 03:49:51 +02:00
Shantur Rathore
044e46cd6b fix(ui): avoid mutating markdown part renderCache 2026-03-02 23:21:26 +00:00
Shantur Rathore
38f75ab06d fix(ui): prevent virtual items mounting offscreen 2026-03-02 23:17:22 +00:00
Shantur Rathore
b6bf58ea8f fix(ui): keep stream virtualized and bottom-anchored while loading 2026-03-02 22:47:21 +00:00
VooDisss
2c27fc53ad perf(ui): fix O(n²) in liveSegmentChars and selectedTokenTotal, add i18n + SSR guard
Addresses bot review feedback on commit 224cab6.

## Performance: liveSegmentChars O(n²) → O(n)

The memo had three inner loops scanning all props.segments per unique
messageId. Added a single O(n) pre-pass building a
segmentsByMessageId Map, then replaced all three inner loops with
map lookups. Total complexity: O(n) instead of O(m×n).

File: packages/ui/src/components/message-timeline.tsx

## Performance: selectedTokenTotal O(n²) → O(n)

For each selected messageId, the memo scanned all segments to sum
chars. On "Select all" this was O(selected × segments). Now builds a
charsByMessageId map in one O(n) pass and does O(1) lookups per
selected message. Same pattern as aggregateTokensByMessageId.

File: packages/ui/src/components/message-section.tsx

## SSR guard: resize listener

window.addEventListener("resize", computeBadgeLayout) lacked a
typeof window !== "undefined" guard. Other window usage in the file
was guarded. Wrapped the addEventListener, requestAnimationFrame, and
onCleanup block in the guard.

File: packages/ui/src/components/message-timeline.tsx

## i18n: mirror selectionHint key in 5 locale files

messageSection.bulkDelete.selectionHint was only defined in
en/messaging.ts. Added the key (English string, since Ctrl/Shift/Esc
are universal keyboard labels) to es, fr, ja, ru, and zh-Hans.

Files: packages/ui/src/lib/i18n/messages/{es,fr,ja,ru,zh-Hans}/messaging.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:46:51 +02:00
Shantur Rathore
4c5acefa07 fix(ui): stabilize virtual list scroll during measurement 2026-03-02 12:01:06 +00:00
VooDisss
224cab6a42 feat(ui): add timeline segment selection, xray token histogram, and group logic overhaul
Overhauls the message timeline sidebar with segment-level selection,
token-aware xray histogram bars, and messageId-based grouping — replacing
the previous message-level selection and positional adjacency logic.

## Selection System (SELECTION-SYSTEM)

- Dual-level selection: `selectedTimelineIds` (segment IDs) as the
  source of truth, bridged to `selectedForDeletion` (message IDs) via
  a reactive `createEffect`.
- CTRL+Click: toggles individual segments. Clicking an assistant parent
  with unexpanded tools expands the group and selects all members.
  Re-clicking collapses and deselects.
- SHIFT+Click: range selection. Direction follows anchor state — if the
  anchor is selected the range is additive; if not, subtractive.
- Escape: clears all selection via a global keydown listener.
- Long-press (500ms, 10px jitter tolerance): mobile/touch selection
  via pointer events with context-menu suppression.
- Scroll anchor preservation: captures badge offsetTop before toggling
  visibility, restores scrollTop after layout shift.

## Token Count Fix (TOKEN-COUNT-FIX)

- New `getPartCharCount()` estimates characters for any `ClientPart`.
  Handles text, tool state (input/output/metadata), and content arrays.
- **Skips `filediff` metadata key** — this key contains full before/after
  file content that inflated character counts by 10-100x.
- `totalChars` field added to `TimelineSegment` and `PendingSegment`,
  accumulated during `buildTimelineSegments()`.

## Scroll Performance (SCROLL-PERF)

- Two-tier positioning replaces per-badge `getBoundingClientRect` on
  every scroll event:
  1. `computeBadgeLayout()` — expensive pass, runs once on activation,
     resize, or expansion. Stores `layoutTop` relative to scroll content.
  2. `handleScrollRaf()` — RAF-throttled, reads 1 container rect per
     frame. Derives all badge screen positions arithmetically.
- `clipBounds` subtracts delete toolbar height + 16px gap when toolbar
  is visible, preventing xray bars from overlapping the toolbar.

## Group Logic (GROUP-LOGIC)

- `getAdjacentGroup()`: changed from backward positional walk to
  `segments.filter(s => s.messageId === clicked.messageId)`. Fixes
  cross-message group overlap when consecutive tool segments belong to
  different assistant messages.
- `groupRole()`: checks for sibling tools via `messageId`.
- `isGroupStart()`: checks previous segment's `messageId`.
- Only assistant badges trigger group selection; tool and user badges
  are always standalone.

## Active Highlight (ACTIVE-HIGHLIGHT)

- Renamed `activeMessageId` → `activeSegmentId` (signal, prop, and
  comparison). Clicking a badge now highlights only that specific badge,
  not all badges sharing the same messageId.
- Intersection observer resolves messageId → first segment's id.
- Auto-scroll effect uses segment id directly (no `.find()` lookup).

## XRay Histogram Bars (XRAY-BARS)

- Portal-based overlay with two bars per segment:
  - Relative bar: width = tokens/maxTokens, green-to-red gradient.
  - Absolute bar: width = tokens/10000 (capped), grey, with red glow
    overflow indicator when tokens exceed ABSOLUTE_TOKEN_CAP (10K).
- Token labels as pill-shaped badges (white bg, dark border, 12px font,
  1.5rem height matching badge height) at the left tip of each bar.
- `liveSegmentChars` memo fetches fresh char counts from the message
  store to handle stale tool output that arrived after segment creation.
- `aggregateTokensByMessageId` memo: O(n) pre-computation replacing the
  previous O(n²) per-segment iteration inside `<For>`.
- `clip-path: inset(...)` clips bars at layout edges.

## Delete Toolbar Token Display (TOKEN-TOTAL-IN-TOOLBAR)

- Removed `outputTokensByMessageId` (backend `entry.outputTokens` only
  counted assistant output, missing tool result content entirely).
- `selectedTokenTotal` now sums `seg.totalChars` across all segments
  for each selected messageId, divides by 4. Consistent with xray bars.
- Three color-coded pills: Before (muted, current context), Selection
  (red, tokens being removed), After (green, remaining after deletion).
  Eliminates mental arithmetic for users targeting a context token count.

## Delete Hover Fix

- Removed `selected.has(segment.messageId)` → `return true` from
  `isDeleteHovered()`. The red delete overlay now only activates from
  actual hover interactions (kind === "message" or "deleteUpTo"), not
  from the selection state. This prevents the red overlay from masking
  the blue segment-level selection highlight.

## CSS Changes

- message-selection.css: Restyled toolbar with accent-primary scheme,
  three-pill token group, button variants (--delete, --cancel), hint.
- message-timeline.css: Selection styling (!important overrides), group
  indicators (left border), xray overlay (fixed fullscreen, z-index 40),
  rib/bar/label styles, container layout, stacking context isolation.

## Files Changed

- packages/ui/src/components/message-section.tsx (+345/-197)
- packages/ui/src/components/message-timeline.tsx (+671/-199)
- packages/ui/src/lib/i18n/messages/en/messaging.ts (+1/-2)
- packages/ui/src/styles/messaging/message-selection.css (+107/-34)
- packages/ui/src/styles/messaging/message-timeline.css (+146/-0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:51:59 +02:00
Shantur Rathore
48b2d7c5ee refactor(ui): extract virtual-follow-list for message stream 2026-03-01 20:14:21 +00:00
Shantur Rathore
594809538d Revert "perf(ui): start streams at newest"
This reverts commit 13802537b4.
2026-03-01 12:41:22 +00:00
Shantur Rathore
13802537b4 perf(ui): start streams at newest
Reverse the message stream scroll layout so the viewport naturally starts at the newest messages and keeps older content virtualized. Use sentinel-based edge chasing to make jump-to-top/bottom land reliably despite VirtualItem mounts.
2026-03-01 12:40:18 +00:00
Shantur Rathore
ca2b3c232f perf(ui): drop virtualized DOM in hidden panes
Add DOM instrumentation tags and harden VirtualItem visibility for hidden/zero-sized roots to prevent inactive instances from keeping heavy tool-call markup mounted; restore message stream virtualization margin.
2026-02-28 14:13:42 +00:00
Shantur Rathore
c51e71c7a2 perf(ui): memoize changes lists and reduce stream rendering 2026-02-28 10:31:32 +00:00
Shantur Rathore
482313f662 fix(ui): render image attachment preview in portal 2026-02-28 00:56:44 +00:00
Shantur Rathore
9a4d378238 perf(ui): avoid full rescan of task child tools 2026-02-27 21:09:46 +00:00
Shantur Rathore
5d5fbfb5f2 perf(ui): lazy-mount tool call details 2026-02-27 13:28:43 +00:00
Shantur Rathore
d147ad49ff chore(ui): remove tool header button borders 2026-02-27 00:13:05 +00:00
Shantur Rathore
9b435e3621 chore(config): bump @opencode-ai/plugin 2026-02-26 15:34:14 +00:00
Shantur Rathore
ab9e188b02 feat(ui): add multi-select message deletion 2026-02-26 15:25:47 +00:00
Shantur Rathore
2991de528a feat(ui): add delete-up-to action and range hover overlay 2026-02-26 13:46:48 +00:00
Shantur Rathore
f1bd681618 chore(ui): remove delete-part actions and use trash for delete 2026-02-26 10:25:38 +00:00
Shantur Rathore
b91dbb1a60 fix(ui): sync delete-hover overlays across preview and stream 2026-02-26 10:10:46 +00:00
Shantur Rathore
688b127c6d fix(ui): highlight all tool segments on message delete hover 2026-02-26 09:34:34 +00:00
Shantur Rathore
0f9c99e3bd feat(ui): mirror delete hover overlay in timeline 2026-02-25 23:32:32 +00:00
Shantur Rathore
1122070b9c feat(ui): highlight delete targets on hover 2026-02-25 23:08:53 +00:00
Shantur Rathore
57b81f00f8 chore(ui): reorder user message actions 2026-02-25 22:54:49 +00:00
Shantur Rathore
362105fe78 feat(ui): add delete message action to stream 2026-02-25 22:49:14 +00:00
Shantur Rathore
5834d2df1b fix(ui): use v2 message info and show model variant 2026-02-25 22:29:27 +00:00
Shantur Rathore
ef4c8ef425 fix(ci): ad-hoc sign Electron macOS apps 2026-02-24 22:22:46 +00:00
Shantur Rathore
5f755a7e1c fix(ci): retry workspace version bump on macos 2026-02-24 09:08:32 +00:00
Shantur Rathore
8607fab5b5 fix(ci): skip macOS codesign verify without identity 2026-02-24 08:53:14 +00:00
Shantur Rathore
0368fe8248 fix(ci): avoid bash globstar on macOS 2026-02-24 07:29:26 +00:00
Shantur Rathore
b970281fa7 chore(electron): use cross-env for dev log level scripts
Make dev:info/dev:debug/dev:trace work on Windows by setting CLI_LOG_LEVEL via cross-env.
2026-02-24 00:19:39 +00:00
Shantur Rathore
8e5a7fc213 fix(electron): make dev CLI log level configurable
Use CLI_LOG_LEVEL when launching the server in desktop dev and add dev:info/dev:debug/dev:trace scripts with dev defaulting to info.
2026-02-24 00:09:49 +00:00
Shantur Rathore
15f362e8b5 Bump v0.11.5 2026-02-23 23:55:52 +00:00
Shantur Rathore
7bbd0a1787 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-23 23:55:32 +00:00
Shantur Rathore
f8aae56728 Merge pull request #190 from VooDisss/issue-187
fix(ui): prevent timeline auto-scroll when removing badges (#189)
2026-02-23 21:50:56 +00:00
Shantur Rathore
027d7fc97d fix(ui): load shiki languages from marked tokens 2026-02-23 18:39:21 +00:00
Shantur Rathore
e90aef4b3c fix(ui): stack instance header under 1024px 2026-02-23 18:36:24 +00:00
Shantur Rathore
e4e89008b2 Merge pull request #199 from NeuralNomadsAI/codenomad/issue-198
CI: rezip Electron macOS artifacts with ditto + validate codesign
2026-02-23 08:58:56 +00:00
Shantur Rathore
90baefbb7e fix(ci): rezip Electron macOS zips with ditto
Add a codesign verify step on extracted artifacts to catch signature/resource mismatches before upload.
2026-02-23 08:54:57 +00:00
Shantur Rathore
1c138f4489 Merge pull request #197 from VooDisss/issue-195
fix: Use legacy diff algorithm for better large file performance
2026-02-23 08:27:11 +00:00
VooDisss
d36e568ed0 fix: Use legacy diff algorithm for better large file performance
- Set diffAlgorithm to 'legacy' for Monaco DiffEditor
- Add maxComputationTime of 10s to avoid UI freeze on huge files

This addresses the issue where sessions with large JSON files (50k-100k+ lines)
would cause the UI to freeze. The 'legacy' algorithm is faster than 'advanced'
for large files, similar to VSCode's workaround for the same issue.

See: https://github.com/microsoft/vscode/issues/184037
2026-02-23 02:30:44 +02:00
Shantur Rathore
d6462ef524 Min version 0.11.4 2026-02-22 17:32:28 +00:00
Shantur Rathore
6a6fcff2c8 Merge pull request #195 from NeuralNomadsAI/dev
Release v0.11.4 - Mobile Fullscreen mode and lots of improvements
2026-02-22 17:15:22 +00:00
Shantur Rathore
a06884ebce Bump to v0.11.4 2026-02-22 16:53:51 +00:00
Shantur Rathore
62bd88f6a4 chore(plugin): Upgrade dependency version 2026-02-22 16:48:49 +00:00
Shantur Rathore
6479561779 fix(ui): auto-expand session thread when child starts working 2026-02-22 16:47:04 +00:00
Shantur Rathore
635237c258 fix(ui): render task prompt consistently while running 2026-02-22 08:58:39 +00:00
Shantur Rathore
33f0aa5714 ci: run dev prerelease nightly
Replace dev push builds with nightly schedule that only runs when dev head advances; still runs on manual dispatch. Plumb a ref input through reusable workflows so scheduled runs build the dev commit.
2026-02-20 13:58:32 +00:00
Shantur Rathore
7ca6285d58 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-20 13:49:03 +00:00
Shantur Rathore
14c60fef6c Merge pull request #192 from VooDisss/issue-144
[QOL] Add informational tooltips to Status Panel sections
2026-02-20 13:47:11 +00:00
Shantur Rathore
336de6a19e fix(i18n): polish Status panel tooltip translations 2026-02-20 13:46:43 +00:00
Shantur Rathore
377c8e2249 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-20 13:31:52 +00:00
VooDisss
697dea21f8 Add informational tooltips to Status Panel sections 2026-02-20 14:09:54 +02:00
VooDisss
96fe1b86dd fix(ui): prevent timeline auto-scroll when removing badges 2026-02-20 12:33:52 +02:00
Shantur Rathore
5fabf286e8 ui: restyle command palette button 2026-02-20 00:32:44 +00:00
Shantur Rathore
e8947d61b1 ui: emphasize command palette button 2026-02-20 00:32:39 +00:00
Shantur Rathore
1ccd14eae8 ui: use Check icon for completed status 2026-02-20 00:32:27 +00:00
Shantur Rathore
b162764ccb ui: use lucide status icons for tool calls 2026-02-20 00:32:15 +00:00
Shantur Rathore
f29f197b9a Merge pull request #177 from NeuralNomadsAI/dev
v0.11.1 Release - Latest OC Support, Improved file/folder picker, Dev Releases and lot more
2026-02-16 16:31:17 +00:00
Shantur Rathore
dbde403b3e Merge pull request #150 from NeuralNomadsAI/dev
Release v0.10.3 - Viewer for Changes, Git Diff and workspace files along with UX fixes
2026-02-11 16:09:49 +00:00
Shantur Rathore
230c981cc2 Merge pull request #134 from NeuralNomadsAI/dev
Release v0.10.2
2026-02-09 01:08:06 +00:00
Shantur Rathore
34978c87fb Merge pull request #125 from NeuralNomadsAI/dev
Release v0.10.1 - Worktrees, HTTPS, PWA and more
2026-02-08 18:07:08 +00:00
Shantur Rathore
3e6d0a402c Merge pull request #116 from NeuralNomadsAI/dev
Release v0.9.4 - Context manipulation, Session search, Themes and more
2026-02-03 20:26:17 +00:00
Shantur Rathore
e81c5f6443 Merge pull request #105 from NeuralNomadsAI/dev
Release v0.9.3 -  Tauri fixes, Skip Auth, Better Question tool and more
2026-01-30 09:18:20 +00:00
Shantur Rathore
b0d27bd127 Merge pull request #99 from NeuralNomadsAI/dev
Release v0.9.2 - Model Favourites and Multi-Lang UI
2026-01-26 21:02:29 +00:00
Shantur Rathore
7576470295 Merge pull request #96 from NeuralNomadsAI/dev
Release v0.9.1 - Thinking variant, Robust process cleanup
2026-01-25 18:08:18 +00:00
Shantur Rathore
6d32e09db0 Merge pull request #94 from NeuralNomadsAI/dev
Release 0.9.0
2026-01-24 16:47:37 +00:00
Shantur Rathore
503cb3a02e Merge pull request #91 from NeuralNomadsAI/dev
Release v0.8.1 - Support apply_patch tool
2026-01-22 23:07:37 +00:00
Shantur Rathore
0250c6350f Merge pull request #89 from NeuralNomadsAI/dev
Change minVersion to 0.8.0
2026-01-22 19:17:20 +00:00
Shantur Rathore
24cc8fe939 Merge pull request #88 from NeuralNomadsAI/dev
Release v0.8.0 - Auto update UI and more fixes
2026-01-22 18:58:51 +00:00
Shantur Rathore
282b234a7c Merge pull request #87 from NeuralNomadsAI/dev
Release 0.7.6 - Question tool fixes + Split test
2026-01-22 17:20:19 +00:00
Shantur Rathore
4ba088a876 Merge pull request #82 from NeuralNomadsAI/dev
Release 0.7.5
2026-01-21 12:27:47 +00:00
Shantur Rathore
7b1817d606 Merge pull request #80 from NeuralNomadsAI/dev
Release 0.7.4
2026-01-20 19:30:19 +00:00
Shantur Rathore
5bc3c23ec5 Merge pull request #79 from NeuralNomadsAI/dev
Release 0.7.3 - Bug fixes and minor improvements
2026-01-20 18:53:39 +00:00
Shantur Rathore
127a51e3c3 Merge pull request #72 from NeuralNomadsAI/dev
Release v0.7.2 - Test1
2026-01-15 20:59:06 +00:00
Shantur Rathore
daa22b6d8c Merge pull request #68 from NeuralNomadsAI/dev
Release v0.7.1
2026-01-15 08:42:55 +00:00
Shantur Rathore
23f2de2d7e Merge pull request #66 from NeuralNomadsAI/dev
Actually Release 0.7.0
2026-01-14 21:56:13 +00:00
Shantur Rathore
80c9b76709 Merge pull request #65 from NeuralNomadsAI/dev
Release v0.7.0
2026-01-14 21:46:38 +00:00
Shantur Rathore
a29b77d60b Merge pull request #59 from NeuralNomadsAI/dev
v0.6.0 Release
2026-01-09 21:55:50 +00:00
222 changed files with 16255 additions and 4666 deletions

View File

@@ -3,6 +3,11 @@ name: Build and Upload Binaries
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version: version:
description: "Version to apply to workspace packages (release builds)" description: "Version to apply to workspace packages (release builds)"
required: false required: false
@@ -23,6 +28,21 @@ on:
required: false required: false
default: true default: true
type: boolean type: boolean
upload_actions_artifacts:
description: "Upload built artifacts to GitHub Actions run artifacts"
required: false
default: false
type: boolean
actions_artifacts_retention_days:
description: "Retention (days) for GitHub Actions artifacts"
required: false
default: 7
type: number
actions_artifacts_name_prefix:
description: "Optional prefix for Actions artifact names"
required: false
default: ""
type: string
set_versions: set_versions:
description: "Run npm version to set workspace versions" description: "Run npm version to set workspace versions"
required: false required: false
@@ -45,6 +65,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -54,7 +76,21 @@ jobs:
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }} if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version shell: bash
env:
NPM_CONFIG_FETCH_RETRIES: 5
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
run: |
set -euo pipefail
for attempt in 1 2 3; do
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
exit 0
fi
echo "npm version failed (attempt $attempt/3); retrying..." >&2
sleep $((attempt * 10))
done
exit 1
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces --include=optional run: npm ci --workspaces --include=optional
@@ -65,6 +101,112 @@ jobs:
- name: Build macOS binaries (Electron) - name: Build macOS binaries (Electron)
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
shell: bash
run: |
set -euo pipefail
release_root="packages/electron-app/release"
apps=()
while IFS= read -r -d '' app; do
apps+=("$app")
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
if [ "${#apps[@]}" -eq 0 ]; then
echo "No CodeNomad.app found under $release_root" >&2
exit 1
fi
# GitHub macOS runners typically have no signing identity. Without any signature,
# the shipped .app can fail Gatekeeper with:
# code has no resources but signature indicates they must be present
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
for app in "${apps[@]}"; do
echo "codesign (adhoc): $app"
codesign --force --deep --sign - "$app"
codesign --verify --deep --strict --verbose=2 "$app"
done
else
echo "macOS codesigning identity present; skipping ad-hoc signing"
fi
- name: Repackage Electron macOS zips (ditto)
shell: bash
run: |
set -euo pipefail
# Prefer the workflow-provided version; fall back to package.json.
VERSION_TO_USE="${VERSION:-}"
if [ -z "$VERSION_TO_USE" ]; then
VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
fi
release_root="packages/electron-app/release"
# macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
# Use find to locate built app bundles instead of ** globs.
apps=()
while IFS= read -r -d '' app; do
apps+=("$app")
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
if [ "${#apps[@]}" -eq 0 ]; then
echo "No CodeNomad.app found under $release_root" >&2
exit 1
fi
for app in "${apps[@]}"; do
bundle_dir=$(basename "$(dirname "$app")")
arch="x64"
if [[ "$bundle_dir" == *"arm64"* ]]; then
arch="arm64"
fi
out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
rm -f "$out_zip"
echo "ditto -ck: $app -> $out_zip"
ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
done
- name: Validate Electron macOS codesign (unzipped)
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
if [ "${#zips[@]}" -eq 0 ]; then
echo "No Electron macOS zip artifacts found to validate" >&2
exit 1
fi
for zip in "${zips[@]}"; do
echo "Validating codesign for: $zip"
extract_dir="$tmp_dir/$(basename "$zip" .zip)"
mkdir -p "$extract_dir"
# Use ditto for extraction as well to preserve bundle metadata.
ditto -x -k "$zip" "$extract_dir"
app_path=""
for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
if [ -d "$candidate" ]; then
app_path="$candidate"
break
fi
done
if [ -z "$app_path" ]; then
echo "No .app found after extracting $zip" >&2
exit 1
fi
codesign --verify --deep --strict --verbose=2 "$app_path"
done
- name: Upload release assets - name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -76,6 +218,15 @@ jobs:
gh release upload "$TAG" "$file" --clobber gh release upload "$TAG" "$file" --clobber
done done
- name: Upload Actions artifacts (Electron macOS)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-macos
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-windows: build-windows:
runs-on: windows-2025 runs-on: windows-2025
env: env:
@@ -85,6 +236,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -115,6 +268,15 @@ jobs:
gh release upload $env:TAG $_.FullName --clobber gh release upload $env:TAG $_.FullName --clobber
} }
- name: Upload Actions artifacts (Electron Windows)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-windows
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-linux: build-linux:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
env: env:
@@ -124,6 +286,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -155,6 +319,15 @@ jobs:
gh release upload "$TAG" "$file" --clobber gh release upload "$TAG" "$file" --clobber
done done
- name: Upload Actions artifacts (Electron Linux)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux
path: packages/electron-app/release/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error
build-tauri-macos: build-tauri-macos:
runs-on: macos-15-intel runs-on: macos-15-intel
env: env:
@@ -164,6 +337,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -206,7 +381,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS) - name: Package Tauri artifacts (macOS)
if: ${{ inputs.upload }} if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
run: | run: |
set -euo pipefail set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle" BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -217,6 +392,15 @@ jobs:
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip" ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
fi fi
- name: Upload Actions artifacts (Tauri macOS)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (macOS) - name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -237,6 +421,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -279,7 +465,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS arm64) - name: Package Tauri artifacts (macOS arm64)
if: ${{ inputs.upload }} if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
run: | run: |
set -euo pipefail set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle" BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
@@ -290,6 +476,15 @@ jobs:
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip" ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
fi fi
- name: Upload Actions artifacts (Tauri macOS arm64)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-macos-arm64
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (macOS arm64) - name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -310,6 +505,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -355,7 +552,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (Windows) - name: Package Tauri artifacts (Windows)
if: ${{ inputs.upload }} if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
shell: pwsh shell: pwsh
run: | run: |
$bundleRoot = "packages/tauri-app/target/release/bundle" $bundleRoot = "packages/tauri-app/target/release/bundle"
@@ -368,6 +565,15 @@ jobs:
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
} }
- name: Upload Actions artifacts (Tauri Windows)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-windows
path: packages/tauri-app/release-tauri/*.zip
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (Windows) - name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh shell: pwsh
@@ -388,6 +594,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -443,7 +651,7 @@ jobs:
run: npm exec -- tauri build run: npm exec -- tauri build
- name: Package Tauri artifacts (Linux) - name: Package Tauri artifacts (Linux)
if: ${{ inputs.upload }} if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
run: | run: |
set -euo pipefail set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target" SEARCH_ROOT="packages/tauri-app/target"
@@ -469,6 +677,15 @@ jobs:
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb" cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm" cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Actions artifacts (Tauri Linux)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}tauri-linux
path: packages/tauri-app/release-tauri/*
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: warn
- name: Upload Tauri release assets (Linux) - name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -490,6 +707,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -587,6 +806,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -623,3 +844,12 @@ jobs:
echo "Uploading $file" echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber gh release upload "$TAG" "$file" --clobber
done done
- name: Upload Actions artifacts (Electron Linux RPM)
if: ${{ inputs.upload_actions_artifacts }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.actions_artifacts_name_prefix }}electron-linux-rpm
path: packages/electron-app/release/*.rpm
retention-days: ${{ inputs.actions_artifacts_retention_days }}
if-no-files-found: error

View File

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

View File

@@ -1,12 +1,13 @@
name: Develop Pre-Release name: Develop Pre-Release
on: on:
push: schedule:
branches: # Nightly build of dev (only if dev has new commits)
- dev - cron: "0 1 * * *"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
actions: read
id-token: write id-token: write
contents: write contents: write
@@ -15,25 +16,63 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
prepare: gate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
version_suffix: ${{ steps.vars.outputs.version_suffix }} run: ${{ steps.gate.outputs.run }}
dev_sha: ${{ steps.gate.outputs.dev_sha }}
version_suffix: ${{ steps.gate.outputs.version_suffix }}
steps: steps:
- name: Compute version suffix - name: Decide whether to run
id: vars id: gate
shell: bash shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
SHA8="${GITHUB_SHA::8}"
api() {
curl -sS \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$1"
}
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
echo "Failed to resolve dev head SHA" >&2
exit 1
fi
DATE=$(date -u +%Y%m%d) DATE=$(date -u +%Y%m%d)
echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT" SHA8="${DEV_SHA::8}"
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
SHOULD_RUN="false"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
SHOULD_RUN="true"
else
# Nightly: only run if dev has advanced since last successful dev-release build.
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
if [ -z "${LAST_SHA}" ]; then
SHOULD_RUN="true"
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
SHOULD_RUN="true"
fi
fi
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
prerelease: prerelease:
needs: prepare needs: gate
if: ${{ needs.gate.outputs.run == 'true' }}
uses: ./.github/workflows/reusable-release.yml uses: ./.github/workflows/reusable-release.yml
with: with:
version_suffix: ${{ needs.prepare.outputs.version_suffix }} ref: ${{ needs.gate.outputs.dev_sha }}
version_suffix: ${{ needs.gate.outputs.version_suffix }}
npm_package_name: "@neuralnomads/codenomad-dev" npm_package_name: "@neuralnomads/codenomad-dev"
dist_tag: latest dist_tag: latest
prerelease: true prerelease: true

View File

@@ -19,6 +19,10 @@ on:
type: string type: string
workflow_call: workflow_call:
inputs: inputs:
ref:
required: false
default: ""
type: string
version: version:
required: true required: true
type: string type: string
@@ -46,6 +50,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

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

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

View File

@@ -1,7 +1,13 @@
name: Release UI name: Release UI
on: on:
workflow_call: {} workflow_call:
inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
workflow_dispatch: {} workflow_dispatch: {}
permissions: permissions:
@@ -18,6 +24,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

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

View File

@@ -3,6 +3,11 @@ name: Reusable Release
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version_suffix: version_suffix:
description: "Suffix appended to package.json version" description: "Suffix appended to package.json version"
required: false required: false
@@ -46,6 +51,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -84,6 +91,7 @@ jobs:
needs: prepare-release needs: prepare-release
uses: ./.github/workflows/build-and-upload.yml uses: ./.github/workflows/build-and-upload.yml
with: with:
ref: ${{ inputs.ref || github.ref }}
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
tag: ${{ needs.prepare-release.outputs.tag }} tag: ${{ needs.prepare-release.outputs.tag }}
release_name: ${{ needs.prepare-release.outputs.release_name }} release_name: ${{ needs.prepare-release.outputs.release_name }}
@@ -95,6 +103,8 @@ jobs:
permissions: permissions:
contents: read contents: read
uses: ./.github/workflows/release-ui.yml uses: ./.github/workflows/release-ui.yml
with:
ref: ${{ inputs.ref || github.ref }}
secrets: inherit secrets: inherit
publish-server: publish-server:
@@ -103,6 +113,7 @@ jobs:
- build-and-upload - build-and-upload
uses: ./.github/workflows/manual-npm-publish.yml uses: ./.github/workflows/manual-npm-publish.yml
with: with:
ref: ${{ inputs.ref || github.ref }}
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
dist_tag: ${{ inputs.dist_tag }} dist_tag: ${{ inputs.dist_tag }}
package_name: ${{ inputs.npm_package_name }} package_name: ${{ inputs.npm_package_name }}

110
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.11.3", "version": "0.13.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.11.3", "version": "0.13.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -3253,9 +3253,9 @@
} }
}, },
"node_modules/@tauri-apps/api": { "node_modules/@tauri-apps/api": {
"version": "2.9.1", "version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==", "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -3305,6 +3305,32 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-notification": { "node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
@@ -8214,6 +8240,27 @@
"regex-recursion": "^6.0.2" "regex-recursion": "^6.0.2"
} }
}, },
"node_modules/openai": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/own-keys": { "node_modules/own-keys": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -10218,14 +10265,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/tauri-plugin-keepawake-api": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/tauri-plugin-keepawake-api/-/tauri-plugin-keepawake-api-0.1.0.tgz",
"integrity": "sha512-XPUl66zUYiB7kCRxsTdmCoNjFM/++NWCJ4kdTo2NUOgBUa8UVYfayDWnnTzGIQbhT7qNAHs+jgKSjhqSKs/QHA==",
"dependencies": {
"@tauri-apps/api": ">=2.0.0-beta.6"
}
},
"node_modules/temp-dir": { "node_modules/temp-dir": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
@@ -10966,6 +11005,36 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/virtua": {
"version": "0.48.8",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.48.8.tgz",
"integrity": "sha512-jpsxOw5V4B6hg44JePRLo9DL0TV7N1lBEVtPjKpAJebXyhI2s9lfiXJESaLapNtr3vtiSk/pWHiLf7B2a6UcgQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0",
"solid-js": ">=1.0",
"svelte": ">=5.0",
"vue": ">=3.2"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
},
"solid-js": {
"optional": true
},
"svelte": {
"optional": true
},
"vue": {
"optional": true
}
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"dev": true, "dev": true,
@@ -11971,6 +12040,7 @@
"node_modules/zod": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -11985,7 +12055,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.3", "version": "0.13.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -11995,6 +12065,7 @@
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0", "app-builder-bin": "^4.2.0",
"cross-env": "^7.0.3",
"electron": "39.0.0", "electron": "39.0.0",
"electron-builder": "^24.0.0", "electron-builder": "^24.0.0",
"electron-vite": "4.0.1", "electron-vite": "4.0.1",
@@ -12021,7 +12092,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.3", "version": "0.13.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12031,6 +12102,7 @@
"fastify": "^4.28.1", "fastify": "^4.28.1",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2", "yaml": "^2.4.2",
@@ -12062,7 +12134,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.11.3", "version": "0.13.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12070,7 +12142,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.11.3", "version": "0.13.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
@@ -12080,6 +12152,8 @@
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
"@suid/system": "^0.14.0", "@suid/system": "^0.14.0",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2.5.3", "@tauri-apps/plugin-opener": "^2.5.3",
"ansi-sequence-parser": "^1.1.3", "ansi-sequence-parser": "^1.1.3",
@@ -12092,7 +12166,7 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0", "virtua": "^0.48.8",
"yaml": "^2.4.2" "yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {

View File

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

View File

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

View File

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

View File

@@ -327,7 +327,6 @@ function finalizeCliSwap(url: string) {
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error)) mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
} }
const SESSION_COOKIE_NAME = "codenomad_session"
let bootstrapExchangeInFlight = false let bootstrapExchangeInFlight = false
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null { function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
@@ -350,6 +349,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
} }
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> { async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
const sessionCookieName = cliManager.getAuthCookieName()
const target = new URL("/api/auth/token", baseUrl) const target = new URL("/api/auth/token", baseUrl)
const body = JSON.stringify({ token }) const body = JSON.stringify({ token })
@@ -380,14 +380,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
return false return false
} }
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME) const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
if (!sessionId) { if (!sessionId) {
return false return false
} }
await session.defaultSession.cookies.set({ await session.defaultSession.cookies.set({
url: baseUrl, url: baseUrl,
name: SESSION_COOKIE_NAME, name: sessionCookieName,
value: sessionId, value: sessionId,
httpOnly: true, httpOnly: true,
path: "/", path: "/",

View File

@@ -11,6 +11,7 @@ import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./use
const nodeRequire = createRequire(import.meta.url) const nodeRequire = createRequire(import.meta.url)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
type CliState = "starting" | "ready" | "error" | "stopped" type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all" type ListeningMode = "local" | "all"
@@ -122,6 +123,7 @@ export class CliProcessManager extends EventEmitter {
private stdoutBuffer = "" private stdoutBuffer = ""
private stderrBuffer = "" private stderrBuffer = ""
private bootstrapToken: string | null = null private bootstrapToken: string | null = null
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
private requestedStop = false private requestedStop = false
async start(options: StartOptions): Promise<CliStatus> { async start(options: StartOptions): Promise<CliStatus> {
@@ -132,6 +134,7 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = "" this.stdoutBuffer = ""
this.stderrBuffer = "" this.stderrBuffer = ""
this.bootstrapToken = null this.bootstrapToken = null
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
this.requestedStop = false this.requestedStop = false
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
@@ -328,6 +331,10 @@ export class CliProcessManager extends EventEmitter {
return { ...this.status } return { ...this.status }
} }
getAuthCookieName(): string {
return this.authCookieName
}
private resolveListeningMode(): ListeningMode { private resolveListeningMode(): ListeningMode {
return readListeningModeFromConfig() return readListeningModeFromConfig()
} }
@@ -416,7 +423,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"] const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
if (options.dev) { if (options.dev) {
// Dev: run plain HTTP + Vite dev server proxy. // Dev: run plain HTTP + Vite dev server proxy.
@@ -431,7 +438,9 @@ export class CliProcessManager extends EventEmitter {
if (options.dev) { if (options.dev) {
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000" const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
args.push("--ui-dev-server", devServer, "--log-level", "debug") const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim()
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
} }
return args return args

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.3", "version": "0.13.1",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -15,7 +15,10 @@
}, },
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad", "homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
"scripts": { "scripts": {
"dev": "electron-vite dev", "dev": "npm run dev:info",
"dev:info": "cross-env CLI_LOG_LEVEL=info electron-vite dev",
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts", "dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"build": "electron-vite build", "build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -42,6 +45,7 @@
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0", "app-builder-bin": "^4.2.0",
"cross-env": "^7.0.3",
"electron": "39.0.0", "electron": "39.0.0",
"electron-builder": "^24.0.0", "electron-builder": "^24.0.0",
"electron-vite": "4.0.1", "electron-vite": "4.0.1",

View File

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

View File

@@ -1,12 +1,12 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.3", "version": "0.13.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.3", "version": "0.13.1",
"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.11.3", "version": "0.13.1",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -32,6 +32,7 @@
"fastify": "^4.28.1", "fastify": "^4.28.1",
"fuzzysort": "^2.0.4", "fuzzysort": "^2.0.4",
"node-forge": "^1.3.3", "node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"undici": "^6.19.8", "undici": "^6.19.8",
"yaml": "^2.4.2", "yaml": "^2.4.2",

View File

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

View File

@@ -16,16 +16,18 @@ export interface AuthManagerInit {
password?: string password?: string
generateToken: boolean generateToken: boolean
dangerouslySkipAuth?: boolean dangerouslySkipAuth?: boolean
cookieName?: string
} }
export class AuthManager { export class AuthManager {
private readonly authStore: AuthStore | null private readonly authStore: AuthStore | null
private readonly tokenManager: TokenManager | null private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager() private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME private readonly cookieName: string
private readonly authEnabled: boolean private readonly authEnabled: boolean
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) { constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
this.cookieName = sanitizeCookieName(init.cookieName)
this.authEnabled = !Boolean(init.dangerouslySkipAuth) this.authEnabled = !Boolean(init.dangerouslySkipAuth)
if (!this.authEnabled) { if (!this.authEnabled) {
@@ -139,6 +141,16 @@ export class AuthManager {
} }
} }
function sanitizeCookieName(value: string | undefined): string {
const trimmed = value?.trim()
if (!trimmed) {
return DEFAULT_AUTH_COOKIE_NAME
}
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
}
function resolveAuthFilePath(configPath: string) { function resolveAuthFilePath(configPath: string) {
const resolvedConfigPath = resolvePath(configPath) const resolvedConfigPath = resolvePath(configPath)
return path.join(path.dirname(resolvedConfigPath), "auth.json") return path.join(path.dirname(resolvedConfigPath), "auth.json")

View File

@@ -19,10 +19,11 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger" import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher" import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui" import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls" import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses" import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
@@ -54,6 +55,7 @@ interface CliOptions {
launch: boolean launch: boolean
authUsername: string authUsername: string
authPassword?: string authPassword?: string
authCookieName: string
generateToken: boolean generateToken: boolean
dangerouslySkipAuth: boolean dangerouslySkipAuth: boolean
} }
@@ -99,6 +101,11 @@ function parseCliOptions(argv: string[]): CliOptions {
.default(DEFAULT_AUTH_USERNAME), .default(DEFAULT_AUTH_USERNAME),
) )
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD")) .addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
.addOption(
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
.env("CODENOMAD_AUTH_COOKIE_NAME")
.default(DEFAULT_AUTH_COOKIE_NAME),
)
.addOption( .addOption(
new Option("--generate-token", "Emit a one-time bootstrap token for desktop") new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
.env("CODENOMAD_GENERATE_TOKEN") .env("CODENOMAD_GENERATE_TOKEN")
@@ -138,6 +145,7 @@ function parseCliOptions(argv: string[]): CliOptions {
launch?: boolean launch?: boolean
username: string username: string
password?: string password?: string
authCookieName: string
generateToken?: boolean generateToken?: boolean
dangerouslySkipAuth?: boolean dangerouslySkipAuth?: boolean
}>() }>()
@@ -184,6 +192,7 @@ function parseCliOptions(argv: string[]): CliOptions {
launch: Boolean(parsed.launch), launch: Boolean(parsed.launch),
authUsername: parsed.username, authUsername: parsed.username,
authPassword: parsed.password, authPassword: parsed.password,
authCookieName: parsed.authCookieName,
generateToken: Boolean(parsed.generateToken), generateToken: Boolean(parsed.generateToken),
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth), dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
} }
@@ -265,6 +274,7 @@ async function main() {
configPath: configLocation.configYamlPath, configPath: configLocation.configYamlPath,
username: options.authUsername, username: options.authUsername,
password: options.authPassword, password: options.authPassword,
cookieName: options.authCookieName,
generateToken: options.generateToken, generateToken: options.generateToken,
dangerouslySkipAuth: options.dangerouslySkipAuth, dangerouslySkipAuth: options.dangerouslySkipAuth,
}, },
@@ -304,6 +314,7 @@ async function main() {
}) })
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore(configLocation.instancesDir) const instanceStore = new InstanceStore(configLocation.instancesDir)
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
const instanceEventBridge = new InstanceEventBridge({ const instanceEventBridge = new InstanceEventBridge({
workspaceManager, workspaceManager,
eventBus, eventBus,
@@ -388,6 +399,7 @@ async function main() {
eventBus, eventBus,
serverMeta, serverMeta,
instanceStore, instanceStore,
speechService,
authManager, authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl, uiDevServerUrl: uiResolution.uiDevServerUrl,
@@ -408,6 +420,7 @@ async function main() {
eventBus, eventBus,
serverMeta, serverMeta,
instanceStore, instanceStore,
speechService,
authManager, authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined, uiDevServerUrl: undefined,
@@ -438,18 +451,22 @@ async function main() {
// which can lead clients to talk to the wrong process. // which can lead clients to talk to the wrong process.
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}` const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
let remoteUrl: string | undefined let remoteUrl: string | undefined
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
if (remoteStart) { if (remoteStart) {
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host) const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
let remoteHost = options.host let remoteHost = options.host
if (wantsAll) { if (wantsAll) {
if (options.host === "0.0.0.0") { if (options.host === "0.0.0.0") {
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port }) const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost" remoteAddresses = resolved.userVisible
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
} }
} else { } else {
remoteHost = "localhost" remoteHost = "localhost"
} }
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}` if (!remoteUrl) {
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
}
} }
serverMeta.localUrl = localUrl serverMeta.localUrl = localUrl
@@ -460,7 +477,9 @@ async function main() {
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local" serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
if (serverMeta.remotePort && remoteUrl) { if (serverMeta.remotePort && remoteUrl) {
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort }) serverMeta.addresses = remoteAddresses.length
? remoteAddresses
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
} else { } else {
serverMeta.addresses = [] serverMeta.addresses = []
} }
@@ -468,6 +487,16 @@ async function main() {
console.log(`Local Connection URL : ${serverMeta.localUrl}`) console.log(`Local Connection URL : ${serverMeta.localUrl}`)
if (serverMeta.remoteUrl) { if (serverMeta.remoteUrl) {
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`) console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
const additionalRemoteUrls = serverMeta.addresses
.map((addr) => addr.remoteUrl)
.filter((url) => url !== serverMeta.remoteUrl)
if (additionalRemoteUrls.length > 0) {
console.log("Other Accessible URLs:")
for (const url of additionalRemoteUrls) {
console.log(` - ${url}`)
}
}
} }
if (options.launch) { if (options.launch) {

View File

@@ -0,0 +1,94 @@
import assert from "node:assert/strict"
import os from "node:os"
import { describe, it } from "node:test"
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
describe("resolveNetworkAddresses", () => {
it("preserves interface order among external addresses", () => {
const addresses = [
{ address: "172.24.0.1", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "10.0.0.8", family: 4, internal: false },
{ address: "127.0.0.1", family: "IPv4", internal: true },
{ address: "169.254.10.20", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.map((entry) => entry.ip),
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
)
})
})
})
describe("resolveRemoteAddresses", () => {
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
const addresses = [
{ address: "169.254.10.20", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "172.24.0.1", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.userVisible.map((entry) => entry.ip),
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
)
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
})
})
it("prefers private LAN addresses over public addresses", () => {
const addresses = [
{ address: "203.0.113.40", family: "IPv4", internal: false },
{ address: "192.168.1.128", family: "IPv4", internal: false },
{ address: "8.8.8.8", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(
result.userVisible.map((entry) => entry.ip),
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
)
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
})
})
it("uses a public address when no private LAN address is available", () => {
const addresses = [
{ address: "169.254.10.20", family: "IPv4", internal: false },
{ address: "203.0.113.40", family: "IPv4", internal: false },
]
usingMockedNetworkInterfaces(addresses, () => {
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
})
})
})
function usingMockedNetworkInterfaces(
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
callback: () => void,
) {
const original = os.networkInterfaces
os.networkInterfaces = (() => ({
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
})) as typeof os.networkInterfaces
try {
callback()
} finally {
os.networkInterfaces = original
}
}

View File

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

View File

@@ -1,6 +1,12 @@
import os from "os" import os from "os"
import type { NetworkAddress } from "../api-types" import type { NetworkAddress } from "../api-types"
export interface ResolvedRemoteAddresses {
all: NetworkAddress[]
userVisible: NetworkAddress[]
primaryRemoteUrl?: string
}
export function resolveNetworkAddresses(args: { export function resolveNetworkAddresses(args: {
host: string host: string
protocol: "http" | "https" protocol: "http" | "https"
@@ -58,10 +64,57 @@ export function resolveNetworkAddresses(args: {
return results.sort((a, b) => { return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope] const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
return 0
}) })
} }
export function resolveRemoteAddresses(args: {
host: string
protocol: "http" | "https"
port: number
}): ResolvedRemoteAddresses {
const all = resolveNetworkAddresses(args)
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
return {
all,
userVisible,
primaryRemoteUrl: userVisible[0]?.remoteUrl,
}
}
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
}
function getUserVisiblePriority(ip: string): number {
if (isPrivateIPv4(ip)) return 0
if (isLinkLocalIPv4(ip)) return 2
return 1
}
function isLinkLocalIPv4(ip: string): boolean {
const octets = parseIPv4(ip)
if (!octets) return false
const [first, second] = octets
return first === 169 && second === 254
}
function isPrivateIPv4(ip: string): boolean {
const octets = parseIPv4(ip)
if (!octets) return false
const [first, second] = octets
if (first === 10) return true
if (first === 192 && second === 168) return true
return first === 172 && second >= 16 && second <= 31
}
function parseIPv4(value: string): number[] | null {
if (!isIPv4Address(value)) return null
return value.split(".").map((part) => Number(part))
}
function isIPv4Address(value: string | undefined): value is string { function isIPv4Address(value: string | undefined): value is string {
if (!value) return false if (!value) return false
const parts = value.split(".") const parts = value.split(".")

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { ServerMeta } from "../../api-types" import { ServerMeta } from "../../api-types"
import { resolveNetworkAddresses } from "../network-addresses"
interface RouteDeps { interface RouteDeps {
serverMeta: ServerMeta serverMeta: ServerMeta
@@ -13,14 +13,12 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
function buildMetaResponse(meta: ServerMeta): ServerMeta { function buildMetaResponse(meta: ServerMeta): ServerMeta {
const localPort = resolveLocalPort(meta) const localPort = resolveLocalPort(meta)
const remote = resolveRemote(meta) const remote = resolveRemote(meta)
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
return { return {
...meta, ...meta,
localPort, localPort,
remotePort: remote?.port, remotePort: remote?.port,
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local", listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
addresses,
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,204 @@
import { Readable } from "node:stream"
import OpenAI from "openai"
import { toFile } from "openai/uploads"
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
import type { Logger } from "../../logger"
import type { NormalizedSpeechSettings, SpeechSynthesisStreamResponse, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
interface OpenAICompatibleSpeechProviderOptions {
settings: NormalizedSpeechSettings
logger: Logger
}
export class OpenAICompatibleSpeechProvider {
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
getCapabilities() {
const { settings } = this.options
return {
available: true,
configured: Boolean(settings.apiKey),
provider: settings.provider,
supportsStt: true,
supportsTts: true,
supportsStreamingTts: true,
baseUrl: settings.baseUrl,
sttModel: settings.sttModel,
ttsModel: settings.ttsModel,
ttsVoice: settings.ttsVoice,
ttsFormats: ["mp3", "wav", "opus", "aac"],
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
}
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
const client = this.createClient()
const startedAt = Date.now()
const extension = extensionForMime(input.mimeType)
const buffer = Buffer.from(input.audioBase64, "base64")
const filename = input.filename?.trim() || `prompt-input.${extension}`
this.options.logger.info(
{
mimeType: input.mimeType,
bytes: buffer.byteLength,
language: input.language,
model: this.options.settings.sttModel,
},
"speech.transcribe",
)
const response = await this.requestTranscription(client, buffer, filename, input)
return {
text: typeof response?.text === "string" ? response.text : "",
language: typeof response?.language === "string" ? response.language : input.language,
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
segments: Array.isArray(response?.segments)
? response.segments
.filter((segment: any) => typeof segment?.text === "string")
.map((segment: any) => ({
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
text: String(segment.text),
}))
: undefined,
}
}
private async requestTranscription(
client: OpenAI,
buffer: Buffer,
filename: string,
input: TranscribeAudioInput,
): Promise<any> {
const baseRequest = {
model: this.options.settings.sttModel,
...(input.language ? { language: input.language } : {}),
...(input.prompt ? { prompt: input.prompt } : {}),
}
try {
const file = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file,
response_format: "verbose_json" as any,
} as any)) as any
} catch (error) {
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file: retryFile,
} as any)) as any
}
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
const format = input.format ?? this.options.settings.ttsFormat
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize",
)
const response = await this.requestSpeechAudio(input.text, format)
const mimeType = response.headers.get("content-type") || mimeTypeForFormat(format)
const audioBuffer = Buffer.from(await response.arrayBuffer())
return {
audioBase64: audioBuffer.toString("base64"),
mimeType,
}
}
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
const format = input.format ?? this.options.settings.ttsFormat
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize.stream",
)
const response = await this.requestSpeechAudio(input.text, format)
if (!response.body) {
throw new Error("Speech provider did not return a stream.")
}
return {
stream: Readable.fromWeb(response.body as any),
mimeType: response.headers.get("content-type") || mimeTypeForFormat(format),
}
}
private async requestSpeechAudio(text: string, format: "mp3" | "wav" | "opus" | "aac"): Promise<Response> {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${settings.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: settings.ttsModel,
voice: settings.ttsVoice,
input: text,
response_format: format,
}),
})
if (!response.ok) {
const detail = await response.text()
throw new Error(detail || `Speech synthesis failed with ${response.status}`)
}
return response
}
private createClient(): OpenAI {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
return new OpenAI({
apiKey: settings.apiKey,
baseURL: settings.baseUrl,
})
}
}
function extensionForMime(mimeType: string): string {
const normalized = mimeType.toLowerCase()
if (normalized.includes("webm")) return "webm"
if (normalized.includes("ogg")) return "ogg"
if (normalized.includes("wav")) return "wav"
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
return "webm"
}
function mimeTypeForFormat(format: "mp3" | "wav" | "opus" | "aac"): string {
if (format === "wav") return "audio/wav"
if (format === "opus") return 'audio/ogg; codecs="opus"'
if (format === "aac") return "audio/aac"
return "audio/mpeg"
}
function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -9,18 +9,34 @@ use std::ffi::OsStr;
use std::fs; use std::fs;
use std::io::{BufRead, BufReader, Read, Write}; use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream; use std::net::TcpStream;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use std::time::{Duration, Instant}; 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::process::CommandExt;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x08000000;
fn log_line(message: &str) { fn log_line(message: &str) {
println!("[tauri-cli] {message}"); println!("[tauri-cli] {message}");
} }
#[cfg(windows)]
fn configure_spawn(command: &mut Command) {
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn configure_spawn(_command: &mut Command) {}
fn workspace_root() -> Option<PathBuf> { fn workspace_root() -> Option<PathBuf> {
std::env::current_dir().ok().and_then(|mut dir| { std::env::current_dir().ok().and_then(|mut dir| {
for _ in 0..3 { for _ in 0..3 {
@@ -32,10 +48,52 @@ fn workspace_root() -> Option<PathBuf> {
}) })
} }
const SESSION_COOKIE_NAME: &str = "codenomad_session"; const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30; const CLI_STOP_GRACE_SECS: u64 = 30;
#[cfg(windows)]
const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000;
#[cfg(unix)]
fn configure_posix_process_group(command: &mut Command) {
// Ensure the CLI runs in its own process group so we can terminate wrapper
// processes (login shell/tsx) without leaving the server orphaned.
unsafe {
command.pre_exec(|| {
if libc::setpgid(0, 0) != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
#[cfg(windows)]
fn kill_process_tree_windows(pid: u32, force: bool) -> bool {
let mut args = vec!["/PID".to_string(), pid.to_string(), "/T".to_string()];
if force {
args.push("/F".to_string());
}
let mut command = Command::new("taskkill");
command.args(&args);
configure_spawn(&mut command);
match command.output() {
Ok(output) => {
if output.status.success() {
return true;
}
// If the PID is already gone, treat it as success.
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
let combined = format!("{stdout}\n{stderr}");
combined.contains("not found") || combined.contains("no running instance")
}
Err(_) => false,
}
}
fn navigate_main(app: &AppHandle, url: &str) { fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") { if let Some(win) = app.webview_windows().get("main") {
let mut display = url.to_string(); let mut display = url.to_string();
@@ -66,7 +124,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
Some(value.to_string()) Some(value.to_string())
} }
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> { fn exchange_bootstrap_token(
base_url: &str,
token: &str,
cookie_name: &str,
) -> anyhow::Result<Option<String>> {
let parsed = Url::parse(base_url)?; let parsed = Url::parse(base_url)?;
let host = parsed.host_str().unwrap_or("127.0.0.1"); let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port_or_known_default().unwrap_or(80); let port = parsed.port_or_known_default().unwrap_or(80);
@@ -101,11 +163,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
for line in lines { for line in lines {
// handle case-insensitive header name // handle case-insensitive header name
if let Some(value) = line.strip_prefix("Set-Cookie:") { if let Some(value) = line.strip_prefix("Set-Cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) { if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
return Ok(Some(session_id)); return Ok(Some(session_id));
} }
} else if let Some(value) = line.strip_prefix("set-cookie:") { } else if let Some(value) = line.strip_prefix("set-cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) { if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
return Ok(Some(session_id)); return Ok(Some(session_id));
} }
} }
@@ -114,11 +176,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
Ok(None) Ok(None)
} }
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> { fn set_session_cookie(
app: &AppHandle,
base_url: &str,
cookie_name: &str,
session_id: &str,
) -> anyhow::Result<()> {
let parsed = Url::parse(base_url)?; let parsed = Url::parse(base_url)?;
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string(); let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id)) let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
.domain(domain) .domain(domain)
.path("/") .path("/")
.http_only(true) .http_only(true)
@@ -132,6 +199,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
Ok(()) Ok(())
} }
fn generate_auth_cookie_name() -> String {
let pid = std::process::id();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -346,13 +423,21 @@ impl CliProcessManager {
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 {
libc::kill(child.id() as i32, libc::SIGTERM); let pid = child.id() as i32;
// Prefer signaling the process group to avoid orphaning children
// when the CLI was launched via a wrapper shell.
let group_res = libc::kill(-pid, libc::SIGTERM);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGTERM);
}
} }
#[cfg(windows)] #[cfg(windows)]
{ {
let _ = child.kill(); let _ = kill_process_tree_windows(child.id(), false);
} }
let start = Instant::now(); let start = Instant::now();
@@ -360,6 +445,21 @@ impl CliProcessManager {
match child.try_wait() { match child.try_wait() {
Ok(Some(_)) => break, Ok(Some(_)) => break,
Ok(None) => { Ok(None) => {
#[cfg(windows)]
if !forced_tree_shutdown
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
{
log_line(&format!(
"regular Windows shutdown still running after {}ms; escalating pid={}",
CLI_WINDOWS_FORCE_GRACE_MS,
child.id()
));
forced_tree_shutdown = true;
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) { if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
log_line(&format!( log_line(&format!(
"stop timed out after {}s; sending SIGKILL pid={}", "stop timed out after {}s; sending SIGKILL pid={}",
@@ -368,11 +468,21 @@ impl CliProcessManager {
)); ));
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
libc::kill(child.id() as i32, libc::SIGKILL); let pid = child.id() as i32;
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
} }
#[cfg(windows)] #[cfg(windows)]
{ {
let _ = child.kill(); if !forced_tree_shutdown
&& !kill_process_tree_windows(child.id(), true)
{
let _ = child.kill();
} else if forced_tree_shutdown {
let _ = child.kill();
}
} }
break; break;
} }
@@ -412,7 +522,8 @@ impl CliProcessManager {
"resolved CLI entry runner={:?} entry={} host={}", "resolved CLI entry runner={:?} entry={} host={}",
resolution.runner, resolution.entry, host resolution.runner, resolution.entry, host
)); ));
let args = resolution.build_args(dev, &host); let auth_cookie_name = Arc::new(generate_auth_cookie_name());
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
log_line(&format!("CLI args: {:?}", args)); log_line(&format!("CLI args: {:?}", args));
if dev { if dev {
log_line("development mode: will prefer tsx + source if present"); log_line("development mode: will prefer tsx + source if present");
@@ -450,9 +561,12 @@ impl CliProcessManager {
.env("ELECTRON_RUN_AS_NODE", "1") .env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd { if let Some(ref cwd) = cwd {
c.current_dir(cwd); c.current_dir(cwd);
} }
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()? c.spawn()?
} }
ShellCommandType::Direct(cmd) => { ShellCommandType::Direct(cmd) => {
@@ -462,9 +576,12 @@ impl CliProcessManager {
.env("ELECTRON_RUN_AS_NODE", "1") .env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
configure_spawn(&mut c);
if let Some(ref cwd) = cwd { if let Some(ref cwd) = cwd {
c.current_dir(cwd); c.current_dir(cwd);
} }
#[cfg(unix)]
configure_posix_process_group(&mut c);
c.spawn()? c.spawn()?
} }
}; };
@@ -487,6 +604,7 @@ impl CliProcessManager {
let app_clone = app.clone(); let app_clone = app.clone();
let ready_clone = ready.clone(); let ready_clone = ready.clone();
let token_clone = bootstrap_token.clone(); let token_clone = bootstrap_token.clone();
let auth_cookie_name_clone = auth_cookie_name.clone();
thread::spawn(move || { thread::spawn(move || {
let stdout = child_clone let stdout = child_clone
@@ -508,6 +626,7 @@ impl CliProcessManager {
&status_clone, &status_clone,
&ready_clone, &ready_clone,
&token_clone, &token_clone,
auth_cookie_name_clone.as_str(),
); );
} }
if let Some(reader) = stderr { if let Some(reader) = stderr {
@@ -518,6 +637,7 @@ impl CliProcessManager {
&status_clone, &status_clone,
&ready_clone, &ready_clone,
&token_clone, &token_clone,
auth_cookie_name_clone.as_str(),
); );
} }
}); });
@@ -537,7 +657,24 @@ impl CliProcessManager {
locked.error = Some("CLI did not start in time".to_string()); locked.error = Some("CLI did not start in time".to_string());
log_line("timeout waiting for CLI readiness"); log_line("timeout waiting for CLI readiness");
if let Some(child) = child_holder_clone.lock().as_mut() { if let Some(child) = child_holder_clone.lock().as_mut() {
let _ = child.kill(); #[cfg(unix)]
unsafe {
let pid = child.id() as i32;
let group_res = libc::kill(-pid, libc::SIGKILL);
if group_res != 0 {
let _ = libc::kill(pid, libc::SIGKILL);
}
}
#[cfg(windows)]
{
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = child.kill();
}
} }
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"})); let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
Self::emit_status(&app_clone, &locked); Self::emit_status(&app_clone, &locked);
@@ -617,6 +754,7 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>, status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>, ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>, bootstrap_token: &Arc<Mutex<Option<String>>>,
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+)").ok(); let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
@@ -652,7 +790,14 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.map(|m| m.as_str().to_string()) .map(|m| m.as_str().to_string())
{ {
Self::mark_ready(app, status, ready, bootstrap_token, url); Self::mark_ready(
app,
status,
ready,
bootstrap_token,
auth_cookie_name,
url,
);
continue; continue;
} }
@@ -667,6 +812,7 @@ impl CliProcessManager {
status, status,
ready, ready,
bootstrap_token, bootstrap_token,
auth_cookie_name,
format!("http://localhost:{port}"), format!("http://localhost:{port}"),
); );
continue; continue;
@@ -679,6 +825,7 @@ impl CliProcessManager {
status, status,
ready, ready,
bootstrap_token, bootstrap_token,
auth_cookie_name,
format!("http://localhost:{}", port), format!("http://localhost:{}", port),
); );
continue; continue;
@@ -697,6 +844,7 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>, status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>, ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>, bootstrap_token: &Arc<Mutex<Option<String>>>,
auth_cookie_name: &str,
base_url: String, base_url: String,
) { ) {
ready.store(true, Ordering::SeqCst); ready.store(true, Ordering::SeqCst);
@@ -720,9 +868,11 @@ impl CliProcessManager {
if scheme.as_deref() != Some("http") { if scheme.as_deref() != Some("http") {
navigate_main(app, &base_url); navigate_main(app, &base_url);
} else { } else {
match exchange_bootstrap_token(&base_url, &token) { match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
Ok(Some(session_id)) => { Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) { if let Err(err) =
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
{
log_line(&format!("failed to set session cookie: {err}")); log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login")); navigate_main(app, &format!("{base_url}/login"));
} else { } else {
@@ -818,24 +968,43 @@ impl CliEntry {
)) ))
} }
fn build_args(&self, dev: bool, host: &str) -> Vec<String> { fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
let mut args = vec![ let mut args = vec![
"serve".to_string(), "serve".to_string(),
"--host".to_string(), "--host".to_string(),
host.to_string(), host.to_string(),
"--auth-cookie-name".to_string(),
auth_cookie_name.to_string(),
"--generate-token".to_string(), "--generate-token".to_string(),
]; ];
if dev { if dev {
// Dev: plain HTTP + Vite dev server proxy. // Dev: plain HTTP + Vite dev server proxy.
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
std::env::var("ELECTRON_RENDERER_URL")
.ok()
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| "http://localhost:3000".to_string());
let log_level = std::env::var("CLI_LOG_LEVEL")
.ok()
.map(|value| value.trim().to_lowercase())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "info".to_string());
args.push("--https".to_string()); args.push("--https".to_string());
args.push("false".to_string()); args.push("false".to_string());
args.push("--http".to_string()); args.push("--http".to_string());
args.push("true".to_string()); args.push("true".to_string());
args.push("--http-port".to_string());
args.push("0".to_string());
args.push("--ui-dev-server".to_string()); args.push("--ui-dev-server".to_string());
args.push("http://localhost:3000".to_string()); args.push(ui_dev_server);
args.push("--log-level".to_string()); args.push("--log-level".to_string());
args.push("debug".to_string()); args.push(log_level);
} else { } else {
// Prod desktop: always keep loopback HTTP enabled. // Prod desktop: always keep loopback HTTP enabled.
args.push("--https".to_string()); args.push("--https".to_string());
@@ -900,6 +1069,11 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
if let Ok(exe) = std::env::current_exe() { if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() { if let Some(dir) = exe.parent() {
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/index.js")));
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
let resources = dir.join("../Resources"); let resources = dir.join("../Resources");
candidates.push(Some(resources.join("server/dist/bin.js"))); candidates.push(Some(resources.join("server/dist/bin.js")));
candidates.push(Some(resources.join("server/dist/index.js"))); candidates.push(Some(resources.join("server/dist/index.js")));
@@ -995,9 +1169,18 @@ fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
} }
fn normalize_path(path: PathBuf) -> String { fn normalize_path(path: PathBuf) -> String {
if let Ok(clean) = path.canonicalize() { let resolved = if let Ok(clean) = path.canonicalize() {
clean.to_string_lossy().to_string() clean
} else { } else {
path.to_string_lossy().to_string() path
};
let rendered = resolved.to_string_lossy().to_string();
if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") {
format!("\\\\{}", stripped)
} else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") {
stripped.to_string()
} else {
rendered
} }
} }

View File

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

View File

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

View File

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

View File

@@ -9,12 +9,10 @@ import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs" import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell2" import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay" import { SettingsScreen } from "./components/settings-screen"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { initGithubStars } from "./stores/github-stars" import { initGithubStars } from "./stores/github-stars"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger" import { getLogger } from "./lib/logger"
@@ -54,11 +52,11 @@ import {
} from "./stores/sessions" } from "./stores/sessions"
import { getInstanceSessionIndicatorStatus } from "./stores/session-status" import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
import { openSettings } from "./stores/settings-screen"
const log = getLogger("actions") const log = getLogger("actions")
const App: Component = () => { const App: Component = () => {
const { isDark } = useTheme()
const { t } = useI18n() const { t } = useI18n()
const { const {
preferences, preferences,
@@ -70,6 +68,7 @@ const App: Component = () => {
toggleAutoCleanupBlankSessions, toggleAutoCleanupBlankSessions,
toggleUsageMetrics, toggleUsageMetrics,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
@@ -77,8 +76,6 @@ const App: Component = () => {
setToolInputsVisibility, setToolInputsVisibility,
} = useConfig() } = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const phoneQuery = useMediaQuery("(max-width: 767px)") const phoneQuery = useMediaQuery("(max-width: 767px)")
@@ -184,10 +181,6 @@ const App: Component = () => {
} }
}) })
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => { createEffect(() => {
initReleaseNotifications() initReleaseNotifications()
}) })
@@ -252,7 +245,6 @@ const App: Component = () => {
clearLaunchError() clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary) const instanceId = await createInstance(folderPath, selectedBinary)
setShowFolderSelection(false) setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
log.info("Created instance", { log.info("Created instance", {
instanceId, instanceId,
@@ -274,7 +266,7 @@ const App: Component = () => {
function handleLaunchErrorAdvanced() { function handleLaunchErrorAdvanced() {
clearLaunchError() clearLaunchError()
setIsAdvancedSettingsOpen(true) openSettings("opencode")
} }
function handleNewInstanceRequest() { function handleNewInstanceRequest() {
@@ -362,6 +354,7 @@ const App: Component = () => {
toggleShowTimelineTools, toggleShowTimelineTools,
toggleUsageMetrics, toggleUsageMetrics,
togglePromptSubmitOnEnter, togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode, setDiffViewMode,
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
@@ -487,7 +480,6 @@ const App: Component = () => {
onSelect={setActiveInstanceId} onSelect={setActiveInstanceId}
onClose={handleCloseInstance} onClose={handleCloseInstance}
onNew={handleNewInstanceRequest} onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/> />
</Show> </Show>
@@ -496,17 +488,24 @@ const App: Component = () => {
const isActiveInstance = () => activeInstanceId() === instance.id const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection() const isVisible = () => isActiveInstance() && !showFolderSelection()
return ( return (
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}> <div
<InstanceMetadataProvider instance={instance}> class="flex-1 min-h-0 overflow-hidden"
<InstanceShell style={{ display: isVisible() ? "flex" : "none" }}
instance={instance} data-instance-id={instance.id}
escapeInDebounce={escapeInDebounce()} data-instance-active={isActiveInstance() ? "true" : "false"}
paletteCommands={paletteCommands} data-instance-visible={isVisible() ? "true" : "false"}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)} >
onNewSession={() => handleNewSession(instance.id)} <InstanceMetadataProvider instance={instance}>
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} <InstanceShell
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} instance={instance}
onExecuteCommand={executeCommand} isActiveInstance={isActiveInstance()}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()} onEnterMobileFullscreen={() => void enterMobileFullscreen()}
@@ -526,10 +525,6 @@ const App: Component = () => {
<FolderSelectionView <FolderSelectionView
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()} isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/> />
</Show> </Show>
@@ -539,12 +534,8 @@ const App: Component = () => {
<FolderSelectionView <FolderSelectionView
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()} isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onClose={() => { onClose={() => {
setShowFolderSelection(false) setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError() clearLaunchError()
}} }}
/> />
@@ -552,7 +543,7 @@ const App: Component = () => {
</div> </div>
</Show> </Show>
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} /> <SettingsScreen />
<AlertDialog /> <AlertDialog />

View File

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

View File

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

View File

@@ -61,6 +61,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
// 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,
lineDecorationsWidth: 12, lineDecorationsWidth: 12,
// Use legacy diff algorithm for better performance with large files
// See: https://github.com/microsoft/vscode/issues/184037
diffAlgorithm: "legacy",
// Limit computation time to avoid freezing on large files
maxComputationTime: 10000,
}) })
setReady(true) setReady(true)

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,9 @@ const log = getLogger("session")
interface InstanceShellProps { interface InstanceShellProps {
instance: Instance instance: Instance
// Provided by App-level instance tabs; lets us pause heavy rendering
// work for inactive instances while keeping them mounted for fast switching.
isActiveInstance?: boolean
escapeInDebounce: boolean escapeInDebounce: boolean
paletteCommands: Accessor<Command[]> paletteCommands: Accessor<Command[]>
onCloseSession: (sessionId: string) => Promise<void> | void onCloseSession: (sessionId: string) => Promise<void> | void
@@ -78,7 +81,8 @@ interface InstanceShellProps {
} }
const InstanceShell2: Component<InstanceShellProps> = (props) => { const InstanceShell2: Component<InstanceShellProps> = (props) => {
const { t } = useI18n() const { t, locale } = useI18n()
const isRTL = () => locale() === "he"
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH) const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const [rightDrawerWidth, setRightDrawerWidth] = createSignal( const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
@@ -115,6 +119,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const desktopQuery = useMediaQuery("(min-width: 1280px)") const desktopQuery = useMediaQuery("(min-width: 1280px)")
const tabletQuery = useMediaQuery("(min-width: 768px)") const tabletQuery = useMediaQuery("(min-width: 768px)")
const compactHeaderQuery = useMediaQuery("(max-width: 1024px)")
const layoutMode = createMemo<LayoutMode>(() => { const layoutMode = createMemo<LayoutMode>(() => {
if (desktopQuery()) return "desktop" if (desktopQuery()) return "desktop"
@@ -123,6 +128,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}) })
const isPhoneLayout = createMemo(() => layoutMode() === "phone") const isPhoneLayout = createMemo(() => layoutMode() === "phone")
const compactHeaderLayout = createMemo(() => isPhoneLayout() || compactHeaderQuery())
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout()) const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop") const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
@@ -366,7 +372,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
sx={{ sx={{
width: `${sessionSidebarWidth()}px`, width: `${sessionSidebarWidth()}px`,
flexShrink: 0, flexShrink: 0,
borderRight: "1px solid var(--border-base)", borderInlineEnd: "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)", backgroundColor: "var(--surface-secondary)",
height: "100%", height: "100%",
minHeight: 0, minHeight: 0,
@@ -408,7 +414,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const modalProps = container ? { container: container as Element } : undefined const modalProps = container ? { container: container as Element } : undefined
return ( return (
<Drawer <Drawer
anchor="left" anchor={isRTL() ? "right" : "left"}
variant="temporary" variant="temporary"
open={leftOpen()} open={leftOpen()}
onClose={closeLeftDrawer} onClose={closeLeftDrawer}
@@ -417,7 +423,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
"& .MuiDrawer-paper": { "& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`, width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
boxSizing: "border-box", boxSizing: "border-box",
borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)", borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)", backgroundColor: "var(--surface-secondary)",
backgroundImage: "none", backgroundImage: "none",
color: "var(--text-primary)", color: "var(--text-primary)",
@@ -475,7 +481,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
sx={{ sx={{
width: `${rightDrawerWidth()}px`, width: `${rightDrawerWidth()}px`,
flexShrink: 0, flexShrink: 0,
borderLeft: "1px solid var(--border-base)", borderInlineStart: "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)", backgroundColor: "var(--surface-secondary)",
height: "100%", height: "100%",
minHeight: 0, minHeight: 0,
@@ -518,7 +524,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const modalProps = container ? { container: container as Element } : undefined const modalProps = container ? { container: container as Element } : undefined
return ( return (
<Drawer <Drawer
anchor="right" anchor={isRTL() ? "left" : "right"}
variant="temporary" variant="temporary"
open={rightOpen()} open={rightOpen()}
onClose={closeRightDrawer} onClose={closeRightDrawer}
@@ -527,7 +533,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
"& .MuiDrawer-paper": { "& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`, width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
boxSizing: "border-box", boxSizing: "border-box",
borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)", borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)", backgroundColor: "var(--surface-secondary)",
backgroundImage: "none", backgroundImage: "none",
color: "var(--text-primary)", color: "var(--text-primary)",
@@ -596,7 +602,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base"> <AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]"> <Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
<Show <Show
when={!isPhoneLayout()} when={!compactHeaderLayout()}
fallback={ fallback={
<div class="flex flex-col w-full gap-1.5"> <div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full"> <div class="flex flex-wrap items-center justify-between gap-2 w-full">
@@ -625,7 +631,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex flex-wrap items-center justify-center gap-1"> <div class="flex flex-wrap items-center justify-center gap-1">
<button <button
type="button" type="button"
class="connection-status-button px-2 py-0.5 text-xs" class="connection-status-button command-palette-button"
onClick={handleCommandPaletteClick} onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")} aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }} style={{ flex: "0 0 auto", width: "auto" }}
@@ -634,8 +640,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</button> </button>
<span class="connection-status-shortcut-hint kbd-hint"> <span class="connection-status-shortcut-hint kbd-hint">
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
</span> </span>
</div> </div>
<div class="flex-1 flex items-center justify-center min-w-0"> <div class="flex-1 flex items-center justify-center min-w-0">
<span <span
@@ -646,7 +652,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</span> </span>
</div> </div>
<Show when={!props.mobileFullscreenMode}> <Show when={isPhoneLayout() && !props.mobileFullscreenMode}>
<IconButton <IconButton
color="inherit" color="inherit"
onClick={props.onEnterMobileFullscreen} onClick={props.onEnterMobileFullscreen}
@@ -670,16 +676,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
{rightAppBarButtonIcon()} {rightAppBarButtonIcon()}
</IconButton> </IconButton>
</Show> </Show>
</div> </div>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1"> <div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<ContextMeter <Show when={!showingInfoView()}>
usedTokens={tokenStats().used} <ContextMeter
availableTokens={tokenStats().avail} usedTokens={tokenStats().used}
formatTokens={formatTokenTotal} availableTokens={tokenStats().avail}
usedLabel={t("instanceShell.metrics.usedLabel")} formatTokens={formatTokenTotal}
availableLabel={t("instanceShell.metrics.availableLabel")} usedLabel={t("instanceShell.metrics.usedLabel")}
/> availableLabel={t("instanceShell.metrics.availableLabel")}
/>
</Show>
</div> </div>
</div> </div>
} }
@@ -721,7 +729,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]"> <div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
<button <button
type="button" type="button"
class="connection-status-button px-2 py-0.5 text-xs" class="connection-status-button command-palette-button"
onClick={handleCommandPaletteClick} onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")} aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }} style={{ flex: "0 0 auto", width: "auto" }}
@@ -735,7 +743,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
</span> </span>
<div class="ml-auto flex items-center gap-3"> <div class="ms-auto flex items-center gap-3">
<div class="connection-status-meta flex items-center gap-3"> <div class="connection-status-meta flex items-center gap-3">
<Show when={connectionStatus() === "connected"}> <Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected"> <span class="status-indicator connected">
@@ -796,12 +804,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
> >
<For each={cachedSessionIds()}> <For each={cachedSessionIds()}>
{(sessionId) => { {(sessionId) => {
const isActive = () => activeSessionIdForInstance() === sessionId const isActive = () => Boolean(props.isActiveInstance) && activeSessionIdForInstance() === sessionId
return ( return (
<div <div
class="session-cache-pane flex flex-col flex-1 min-h-0" class="session-cache-pane flex flex-col flex-1 min-h-0"
style={{ display: isActive() ? "flex" : "none" }} style={{ display: isActive() ? "flex" : "none" }}
data-session-id={sessionId} data-session-id={sessionId}
data-instance-id={props.instance.id}
data-session-active={isActive() ? "true" : "false"}
aria-hidden={!isActive()} aria-hidden={!isActive()}
> >
<SessionView <SessionView
@@ -837,7 +847,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
return ( return (
<> <>
<div class="instance-shell2 flex flex-col flex-1 min-h-0"> <div
class="instance-shell2 flex flex-col flex-1 min-h-0"
data-instance-id={props.instance.id}
>
<Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}> <Show when={hasSessions()} fallback={<InstanceWelcomeView instance={props.instance} />}>
{sessionLayout} {sessionLayout}
</Show> </Show>

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface ChangesTabProps { interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -32,14 +34,18 @@ interface ChangesTabProps {
} }
const ChangesTab: Component<ChangesTabProps> = (props) => { const ChangesTab: Component<ChangesTabProps> = (props) => {
const renderContent = (): JSX.Element => { const sessionId = createMemo(() => props.activeSessionId())
const sessionId = props.activeSessionId() const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
const diffs = createMemo(() => (hasSession() ? props.activeSessionDiffs() : null))
const hasSession = Boolean(sessionId && sessionId !== "info") const sorted = createMemo<any[]>(() => {
const diffs = hasSession ? props.activeSessionDiffs() : null const list = diffs()
if (!Array.isArray(list)) return []
return [...list].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
})
const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : [] const totals = createMemo(() => {
const totals = sorted.reduce( return sorted().reduce(
(acc, item) => { (acc, item) => {
acc.additions += typeof item.additions === "number" ? item.additions : 0 acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0 acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
@@ -47,41 +53,61 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
}, },
{ additions: 0, deletions: 0 }, { additions: 0, deletions: 0 },
) )
})
const mostChanged = sorted.length const mostChanged = createMemo<any | null>(() => {
? sorted.reduce((best, item) => { const items = sorted()
const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0 if (items.length === 0) return null
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0 return items.reduce((best, item) => {
const bestScore = bestAdd + bestDel const bestAdd = typeof (best as any)?.additions === "number" ? (best as any).additions : 0
const bestDel = typeof (best as any)?.deletions === "number" ? (best as any).deletions : 0
const bestScore = bestAdd + bestDel
const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0 const add = typeof (item as any)?.additions === "number" ? (item as any).additions : 0
const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0 const del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
const score = add + del const score = add + del
if (score > bestScore) return item if (score > bestScore) return item
if (score < bestScore) return best if (score < bestScore) return best
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
}, sorted[0]) }, items[0])
: null })
// Auto-select the most-changed file if none selected. const selectedFileData = createMemo<any | null>(() => {
const currentSelected = props.selectedFile() const currentSelected = props.selectedFile()
const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged const items = sorted()
if (currentSelected) {
const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}` const match = items.find((f) => f.file === currentSelected)
if (match) return match
const emptyViewerMessage = () => {
if (!hasSession) return props.t("instanceShell.sessionChanges.noSessionSelected")
if (diffs === undefined) return props.t("instanceShell.sessionChanges.loading")
if (!Array.isArray(diffs) || diffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
} }
return mostChanged()
})
const scopeKey = createMemo(() => `${props.instanceId}:${hasSession() ? sessionId() : "no-session"}`)
const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
const currentDiffs = diffs()
if (currentDiffs === undefined) return props.t("instanceShell.sessionChanges.loading")
if (!Array.isArray(currentDiffs) || currentDiffs.length === 0) return props.t("instanceShell.sessionChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
})
const headerPath = createMemo(() => {
const file = selectedFileData()
return file?.file ? String(file.file) : props.t("instanceShell.rightPanel.tabs.changes")
})
const renderContent = (): JSX.Element => {
const sortedList = sorted()
const totalsValue = totals()
const selected = selectedFileData()
const renderViewer = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
<div class="file-viewer-content file-viewer-content--monaco"> <div class="file-viewer-content file-viewer-content--monaco">
<Show <Show
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null} when={selected && hasSession() && sortedList.length > 0 ? selected : null}
fallback={ fallback={
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span> <span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
@@ -89,15 +115,23 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<MonacoDiffViewer <Suspense
scopeKey={scopeKey} fallback={
path={String(file().file || "")} <div class="file-viewer-empty">
before={String((file() as any).before || "")} <span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
after={String((file() as any).after || "")} </div>
viewMode={props.diffViewMode()} }
contextMode={props.diffContextMode()} >
wordWrap={props.diffWordWrapMode()} <LazyMonacoDiffViewer
/> scopeKey={scopeKey()}
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
)} )}
</Show> </Show>
</div> </div>
@@ -109,11 +143,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
) )
const renderListPanel = () => ( const renderListPanel = () => (
<Show when={sorted.length > 0} fallback={renderEmptyList()}> <Show when={sortedList.length > 0} fallback={renderEmptyList()}>
<For each={sorted}> <For each={sortedList}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`} class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => { onClick={() => {
props.onSelectFile(item.file, props.isPhoneLayout()) props.onSelectFile(item.file, props.isPhoneLayout())
}} }}
@@ -134,11 +168,11 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
) )
const renderListOverlay = () => ( const renderListOverlay = () => (
<Show when={sorted.length > 0} fallback={renderEmptyList()}> <Show when={sortedList.length > 0} fallback={renderEmptyList()}>
<For each={sorted}> <For each={sortedList}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`} class={`file-list-item ${selected?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => { onClick={() => {
props.onSelectFile(item.file, true) props.onSelectFile(item.file, true)
}} }}
@@ -159,8 +193,6 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
</Show> </Show>
) )
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
return ( return (
<SplitFilePanel <SplitFilePanel
header={ header={
@@ -171,10 +203,10 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}> <div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions"> <span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span> <span class="files-tab-stat-value">+{totalsValue.additions}</span>
</span> </span>
<span class="files-tab-stat files-tab-stat-deletions"> <span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span> <span class="files-tab-stat-value">-{totalsValue.deletions}</span>
</span> </span>
</div> </div>
@@ -198,7 +230,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Changes" overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.changes")}
/> />
) )
} }

View File

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

View File

@@ -1,14 +1,16 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js" import { For, Show, Suspense, createMemo, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid" import { RefreshCw } from "lucide-solid"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
const LazyMonacoDiffViewer = lazy(() =>
import("../../../../file-viewer/monaco-diff-viewer").then((module) => ({ default: module.MonacoDiffViewer })),
)
interface GitChangesTabProps { interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -46,17 +48,18 @@ interface GitChangesTabProps {
} }
const GitChangesTab: Component<GitChangesTabProps> = (props) => { const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderContent = (): JSX.Element => { const sessionId = createMemo(() => props.activeSessionId())
const sessionId = props.activeSessionId() const hasSession = createMemo(() => Boolean(sessionId() && sessionId() !== "info"))
const entries = createMemo(() => (hasSession() ? props.entries() : null))
const hasSession = Boolean(sessionId && sessionId !== "info") const sorted = createMemo<GitFileStatus[]>(() => {
const entries = hasSession ? props.entries() : null const list = entries()
if (!Array.isArray(list)) return []
return [...list].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
})
const sorted = Array.isArray(entries) const totals = createMemo(() => {
? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || ""))) return sorted().reduce(
: []
const totals = sorted.reduce(
(acc, item) => { (acc, item) => {
acc.additions += typeof item.added === "number" ? item.added : 0 acc.additions += typeof item.added === "number" ? item.added : 0
acc.deletions += typeof item.removed === "number" ? item.removed : 0 acc.deletions += typeof item.removed === "number" ? item.removed : 0
@@ -64,21 +67,33 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
}, },
{ additions: 0, deletions: 0 }, { additions: 0, deletions: 0 },
) )
})
const nonDeleted = sorted.filter((item) => item && item.status !== "deleted") const nonDeleted = createMemo(() => sorted().filter((item) => item && item.status !== "deleted"))
const emptyViewerMessage = () => {
if (!hasSession) return "Select a session to view changes."
if (entries === null) return "Loading git changes…"
if (nonDeleted.length === 0) return "No git changes yet."
return "No file selected."
}
const selectedEntry = createMemo<GitFileStatus | null>(() => {
const list = sorted()
const selectedPath = props.selectedPath() const selectedPath = props.selectedPath()
const fallbackPath = props.mostChangedPath() const fallbackPath = props.mostChangedPath()
const selectedEntry = const found =
sorted.find((item) => item.path === selectedPath) || list.find((item) => item.path === selectedPath) ||
(fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null) (fallbackPath ? list.find((item) => item.path === fallbackPath) : undefined)
return found ?? null
})
const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
const currentEntries = entries()
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
return props.t("instanceShell.filesShell.viewerEmpty")
})
const renderContent = (): JSX.Element => {
const totalsValue = totals()
const selected = selectedEntry()
const sortedList = sorted()
const nonDeletedList = nonDeleted()
const renderViewer = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
@@ -91,12 +106,12 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
fallback={ fallback={
<Show <Show
when={ when={
selectedEntry && selected &&
props.selectedBefore() !== null && props.selectedBefore() !== null &&
props.selectedAfter() !== null && props.selectedAfter() !== null &&
selectedEntry.status !== "deleted" selected.status !== "deleted"
? { ? {
path: selectedEntry.path, path: selected.path,
before: props.selectedBefore() as string, before: props.selectedBefore() as string,
after: props.selectedAfter() as string, after: props.selectedAfter() as string,
} }
@@ -109,15 +124,23 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
{(file) => ( {(file) => (
<MonacoDiffViewer <Suspense
scopeKey={props.scopeKey()} fallback={
path={String(file().path || "")} <div class="file-viewer-empty">
before={String((file() as any).before || "")} <span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
after={String((file() as any).after || "")} </div>
viewMode={props.diffViewMode()} }
contextMode={props.diffContextMode()} >
wordWrap={props.diffWordWrapMode()} <LazyMonacoDiffViewer
/> scopeKey={props.scopeKey()}
path={String(file().path || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
</Suspense>
)} )}
</Show> </Show>
} }
@@ -131,7 +154,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
} }
> >
<div class="file-viewer-empty"> <div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span> <span class="file-viewer-empty-text">{props.t("instanceInfo.loading")}</span>
</div> </div>
</Show> </Show>
</div> </div>
@@ -141,8 +164,8 @@ 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 renderListPanel = () => (
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}> <Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
<For each={sorted}> <For each={sortedList}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`} class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
@@ -156,7 +179,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div> </div>
<div class="file-list-item-stats"> <div class="file-list-item-stats">
<Show when={item.status === "deleted"}> <Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span> <span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
</Show> </Show>
<Show when={item.status !== "deleted"}> <Show when={item.status !== "deleted"}>
<> <>
@@ -173,8 +196,8 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
) )
const renderListOverlay = () => ( const renderListOverlay = () => (
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}> <Show when={nonDeletedList.length > 0} fallback={renderEmptyList()}>
<For each={sorted}> <For each={sortedList}>
{(item) => ( {(item) => (
<div <div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`} class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
@@ -187,7 +210,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
</div> </div>
<div class="file-list-item-stats"> <div class="file-list-item-stats">
<Show when={item.status === "deleted"}> <Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span> <span class="text-[10px] text-secondary">{props.t("instanceShell.gitChanges.deleted")}</span>
</Show> </Show>
<Show when={item.status !== "deleted"}> <Show when={item.status !== "deleted"}>
<> <>
@@ -204,19 +227,19 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
) )
return ( return (
<SplitFilePanel <SplitFilePanel
header={ header={
<> <>
<span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}> <span class="files-tab-selected-path" title={selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}>
<span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span> <span class="file-path-text">{selected?.path || props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
</span> </span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}> <div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions"> <span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span> <span class="files-tab-stat-value">+{totalsValue.additions}</span>
</span> </span>
<span class="files-tab-stat files-tab-stat-deletions"> <span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span> <span class="files-tab-stat-value">-{totalsValue.deletions}</span>
</span> </span>
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show> <Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div> </div>
@@ -226,23 +249,23 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
class="files-header-icon-button" class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")} title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")} aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={!hasSession || props.statusLoading() || entries === null} disabled={!hasSession() || props.statusLoading() || entries() === null}
style={{ "margin-left": "auto" }} style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()} onClick={() => props.onRefresh()}
> >
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} /> <RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
</button> </button>
<DiffToolbar <DiffToolbar
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()} wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange} onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange} onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange} onWordWrapModeChange={props.onWordWrapModeChange}
/> />
</> </>
} }
list={{ panel: renderListPanel, overlay: renderListOverlay }} list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()} viewer={renderViewer()}
listOpen={props.listOpen()} listOpen={props.listOpen()}
@@ -251,7 +274,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
onResizeMouseDown={props.onResizeMouseDown} onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart} onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()} isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Git Changes" overlayAriaLabel={props.t("instanceShell.rightPanel.tabs.gitChanges")}
/> />
) )
} }

View File

@@ -1,8 +1,9 @@
import { For, Show, type Accessor, type Component } from "solid-js" import { For, Show, type Accessor, type Component } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import { Accordion } from "@kobalte/core" import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip"
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import { 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"
@@ -206,21 +207,25 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "session-changes", id: "session-changes",
labelKey: "instanceShell.rightPanel.sections.sessionChanges", labelKey: "instanceShell.rightPanel.sections.sessionChanges",
tooltipKey: "instanceShell.rightPanel.sections.sessionChanges.tooltip",
render: renderStatusSessionChanges, render: renderStatusSessionChanges,
}, },
{ {
id: "plan", id: "plan",
labelKey: "instanceShell.rightPanel.sections.plan", labelKey: "instanceShell.rightPanel.sections.plan",
tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip",
render: renderPlanSectionContent, render: renderPlanSectionContent,
}, },
{ {
id: "background-processes", id: "background-processes",
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip",
render: renderBackgroundProcesses, render: renderBackgroundProcesses,
}, },
{ {
id: "mcp", id: "mcp",
labelKey: "instanceShell.rightPanel.sections.mcp", labelKey: "instanceShell.rightPanel.sections.mcp",
tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -233,6 +238,7 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "lsp", id: "lsp",
labelKey: "instanceShell.rightPanel.sections.lsp", labelKey: "instanceShell.rightPanel.sections.lsp",
tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -245,6 +251,7 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "plugins", id: "plugins",
labelKey: "instanceShell.rightPanel.sections.plugins", labelKey: "instanceShell.rightPanel.sections.plugins",
tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -276,7 +283,23 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<Accordion.Item value={section.id} class="right-panel-accordion-item"> <Accordion.Item value={section.id} class="right-panel-accordion-item">
<Accordion.Header> <Accordion.Header>
<Accordion.Trigger class="right-panel-accordion-trigger"> <Accordion.Trigger class="right-panel-accordion-trigger">
<span>{props.t(section.labelKey)}</span> <span class="section-left">
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger
class="section-info-trigger"
aria-label={props.t(section.tooltipKey)}
onClick={(e) => e.stopPropagation()}
>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">
{props.t(section.tooltipKey)}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
<span class="section-label">{props.t(section.labelKey)}</span>
</span>
<ChevronDown <ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`} class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/> />

View File

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

View File

@@ -1,5 +1,5 @@
import { batch, createMemo, type Accessor } from "solid-js" import { batch, createMemo, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { Session } from "../../../types/session" import type { Session } from "../../../types/session"
import { import {
activeParentSessionId, activeParentSessionId,

View File

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

View File

@@ -0,0 +1,9 @@
export const MESSAGE_ANCHOR_PREFIX = "message-anchor-"
export function getMessageAnchorId(messageId: string) {
return `${MESSAGE_ANCHOR_PREFIX}${messageId}`
}
export function getMessageIdFromAnchorId(anchorId: string) {
return anchorId.startsWith(MESSAGE_ANCHOR_PREFIX) ? anchorId.slice(MESSAGE_ANCHOR_PREFIX.length) : anchorId
}

View File

@@ -1,64 +0,0 @@
import { Index, type Accessor } from "solid-js"
import VirtualItem from "./virtual-item"
import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
export function getMessageAnchorId(messageId: string) {
return `message-anchor-${messageId}`
}
const VIRTUAL_ITEM_MARGIN_PX = 800
interface MessageBlockListProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageIds: () => string[]
lastAssistantIndex: () => number
showThinking: () => boolean
thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean
scrollContainer: Accessor<HTMLDivElement | undefined>
loading?: boolean
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
onContentRendered?: () => void
setBottomSentinel: (element: HTMLDivElement | null) => void
suspendMeasurements?: () => boolean
}
export default function MessageBlockList(props: MessageBlockListProps) {
return (
<>
<Index each={props.messageIds()}>
{(messageId, index) => (
<VirtualItem
id={getMessageAnchorId(messageId())}
cacheKey={messageId()}
scrollContainer={props.scrollContainer}
threshold={VIRTUAL_ITEM_MARGIN_PX}
placeholderClass="message-stream-placeholder"
virtualizationEnabled={() => !props.loading}
suspendMeasurements={props.suspendMeasurements}
>
<MessageBlock
messageId={messageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageIndex={index}
lastAssistantIndex={props.lastAssistantIndex}
showThinking={props.showThinking}
thinkingDefaultExpanded={props.thinkingDefaultExpanded}
showUsageMetrics={props.showUsageMetrics}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
</VirtualItem>
)}
</Index>
<div ref={props.setBottomSentinel} aria-hidden="true" style={{ height: "1px" }} />
</>
)
}

View File

@@ -1,7 +1,6 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js" import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message" import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
@@ -12,21 +11,38 @@ import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances" import { setActiveInstanceId } from "../stores/instances"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions" import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
function DeleteUpToIcon() {
return (
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
</span>
)
}
const TOOL_ICON = "🔧" 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 LazyToolCall = lazy(() => import("./tool-call"))
function ToolCallFallback() {
return <div class="tool-call tool-call-loading" />
}
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
type ToolState = import("@opencode-ai/sdk").ToolState type ToolState = import("@opencode-ai/sdk/v2").ToolState
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk").ToolStateError type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning { function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
return Boolean(state && state.status === "running") return Boolean(state && state.status === "running")
@@ -194,8 +210,13 @@ interface MessageContentItemProps {
messageIndex: number messageIndex: number
lastAssistantIndex: () => number lastAssistantIndex: () => number
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function isSupportedPartType(part: unknown): boolean { function isSupportedPartType(part: unknown): boolean {
@@ -282,7 +303,12 @@ function MessageContentItem(props: MessageContentItemProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
isQueued={isQueued()} isQueued={isQueued()}
showAgentMeta={showAgentMeta()} showAgentMeta={showAgentMeta()}
showDeleteMessage={props.showDeleteMessage}
onDeleteHoverChange={props.onDeleteHoverChange}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
@@ -298,11 +324,41 @@ interface ToolCallItemProps {
messageId: string messageId: string
partId: string partId: string
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function ToolCallItem(props: ToolCallItemProps) { function ToolCallItem(props: ToolCallItemProps) {
const { t } = useI18n() const { t } = useI18n()
const [deleting, setDeleting] = createSignal(false) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
const isSelectedToolPartForDeletion = () => Boolean(props.selectedToolPartKeys?.().has(`${props.messageId}:${props.partId}`))
const isDeleteOverlayActive = () => {
if (isSelectedForDeletion()) return true
if (isSelectedToolPartForDeletion()) return true
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
if (hover.kind === "message") {
return hover.messageId === props.messageId
}
if (hover.kind === "deleteUpTo") {
const ids = props.store().getSessionMessageIds(props.sessionId)
const targetIndex = ids.indexOf(hover.messageId)
if (targetIndex === -1) return false
const currentIndex = ids.indexOf(props.messageId)
if (currentIndex === -1) return false
return currentIndex >= targetIndex
}
return false
}
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))
@@ -319,14 +375,6 @@ function ToolCallItem(props: ToolCallItemProps) {
const messageVersion = createMemo(() => record()?.revision ?? 0) const messageVersion = createMemo(() => record()?.revision ?? 0)
const partVersion = createMemo(() => partEntry()?.revision ?? 0) const partVersion = createMemo(() => partEntry()?.revision ?? 0)
const deleteDisabled = createMemo(() => {
if (deleting()) return true
// Avoid deleting while a tool is actively running to prevent confusing UI states.
if (isToolStateRunning(toolState())) return true
// Avoid deleting permission prompts from here; those are interactive.
return Boolean(toolPart()?.pendingPermission)
})
const taskSessionId = createMemo(() => { const taskSessionId = createMemo(() => {
const state = toolState() const state = toolState()
if (!state) return "" if (!state) return ""
@@ -350,38 +398,72 @@ function ToolCallItem(props: ToolCallItemProps) {
navigateToTaskSession(location) navigateToTaskSession(location)
} }
const handleDeleteToolPart = async (event: MouseEvent) => { const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (deleteDisabled()) return if (!props.showDeleteMessage) return
if (deletingMessage()) return
setDeleting(true) setDeletingMessage(true)
try { try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId) await deleteMessage(props.instanceId, props.sessionId, props.messageId)
} catch (error) { } catch (error) {
showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageBlock.tool.deletePart.failed.title"), title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeleting(false) setDeletingMessage(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
} }
} }
return ( return (
<Show when={toolPart()}> <Show when={toolPart()}>
{(resolvedToolPart) => ( {(resolvedToolPart) => (
<> <div class="delete-hover-scope" data-delete-part-hover={isDeleteOverlayActive() ? "true" : undefined}>
<div class="tool-call-header-label"> <div class="tool-call-header-label">
<div class="tool-call-header-meta"> <div class="tool-call-header-meta">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span class="tool-call-icon">{TOOL_ICON}</span> <span class="tool-call-icon">{TOOL_ICON}</span>
<span>{t("messageBlock.tool.header")}</span> <span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span> <span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-0">
<Show when={taskSessionId()}> <Show when={taskSessionId()}>
<button <button
class="tool-call-header-button" class="tool-call-header-button"
@@ -395,30 +477,49 @@ function ToolCallItem(props: ToolCallItemProps) {
</button> </button>
</Show> </Show>
<button <Show when={props.showDeleteMessage}>
class="tool-call-header-button" <button
type="button" class="tool-call-header-button"
disabled={deleteDisabled()} type="button"
onClick={handleDeleteToolPart} disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")} onClick={handleDeleteUpTo}
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
> onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" /> title={t("messageItem.actions.deleteMessagesUpTo")}
</button> aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="tool-call-header-button"
type="button"
disabled={deletingMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div> </div>
</div> </div>
<ToolCall <Suspense fallback={<ToolCallFallback />}>
toolCall={resolvedToolPart()} <LazyToolCall
toolCallId={props.partId} toolCall={resolvedToolPart()}
messageId={props.messageId} toolCallId={props.partId}
messageVersion={messageVersion()} messageId={props.messageId}
partVersion={partVersion()} messageVersion={messageVersion()}
instanceId={props.instanceId} partVersion={partVersion()}
sessionId={props.sessionId} instanceId={props.instanceId}
onContentRendered={props.onContentRendered} sessionId={props.sessionId}
/> onContentRendered={props.onContentRendered}
</> />
</Suspense>
</div>
)} )}
</Show> </Show>
) )
@@ -470,7 +571,13 @@ interface MessageBlockProps {
showThinking: () => boolean showThinking: () => boolean
thinkingDefaultExpanded: () => boolean thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean showUsageMetrics: () => boolean
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
selectedToolPartKeys?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
} }
@@ -480,6 +587,29 @@ export default function MessageBlock(props: MessageBlockProps) {
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))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
const isDeleteMessageHovered = () => {
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
const selected = props.selectedMessageIds?.() ?? new Set<string>()
if (selected.has(props.messageId)) {
return true
}
if (hover.kind === "message") {
return hover.messageId === props.messageId
}
if (hover.kind === "deleteUpTo") {
const ids = props.store().getSessionMessageIds(props.sessionId)
const targetIndex = ids.indexOf(hover.messageId)
if (targetIndex === -1) return false
const currentIndex = ids.indexOf(props.messageId)
if (currentIndex === -1) return false
return currentIndex >= targetIndex
}
return false
}
const block = createMemo<MessageDisplayBlock | null>(() => { const block = createMemo<MessageDisplayBlock | null>(() => {
const current = record() const current = record()
@@ -668,9 +798,13 @@ export default function MessageBlock(props: MessageBlockProps) {
return ( return (
<Show when={block()}> <Show when={block()}>
{(resolvedBlock) => ( {(resolvedBlock) => (
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}> <div
class="message-stream-block"
data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
>
<For each={resolvedBlock().items}> <For each={resolvedBlock().items}>
{(item) => ( {(item, index) => (
<Switch> <Switch>
<Match when={item.type === "content"}> <Match when={item.type === "content"}>
<MessageContentItem <MessageContentItem
@@ -681,7 +815,12 @@ export default function MessageBlock(props: MessageBlockProps) {
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}
onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
@@ -697,6 +836,13 @@ export default function MessageBlock(props: MessageBlockProps) {
store={props.store} store={props.store}
messageId={toolItem.messageId} messageId={toolItem.messageId}
partId={toolItem.partId} partId={toolItem.partId}
showDeleteMessage={index() === 0}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
selectedToolPartKeys={props.selectedToolPartKeys}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</div> </div>
@@ -709,6 +855,14 @@ export default function MessageBlock(props: MessageBlockProps) {
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}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "step-finish"}> <Match when={item.type === "step-finish"}>
@@ -718,6 +872,14 @@ export default function MessageBlock(props: MessageBlockProps) {
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}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "compaction"}> <Match when={item.type === "compaction"}>
@@ -728,7 +890,11 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as CompactionDisplayItem).messageId} messageId={(item as CompactionDisplayItem).messageId}
partId={(item as CompactionDisplayItem).partId} showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "reasoning"}> <Match when={item.type === "reasoning"}>
@@ -738,9 +904,14 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as ReasoningDisplayItem).messageId} messageId={(item as ReasoningDisplayItem).messageId}
partId={(item as ReasoningDisplayItem).partId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta} showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded} defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/> />
</Match> </Match>
</Switch> </Switch>
@@ -759,6 +930,14 @@ interface StepCardProps {
showAgentMeta?: boolean showAgentMeta?: boolean
showUsage?: boolean showUsage?: boolean
borderColor?: string borderColor?: string
showDeleteMessage?: boolean
instanceId?: string
sessionId?: string
messageId?: string
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
interface CompactionCardProps { interface CompactionCardProps {
@@ -768,12 +947,18 @@ interface CompactionCardProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string messageId: string
partId: string showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function CompactionCard(props: CompactionCardProps) { function CompactionCard(props: CompactionCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [deleting, setDeleting] = createSignal(false) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
const isAuto = () => Boolean((props.part as any)?.auto) const isAuto = () => Boolean((props.part as any)?.auto)
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel")) const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR) const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
@@ -781,44 +966,98 @@ function CompactionCard(props: CompactionCardProps) {
const containerClass = () => const containerClass = () =>
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}` `message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
const canDelete = () => Boolean(props.partId) && !deleting() const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDelete = async (event: MouseEvent) => { const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!canDelete()) return if (!props.showDeleteMessage) return
setDeleting(true) if (!canDeleteMessage()) return
setDeletingMessage(true)
try { try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId) await deleteMessage(props.instanceId, props.sessionId, props.messageId)
} catch (error) { } catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"), title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeleting(false) setDeletingMessage(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
} }
} }
return ( return (
<div <div
class={`${containerClass()} relative`} class={`delete-hover-scope ${containerClass()} relative`}
style={{ "border-left": `4px solid ${borderColor()}` }} style={{ "border-left": `4px solid ${borderColor()}` }}
role="status" role="status"
aria-label={t("messageBlock.compaction.ariaLabel")} aria-label={t("messageBlock.compaction.ariaLabel")}
> >
<button <div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
type="button" <Show when={props.showDeleteMessage}>
class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2" <button
disabled={!canDelete()} type="button"
onClick={handleDelete} class="tool-call-header-button"
title={t("messagePart.actions.deleteTitle")} disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
> onClick={handleDeleteUpTo}
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
</button> onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="tool-call-header-button"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
<div class="message-compaction-row"> <div class="message-compaction-row">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" /> <FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
<span class="message-compaction-label">{label()}</span> <span class="message-compaction-label">{label()}</span>
</div> </div>
@@ -828,6 +1067,9 @@ function CompactionCard(props: CompactionCardProps) {
function StepCard(props: StepCardProps) { function StepCard(props: StepCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.messageId && props.selectedMessageIds?.().has(props.messageId))
const timestamp = () => { const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now() const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value) const date = new Date(value)
@@ -872,6 +1114,42 @@ function StepCard(props: StepCardProps) {
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined) const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
const canDeleteMessage = () =>
Boolean(props.showDeleteMessage && props.instanceId && props.sessionId && props.messageId) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canDeleteMessage()) return
setDeletingMessage(true)
try {
await deleteMessage(props.instanceId!, props.sessionId!, props.messageId!)
} catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeletingMessage(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.messageId) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
}
}
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => { const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [ const entries = [
@@ -902,17 +1180,83 @@ function StepCard(props: StepCardProps) {
return null return null
} }
return ( return (
<div class={`message-step-card message-step-finish message-step-finish-flush`} style={finishStyle()}> <div class={`message-step-card message-step-finish message-step-finish-flush relative`} style={finishStyle()}>
<Show when={props.showDeleteMessage && props.messageId}>
<input
class="message-select-checkbox absolute left-2 top-1/2 -translate-y-1/2"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId!, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<Show when={props.showDeleteMessage}>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
type="button"
class="message-action-button"
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onClick={handleDeleteUpTo}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId! })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="message-action-button"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId! })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</Show>
{renderUsageChips(usage)} {renderUsageChips(usage)}
</div> </div>
) )
} }
return ( return (
<div class={`message-step-card message-step-start`}> <div class={`message-step-card message-step-start relative`}>
<div class="message-step-heading"> <div class="message-step-heading">
<div class="message-step-title"> <div class="message-step-title">
<div class="message-step-title-left"> <div class="message-step-title-left">
<Show when={props.showDeleteMessage && props.messageId}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId!, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}> <Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline"> <span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show> <Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
@@ -939,15 +1283,41 @@ interface ReasoningCardProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string messageId: string
partId: string
showAgentMeta?: boolean showAgentMeta?: boolean
defaultExpanded?: boolean defaultExpanded?: boolean
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onContentRendered?: () => void
} }
function ReasoningCard(props: ReasoningCardProps) { function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deleting, setDeleting] = createSignal(false) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
let pendingRenderNotificationFrame: number | null = null
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
})
createEffect(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
@@ -974,6 +1344,8 @@ function ReasoningCard(props: ReasoningCardProps) {
return modelID return modelID
} }
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
const reasoningText = () => { const reasoningText = () => {
const part = props.part as any const part = props.part as any
if (!part) return "" if (!part) return ""
@@ -1014,29 +1386,57 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () => const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view") expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
const hasDeleteTarget = () => Boolean(props.partId) const speech = useSpeech({
const canDelete = () => hasDeleteTarget() && !deleting() id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`,
text: reasoningText,
})
const handleDelete = async (event: MouseEvent) => { const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
createEffect(() => {
if (!expanded()) return
reasoningText()
notifyContentRendered()
})
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!canDelete()) return if (!props.showDeleteMessage) return
setDeleting(true) if (!canDeleteMessage()) return
setDeletingMessage(true)
try { try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId) await deleteMessage(props.instanceId, props.sessionId, props.messageId)
} catch (error) { } catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"), title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeleting(false) setDeletingMessage(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
} }
} }
return ( return (
<div class="message-reasoning-card"> <div class="delete-hover-scope message-reasoning-card">
<div class="message-reasoning-header"> <div class="message-reasoning-header">
<button <button
type="button" type="button"
@@ -1045,26 +1445,46 @@ function ReasoningCard(props: ReasoningCardProps) {
aria-expanded={expanded()} aria-expanded={expanded()}
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")} aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
> >
<span class="message-reasoning-label flex flex-wrap items-center gap-2"> <span class="message-reasoning-label">
<span>{t("messageBlock.reasoning.thinkingLabel")}</span> <span class="message-reasoning-label-primary">
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}> <Show when={props.showDeleteMessage}>
<span class="message-step-meta-inline"> <input
<Show when={agentIdentifier()}> class="message-select-checkbox"
{(value) => ( type="checkbox"
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span> checked={isSelectedForDeletion()}
)} onClick={(event) => {
</Show> event.stopPropagation()
<Show when={modelIdentifier()}> }}
{(value) => ( onChange={(event) => {
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span> event.stopPropagation()
)} const next = Boolean((event.currentTarget as HTMLInputElement).checked)
</Show> props.onToggleSelectedMessage?.(props.messageId, next)
</span> }}
</Show> aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
</span>
</span> </span>
</button> </button>
<div class="message-reasoning-actions"> <div class="message-reasoning-actions">
<Show when={canSpeakReasoning()}>
<SpeechActionButton
class="message-action-button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void speech.toggle()
}}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<button <button
type="button" type="button"
class="message-action-button" class="message-action-button"
@@ -1081,16 +1501,31 @@ function ReasoningCard(props: ReasoningCardProps) {
</Show> </Show>
</button> </button>
<Show when={hasDeleteTarget()}> <Show when={props.showDeleteMessage}>
<button <button
type="button" type="button"
class="message-action-button" class="message-action-button"
onClick={handleDelete} onClick={handleDeleteUpTo}
disabled={!canDelete()} disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
aria-label={t("messagePart.actions.deleteTitle")} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
title={t("messagePart.actions.deleteTitle")} onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
title={t("messageItem.actions.deleteMessagesUpTo")}
> >
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" /> <DeleteUpToIcon />
</button>
<button
type="button"
class="message-action-button"
onClick={handleDeleteMessage}
disabled={!canDeleteMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</Show> </Show>
@@ -1098,11 +1533,28 @@ function ReasoningCard(props: ReasoningCardProps) {
</div> </div>
</div> </div>
<Show when={hasMeta()}>
<div class="message-reasoning-meta-row">
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</div>
</Show>
<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")}> <div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
<pre class="message-reasoning-text">{reasoningText() || ""}</pre> <pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,14 +1,26 @@
import { For, Show, createSignal } from "solid-js" import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid" import { Portal } from "solid-js/web"
import type { MessageInfo, ClientPart } from "../types/message" import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
import { partHasRenderableText } from "../types/message" import { 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"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions" import { deleteMessage } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env" import { isTauriHost } from "../lib/runtime-env"
import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
function DeleteUpToIcon() {
return (
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
</span>
)
}
interface MessageItemProps { interface MessageItemProps {
record: MessageRecord record: MessageRecord
@@ -18,15 +30,112 @@ interface MessageItemProps {
isQueued?: boolean isQueued?: boolean
parts: ClientPart[] parts: ClientPart[]
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
showAgentMeta?: boolean showAgentMeta?: boolean
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
} }
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
const { t } = useI18n() const { t } = useI18n()
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set()) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
type ImagePreviewState = {
url: string
name: string
anchor: HTMLElement
}
const [imagePreview, setImagePreview] = createSignal<ImagePreviewState | null>(null)
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const getImagePreviewPosition = () => {
const state = imagePreview()
if (!state) return null
const rect = state.anchor.getBoundingClientRect()
// Outer box: 320px image + 8px padding on each side.
const padding = 8
const maxImage = 320
const gap = 8
const chrome = padding * 2
const outerWidth = maxImage + chrome
const outerHeight = maxImage + chrome
const viewportW = window.innerWidth
const viewportH = window.innerHeight
const left = clamp(rect.left, 8, Math.max(8, viewportW - outerWidth - 8))
const fitsAbove = rect.top >= outerHeight + gap + 8
const preferredTop = fitsAbove ? rect.top - outerHeight - gap : rect.bottom + gap
const top = clamp(preferredTop, 8, Math.max(8, viewportH - outerHeight - 8))
return { left, top }
}
createEffect(() => {
const active = imagePreview()
if (!active) return
// If the user scrolls (message stream scroll container) or resizes, the anchor moves.
// Hide the popover to avoid showing it in the wrong place.
const hide = () => setImagePreview(null)
window.addEventListener("scroll", hide, true)
window.addEventListener("resize", hide)
onCleanup(() => {
window.removeEventListener("scroll", hide, true)
window.removeEventListener("resize", hide)
})
})
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id))
let topRowEl: HTMLDivElement | undefined
let actionsEl: HTMLDivElement | undefined
let speakerPrimaryEl: HTMLDivElement | undefined
let metaMeasureEl: HTMLSpanElement | undefined
const [showMetaInline, setShowMetaInline] = createSignal(true)
const metaText = () => agentMeta()
const updateMetaLayout = () => {
const text = metaText()
if (!text) return
if (!topRowEl || !actionsEl || !speakerPrimaryEl || !metaMeasureEl) return
const rowWidth = topRowEl.getBoundingClientRect().width
const actionsWidth = actionsEl.getBoundingClientRect().width
const primaryWidth = speakerPrimaryEl.getBoundingClientRect().width
const metaWidth = metaMeasureEl.getBoundingClientRect().width
// Allow for the flex gap between left and actions.
const availableLeft = Math.max(0, rowWidth - actionsWidth - 12)
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
}
createEffect(() => {
const text = metaText()
if (!text || typeof ResizeObserver === "undefined") {
setShowMetaInline(true)
return
}
updateMetaLayout()
const observer = new ResizeObserver(() => updateMetaLayout())
if (topRowEl) observer.observe(topRowEl)
if (actionsEl) observer.observe(actionsEl)
if (speakerPrimaryEl) observer.observe(speakerPrimaryEl)
onCleanup(() => observer.disconnect())
})
const isUser = () => props.record.role === "user" const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
@@ -123,6 +232,11 @@ export default function MessageItem(props: MessageItemProps) {
} }
} }
const showImagePreview = (anchor: HTMLElement, url: string, name: string) => {
if (!url) return
setImagePreview({ anchor, url, name })
}
const errorMessage = () => { const errorMessage = () => {
const info = props.messageInfo const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.error) return null if (!info || info.role !== "assistant" || !info.error) return null
@@ -182,6 +296,13 @@ export default function MessageItem(props: MessageItemProps) {
.join("\n\n") .join("\n\n")
} }
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.record.id}`,
text: getRawContent,
})
const canSpeakMessage = () => getRawContent().trim().length > 0 && speech.canUseSpeech()
const handleCopy = async () => { const handleCopy = async () => {
const content = getRawContent() const content = getRawContent()
if (!content) return if (!content) return
@@ -190,47 +311,30 @@ export default function MessageItem(props: MessageItemProps) {
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
const deletableTextPartId = () => { const handleDeleteMessage = async () => {
const part = props.parts.find((candidate) => { if (deletingMessage()) return
if (!candidate || candidate.type !== "text") return false setDeletingMessage(true)
const id = (candidate as any).id
if (typeof id !== "string" || id.length === 0) return false
return !Boolean((candidate as any).synthetic)
})
return (part as any)?.id as string | undefined
}
const isDeletingPart = (partId?: string) => {
if (!partId) return false
return deletingParts().has(partId)
}
const setPartDeleting = (partId: string, value: boolean) => {
setDeletingParts((prev) => {
const next = new Set(prev)
if (value) {
next.add(partId)
} else {
next.delete(partId)
}
return next
})
}
const handleDeletePart = async (partId?: string) => {
if (!partId) return
if (isDeletingPart(partId)) return
setPartDeleting(partId, true)
try { try {
await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId) await deleteMessage(props.instanceId, props.sessionId, props.record.id)
} catch (error) { } catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"), title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setPartDeleting(partId, false) setDeletingMessage(false)
}
}
const handleDeleteUpTo = async () => {
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.record.id)
} finally {
setDeletingUpTo(false)
} }
} }
@@ -258,8 +362,16 @@ export default function MessageItem(props: MessageItemProps) {
if (!info || info.role !== "assistant") return "" if (!info || info.role !== "assistant") return ""
const modelID = info.modelID || "" const modelID = info.modelID || ""
const providerID = info.providerID || "" const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID const base = modelID && providerID ? `${providerID}/${modelID}` : modelID
if (!base) return ""
const variant = (info as SDKAssistantMessageV2).variant
if (typeof variant === "string" && variant.trim().length > 0) {
return `${base} (${variant.trim()})`
}
return base
} }
const agentMeta = () => { const agentMeta = () => {
@@ -278,28 +390,78 @@ export default function MessageItem(props: MessageItemProps) {
return ( return (
<div class={containerClass()}> <div
class={containerClass()}
data-view="message-item"
data-instance-id={props.instanceId}
data-session-id={props.sessionId}
data-message-id={props.record.id}
data-message-role={isUser() ? "user" : "assistant"}
data-message-status={props.record.status}
>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}> <header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="message-item-header-row message-item-header-row--top"> <div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
<div class="message-speaker"> <div class="message-header-left">
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}> <div class="message-speaker-primary" ref={(el) => (speakerPrimaryEl = el)}>
{speakerLabel()} <Show when={props.showDeleteMessage}>
</span> <input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.record.id, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
</div>
<Show when={metaText() && showMetaInline()}>
<span class="message-agent-meta-inline">{metaText()}</span>
</Show>
<Show when={metaText()}>
<span
ref={(el) => (metaMeasureEl = el)}
class="message-agent-meta-inline message-agent-meta-inline--measure"
>
{metaText()}
</span>
</Show>
</div> </div>
<div class="message-item-actions"> <div class="message-item-actions" ref={(el) => (actionsEl = el)}>
<Show when={isUser()}> <Show when={isUser()}>
<div class="message-action-group"> <div class="message-action-group">
<Show when={props.onRevert}> <button
<button class="message-action-button"
onClick={handleCopy}
title={copyLabel()}
aria-label={copyLabel()}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={canSpeakMessage()}>
<SpeechActionButton
class="message-action-button" class="message-action-button"
onClick={handleRevert} onClick={() => void speech.toggle()}
title={t("messageItem.actions.revert")} title={speech.buttonTitle()}
aria-label={t("messageItem.actions.revert")} isLoading={speech.isLoading()}
> isPlaying={speech.isPlaying()}
<Undo class="w-3.5 h-3.5" aria-hidden="true" /> />
</button>
</Show> </Show>
<Show when={props.onFork}> <Show when={props.onFork}>
<button <button
class="message-action-button" class="message-action-button"
@@ -310,14 +472,43 @@ export default function MessageItem(props: MessageItemProps) {
<Split class="w-3.5 h-3.5" aria-hidden="true" /> <Split class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</Show> </Show>
<button
class="message-action-button" <Show when={props.onRevert}>
onClick={handleCopy} <button
title={copyLabel()} class="message-action-button"
aria-label={copyLabel()} onClick={handleRevert}
> title={t("messageItem.actions.revertTitle")}
<Copy class="w-3.5 h-3.5" aria-hidden="true" /> aria-label={t("messageItem.actions.revertTitle")}
</button> >
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.showDeleteMessage}>
<button
class="message-action-button"
onClick={() => void handleDeleteUpTo()}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div> </div>
</Show> </Show>
<Show when={!isUser()}> <Show when={!isUser()}>
@@ -331,18 +522,40 @@ export default function MessageItem(props: MessageItemProps) {
<Copy class="w-3.5 h-3.5" aria-hidden="true" /> <Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
<Show when={deletableTextPartId()}> <Show when={canSpeakMessage()}>
{(partId) => ( <SpeechActionButton
<button class="message-action-button"
class="message-action-button" onClick={() => void speech.toggle()}
onClick={() => void handleDeletePart(partId())} title={speech.buttonTitle()}
disabled={isDeletingPart(partId())} isLoading={speech.isLoading()}
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")} isPlaying={speech.isPlaying()}
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")} />
> </Show>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button> <Show when={props.showDeleteMessage}>
)} <button
class="message-action-button"
onClick={() => void handleDeleteUpTo()}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show> </Show>
</div> </div>
</Show> </Show>
@@ -350,17 +563,15 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</div> </div>
<Show when={agentMeta()}> <Show when={metaText() && !showMetaInline()}>
{(meta) => ( <div class="message-item-header-row message-item-header-row--meta">
<div class="message-item-header-row message-item-header-row--bottom"> <span class="message-agent-meta-block">{metaText()}</span>
<span class="message-agent-meta">{meta()}</span> </div>
</div>
)}
</Show> </Show>
</header> </header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]"> <div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
<Show when={props.isQueued && isUser()}> <Show when={props.isQueued && isUser()}>
@@ -368,7 +579,7 @@ export default function MessageItem(props: MessageItemProps) {
</Show> </Show>
<Show when={errorMessage()}> <Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div> <div class="message-error-block" dir="auto"> {errorMessage()}</div>
</Show> </Show>
<Show when={isGenerating()}> <Show when={isGenerating()}>
@@ -378,16 +589,20 @@ export default function MessageItem(props: MessageItemProps) {
</Show> </Show>
<For each={messageParts()}> <For each={messageParts()}>
{(part) => ( {(part) => {
<MessagePart return (
part={part} <div class="message-part-shell">
messageType={props.record.role} <MessagePart
instanceId={props.instanceId} part={part}
sessionId={props.sessionId} messageType={props.record.role}
primaryUserTextPartId={primaryUserTextPartId()} instanceId={props.instanceId}
onRendered={props.onContentRendered} sessionId={props.sessionId}
/> primaryUserTextPartId={primaryUserTextPartId()}
)} onRendered={props.onContentRendered}
/>
</div>
)
}}
</For> </For>
<Show when={fileAttachments().length > 0}> <Show when={fileAttachments().length > 0}>
@@ -397,7 +612,16 @@ export default function MessageItem(props: MessageItemProps) {
const name = getAttachmentName(attachment) const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment) const isImage = isImageAttachment(attachment)
return ( return (
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}> <div
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
title={name}
onMouseEnter={(e) => {
if (!isImage) return
const el = e.currentTarget as HTMLElement
showImagePreview(el, attachment.url || "", name)
}}
onMouseLeave={() => setImagePreview(null)}
>
<Show when={isImage} fallback={ <Show when={isImage} fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path
@@ -425,24 +649,6 @@ export default function MessageItem(props: MessageItemProps) {
</svg> </svg>
</button> </button>
</Show> </Show>
<button
type="button"
onClick={() => void handleDeletePart(attachment.id)}
class="attachment-remove"
disabled={isDeletingPart(attachment.id)}
aria-label={t("messagePart.actions.deleteTitle")}
title={t("messagePart.actions.deleteTitle")}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div> </div>
) )
}} }}
@@ -450,6 +656,31 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</Show> </Show>
<Show when={imagePreview()}>
{(stateAccessor) => {
const state = stateAccessor()
const pos = () => getImagePreviewPosition()
return (
<Portal>
<Show when={pos()}>
{(posAccessor) => {
const coords = posAccessor()
return (
<div
class="attachment-image-popover"
style={{ left: `${coords.left}px`, top: `${coords.top}px` }}
aria-hidden="true"
>
<img src={state.url} alt={state.name} />
</div>
)
}}
</Show>
</Portal>
)
}}
</Show>
<Show when={props.record.status === "sending"}> <Show when={props.record.status === "sending"}>
<div class="message-sending"> <div class="message-sending">
<span class="generating-spinner"></span> {t("messageItem.status.sending")} <span class="generating-spinner"></span> {t("messageItem.status.sending")}

View File

@@ -51,7 +51,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-shortcut-action"> <div class="connection-status-shortcut-action">
<button <button
type="button" type="button"
class="connection-status-button" class="connection-status-button command-palette-button"
onClick={props.onCommandPalette} onClick={props.onCommandPalette}
aria-label={t("messageListHeader.commandPalette.ariaLabel")} aria-label={t("messageListHeader.commandPalette.ariaLabel")}
> >

View File

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

View File

@@ -1,12 +1,18 @@
import type { Component } from "solid-js" import type { Component } from "solid-js"
import MessageBlock from "./message-block" import MessageBlock from "./message-block"
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"
interface MessagePreviewProps { interface MessagePreviewProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string messageId: string
store: () => InstanceMessageStore store: () => InstanceMessageStore
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
const MessagePreview: Component<MessagePreviewProps> = (props) => { const MessagePreview: Component<MessagePreviewProps> = (props) => {
@@ -24,6 +30,11 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
showThinking={() => false} showThinking={() => false}
thinkingDefaultExpanded={() => false} thinkingDefaultExpanded={() => false}
showUsageMetrics={() => false} showUsageMetrics={() => false}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</div> </div>
) )

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
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 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 { getToolIcon } from "./tool-call/utils" import { getToolIcon } from "./tool-call/utils"
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid" import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction" export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
@@ -19,18 +21,38 @@ export interface TimelineSegment {
shortLabel?: string shortLabel?: string
variant?: "auto" | "manual" variant?: "auto" | "manual"
toolPartIds?: string[] toolPartIds?: string[]
partIds?: string[]
partId?: string
totalChars: number
} }
interface MessageTimelineProps { interface MessageTimelineProps {
segments: TimelineSegment[] segments: TimelineSegment[]
onSegmentClick?: (segment: TimelineSegment) => void onSegmentClick?: (segment: TimelineSegment) => void
activeMessageId?: string | null onToggleSelection?: (id: string) => void
onLongPressSelection?: (segment: TimelineSegment) => void
onSelectRange?: (id: string) => void
onClearSelection?: () => void
selectedIds?: Accessor<Set<string>>
expandedMessageIds?: Accessor<Set<string>>
// Optional: restrict histogram/xray overlay to only show for these message ids.
// Used to hide ribs for messages before the last compaction.
deletableMessageIds?: Accessor<Set<string>>
activeSegmentId?: string | null
instanceId: string instanceId: string
sessionId: string sessionId: string
showToolSegments?: boolean showToolSegments?: boolean
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
const MAX_TOOLTIP_LENGTH = 220 const MAX_TOOLTIP_LENGTH = 220
const LONG_PRESS_MS = 500
const JITTER_THRESHOLD = 10
const ABSOLUTE_TOKEN_CAP = 10000
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -38,10 +60,8 @@ interface PendingSegment {
type: TimelineSegmentType type: TimelineSegmentType
texts: string[] texts: string[]
reasoningTexts: string[] reasoningTexts: string[]
toolTitles: string[] partIds: string[]
toolTypeLabels: string[] totalChars: number
toolIcons: string[]
toolPartIds: string[]
hasPrimaryText: boolean hasPrimaryText: boolean
} }
@@ -171,18 +191,13 @@ export function buildTimelineSegments(
pending = null pending = null
return return
} }
const isToolSegment = pending.type === "tool" const label = segmentLabel(pending.type)
const label = isToolSegment const shortLabel = undefined
? pending.toolTypeLabels[0] || segmentLabel("tool") const tooltip = formatTextsTooltip(
: segmentLabel(pending.type) [...pending.texts, ...pending.reasoningTexts],
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
const tooltip = isToolSegment )
? formatToolTooltip(pending.toolTitles, t)
: formatTextsTooltip(
[...pending.texts, ...pending.reasoningTexts],
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
)
result.push({ result.push({
id: `${record.id}:${segmentIndex}`, id: `${record.id}:${segmentIndex}`,
messageId: record.id, messageId: record.id,
@@ -190,16 +205,24 @@ export function buildTimelineSegments(
label, label,
tooltip, tooltip,
shortLabel, shortLabel,
toolPartIds: isToolSegment ? pending.toolPartIds : undefined, partIds: pending.partIds,
totalChars: pending.totalChars,
}) })
segmentIndex += 1 segmentIndex += 1
pending = null pending = null
} }
const ensureSegment = (type: TimelineSegmentType): PendingSegment => { const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
if (!pending || pending.type !== type) { if (!pending || pending.type !== type) {
flushPending() flushPending()
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" } pending = {
type,
texts: [],
reasoningTexts: [],
partIds: [],
totalChars: 0,
hasPrimaryText: type !== "assistant",
}
} }
return pending! return pending!
} }
@@ -211,14 +234,21 @@ export function buildTimelineSegments(
if (!part || typeof part !== "object") continue if (!part || typeof part !== "object") continue
if (part.type === "tool") { if (part.type === "tool") {
const target = ensureSegment("tool") flushPending()
const toolPart = part as ToolCallPart const toolPart = part as ToolCallPart
target.toolTitles.push(getToolTitle(toolPart, t)) const partId = typeof toolPart.id === "string" ? toolPart.id : ""
target.toolTypeLabels.push(getToolTypeLabel(toolPart, t)) const title = getToolTitle(toolPart, t)
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool")) result.push({
if (typeof toolPart.id === "string" && toolPart.id.length > 0) { id: `${record.id}:${segmentIndex}`,
target.toolPartIds.push(toolPart.id) messageId: record.id,
} type: "tool",
label: getToolTypeLabel(toolPart, t) || segmentLabel("tool"),
tooltip: formatToolTooltip([title], t),
shortLabel: getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"),
toolPartIds: partId ? [partId] : undefined,
totalChars: getPartCharCount(part),
})
segmentIndex += 1
continue continue
} }
@@ -228,13 +258,18 @@ export function buildTimelineSegments(
const target = ensureSegment(defaultContentType) const target = ensureSegment(defaultContentType)
if (target) { if (target) {
target.reasoningTexts.push(text) target.reasoningTexts.push(text)
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
target.partIds.push((part as any).id)
}
target.totalChars += getPartCharCount(part)
} }
continue continue
} }
if (part.type === "compaction") { if (part.type === "compaction") {
flushPending() flushPending()
const isAuto = Boolean((part as any)?.auto) const isAuto = Boolean((part as any)?.auto)
const partId = typeof (part as any)?.id === "string" ? ((part as any).id as string) : ""
result.push({ result.push({
id: `${record.id}:${segmentIndex}`, id: `${record.id}:${segmentIndex}`,
messageId: record.id, messageId: record.id,
@@ -242,6 +277,8 @@ export function buildTimelineSegments(
label: segmentLabel("compaction"), label: segmentLabel("compaction"),
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"), tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
variant: isAuto ? "auto" : "manual", variant: isAuto ? "auto" : "manual",
partId,
totalChars: 0,
}) })
segmentIndex += 1 segmentIndex += 1
continue continue
@@ -250,19 +287,23 @@ export function buildTimelineSegments(
if (part.type === "step-start" || part.type === "step-finish") { if (part.type === "step-start" || part.type === "step-finish") {
continue continue
} }
const text = collectTextFromPart(part, t) const text = collectTextFromPart(part, t)
if (text.trim().length === 0) continue if (text.trim().length === 0) continue
const target = ensureSegment(defaultContentType) const target = ensureSegment(defaultContentType)
if (target) { if (target) {
target.texts.push(text) target.texts.push(text)
target.hasPrimaryText = true target.hasPrimaryText = true
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
target.partIds.push((part as any).id)
}
target.totalChars += getPartCharCount(part)
} }
} }
flushPending() flushPending()
return result return result
} }
@@ -278,7 +319,14 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
let hoverTimer: number | null = null let hoverTimer: number | null = null
let closeTimer: number | null = null let closeTimer: number | null = null
const showTools = () => props.showToolSegments ?? true const showTools = () => props.showToolSegments ?? true
const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const }
const isHistogramEligible = (segment: TimelineSegment): boolean => {
const allowed = props.deletableMessageIds?.()
if (!allowed) return true
return allowed.has(segment.messageId)
}
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => { const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
if (element) { if (element) {
buttonRefs.set(segmentId, element) buttonRefs.set(segmentId, element)
@@ -286,7 +334,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
buttonRefs.delete(segmentId) buttonRefs.delete(segmentId)
} }
} }
const clearHoverTimer = () => { const clearHoverTimer = () => {
if (hoverTimer !== null && typeof window !== "undefined") { if (hoverTimer !== null && typeof window !== "undefined") {
window.clearTimeout(hoverTimer) window.clearTimeout(hoverTimer)
@@ -312,8 +360,11 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
setHoverAnchorRect(null) setHoverAnchorRect(null)
}, 160) }, 160)
} }
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => { const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
// Suppress previews during long-press selection gestures.
if (longPressTimer !== null) return
if (typeof window === "undefined") return if (typeof window === "undefined") return
clearHoverTimer() clearHoverTimer()
clearCloseTimer() clearCloseTimer()
@@ -328,7 +379,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const handleMouseLeave = () => { const handleMouseLeave = () => {
scheduleClose() scheduleClose()
} }
createEffect(() => { createEffect(() => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
const anchor = hoverAnchorRect() const anchor = hoverAnchorRect()
@@ -350,13 +401,235 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
clearCloseTimer() clearCloseTimer()
}) })
createEffect(() => { // --- Selection & histogram rib state ---
const activeId = props.activeMessageId const isSelectionActive = createMemo(() => (props.selectedIds?.().size ?? 0) > 0)
// Segments eligible for xray ribs. We intentionally exclude messages before
// the last compaction (when provided by the parent) to avoid misleading token
// weights for content that's no longer in context.
const xraySegments = createMemo(() => {
if (!isSelectionActive()) return [] as TimelineSegment[]
return props.segments.filter((segment) => isHistogramEligible(segment))
})
// Stable layout offsets per badge (relative to scroll content), recomputed only
// on activation, resize, or expansion — NOT on every scroll frame.
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
let scrollContainerRef: HTMLDivElement | undefined
let xrayOverlayRef: HTMLDivElement | undefined
// Full layout recomputation: reads every badge's getBoundingClientRect once,
// then stores offsets relative to the scroll content so they survive scrolling.
const computeBadgeLayout = () => {
if (!isSelectionActive() || !scrollContainerRef) return
const containerRect = scrollContainerRef.getBoundingClientRect()
const scrollTop = scrollContainerRef.scrollTop
const offsets: Record<string, { layoutTop: number; height: number }> = {}
for (const [id, element] of buttonRefs.entries()) {
if (!element) continue
const rect = element.getBoundingClientRect()
// Store position relative to scroll content (survives scrolling).
offsets[id] = {
layoutTop: rect.top - containerRect.top + scrollTop,
height: rect.height,
}
}
setBadgeOffsets(offsets)
if (xrayOverlayRef) {
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollTop}px`)
}
if (typeof window !== "undefined") {
setWindowWidth(window.innerWidth)
}
}
const handleScroll = () => {
if (!isSelectionActive()) return
if (!scrollContainerRef || !xrayOverlayRef) return
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
}
createEffect(() => {
if (isSelectionActive()) {
computeBadgeLayout()
if (typeof window !== "undefined") {
// Deferred pass: tool segments become visible when selection activates,
// but they may need a layout pass before getBoundingClientRect is accurate.
requestAnimationFrame(computeBadgeLayout)
window.addEventListener("resize", computeBadgeLayout)
onCleanup(() => {
window.removeEventListener("resize", computeBadgeLayout)
})
}
}
})
// Re-compute badge layout after expansion changes (tools become visible in DOM)
createEffect(() => {
props.expandedMessageIds?.()
if (isSelectionActive()) {
requestAnimationFrame(computeBadgeLayout)
}
})
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
// Compute fresh char counts from the store. segment.totalChars can be stale for
// tool parts whose output arrived after the timeline segment was first built.
const liveSegmentChars = createMemo(() => {
if (!isSelectionActive()) return {} as Record<string, number>
const result: Record<string, number> = {}
const resolvedStore = store()
// Compute live char counts by reading only the parts that the segment
// references (partIds/toolPartIds). This stays accurate for streamed tool
// outputs without scanning every part in the message.
for (const segment of xraySegments()) {
const record = resolvedStore.getMessage(segment.messageId)
if (!record) {
result[segment.id] = segment.totalChars
continue
}
const ids = [...(segment.partIds ?? []), ...(segment.toolPartIds ?? [])]
let chars = 0
for (const partId of ids) {
const part = record.parts?.[partId]?.data
if (!part) continue
chars += getPartCharCount(part)
}
result[segment.id] = chars > 0 ? chars : segment.totalChars
}
return result
})
// Pre-compute aggregate tokens per message: O(n) once, O(1) per lookup.
// Avoids the previous O(n²) pattern of iterating all segments inside each <For> item.
const aggregateTokensByMessageId = createMemo(() => {
const chars = liveSegmentChars()
const result: Record<string, number> = {}
for (const s of xraySegments()) {
result[s.messageId] = (result[s.messageId] ?? 0) + (chars[s.id] ?? s.totalChars)
}
for (const id of Object.keys(result)) {
result[id] = Math.max(Math.round(result[id] / 4), 1)
}
return result
})
const getSegmentTokens = (segment: TimelineSegment): number => {
const isExpanded = props.expandedMessageIds?.().has(segment.messageId) ?? false
// When tools are hidden (not expanded, not in selection mode), assistant/user
// bars show aggregate tokens for the whole message. When tools are visible
// (expanded or selection mode active), each segment shows its own tokens to
// avoid double-counting.
if (!isExpanded && !isSelectionActive() && (segment.type === "assistant" || segment.type === "user")) {
return aggregateTokensByMessageId()[segment.messageId] ?? 1
}
const chars = liveSegmentChars()[segment.id] ?? segment.totalChars
return Math.max(Math.round(chars / 4), 1)
}
const getMessageAggregateTokens = (messageId: string): number => {
return aggregateTokensByMessageId()[messageId] ?? 1
}
const formatTokenLabel = (tokens: number): string => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
return String(tokens)
}
const maxTokens = createMemo(() => {
let max = 0
for (const s of xraySegments()) {
const tokens = getSegmentTokens(s)
if (tokens > max) max = tokens
}
return Math.max(max, 1)
})
// --- Long-press for mobile selection ---
let longPressTimer: number | null = null
let wasLongPress = false
let pressStartPos = { x: 0, y: 0 }
const handlePointerDown = (segment: TimelineSegment, event: PointerEvent) => {
if (event.button !== 0) return
wasLongPress = false
pressStartPos = { x: event.clientX, y: event.clientY }
clearHoverTimer()
clearCloseTimer()
if (longPressTimer !== null && typeof window !== "undefined") {
window.clearTimeout(longPressTimer)
}
if (typeof window !== "undefined") {
longPressTimer = window.setTimeout(() => {
longPressTimer = null
wasLongPress = true
// Scroll anchoring: preserve visual position of the pressed badge.
const btn = buttonRefs.get(segment.id)
let anchorOffset: number | null = null
if (btn && scrollContainerRef) {
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
}
if (props.onLongPressSelection) {
props.onLongPressSelection(segment)
} else {
props.onToggleSelection?.(segment.id)
}
if (anchorOffset !== null && btn && scrollContainerRef) {
const desired = btn.offsetTop - anchorOffset
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
scrollContainerRef.scrollTop = desired
}
}
}, LONG_PRESS_MS)
}
}
const handlePointerUp = () => {
if (longPressTimer !== null && typeof window !== "undefined") {
window.clearTimeout(longPressTimer)
longPressTimer = null
}
}
const handlePointerMove = (event: PointerEvent) => {
if (longPressTimer !== null) {
const dist = Math.sqrt(
Math.pow(event.clientX - pressStartPos.x, 2) +
Math.pow(event.clientY - pressStartPos.y, 2),
)
if (dist > JITTER_THRESHOLD) {
if (typeof window !== "undefined") {
window.clearTimeout(longPressTimer)
}
longPressTimer = null
}
}
}
const handleContextMenu = (event: MouseEvent) => {
if (wasLongPress) {
event.preventDefault()
}
}
createEffect(on(() => props.activeSegmentId, (activeId) => {
if (!activeId) return if (!activeId) return
const targetSegment = props.segments.find((segment) => segment.messageId === activeId) const element = buttonRefs.get(activeId)
if (!targetSegment) return
const element = buttonRefs.get(targetSegment.id)
if (!element) return if (!element) return
const timer = typeof window !== "undefined" ? window.setTimeout(() => { const timer = typeof window !== "undefined" ? window.setTimeout(() => {
element.scrollIntoView({ block: "nearest", behavior: "smooth" }) element.scrollIntoView({ block: "nearest", behavior: "smooth" })
@@ -366,7 +639,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
window.clearTimeout(timer) window.clearTimeout(timer)
} }
}) })
}) }))
createEffect(() => { createEffect(() => {
const element = tooltipElement() const element = tooltipElement()
@@ -383,92 +656,265 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}) })
const previewData = createMemo(() => { const previewData = createMemo(() => {
const segment = hoveredSegment() const segment = hoveredSegment()
if (!segment) return null if (!segment) return null
const record = store().getMessage(segment.messageId) const record = store().getMessage(segment.messageId)
if (!record) return null if (!record) return null
return { messageId: segment.messageId } return { messageId: segment.messageId }
}) })
// Pre-computed set of messageIds that have at least one tool segment.
// Used by groupRole() inside <For> to avoid O(n) .some() per segment → O(1) .has().
const messagesWithTools = createMemo(() => {
const set = new Set<string>()
for (const s of props.segments) {
if (s.type === "tool") set.add(s.messageId)
}
return set
})
// Pre-computed index map for session message ordering.
// Used by isDeleteHovered() to replace O(n) indexOf with O(1) Map.get().
const messageIdToSessionIndex = createMemo(() => {
const ids = store().getSessionMessageIds(props.sessionId)
const map = new Map<string, number>()
for (let i = 0; i < ids.length; i++) map.set(ids[i], i)
return map
})
return ( return (
<div class="message-timeline" role="navigation" aria-label={t("messageTimeline.ariaLabel")}> <div class="message-timeline-container">
<For each={props.segments}> <div
{(segment) => { ref={scrollContainerRef}
onCleanup(() => buttonRefs.delete(segment.id)) class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
const isActive = () => props.activeMessageId === segment.messageId role="navigation"
aria-label={t("messageTimeline.ariaLabel")}
onScroll={handleScroll}
>
<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 hasActivePermission = () => { const isDeleteHovered = () => {
if (segment.type !== "tool") return false const hover = deleteHover() as DeleteHoverState
const partIds = segment.toolPartIds ?? [] if (hover.kind === "message") {
if (partIds.length === 0) return false return hover.messageId === segment.messageId
for (const partId of partIds) { }
const permissionState = store().getPermissionState(segment.messageId, partId)
if (permissionState?.active) return true 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
} }
return false
}
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission()) const isDeleteSelected = () => {
const selected = props.selectedMessageIds?.()
if (!selected) return false
return selected.has(segment.messageId)
}
const shortLabelContent = () => { const hasActivePermission = () => {
if (segment.type === "tool") { if (segment.type !== "tool") return false
if (hasActivePermission()) { const partIds = segment.toolPartIds ?? []
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" /> 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
// assistant. Uses messageId for correctness (not positional adjacency).
const groupRole = (): "child" | "parent" | "none" => {
if (segment.type === "tool") return "child"
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
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 = () => {
if (segment.type === "tool") {
if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
}
return segment.shortLabel ?? getToolIcon("tool")
} }
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" />
} }
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 ( return (
<button <button
ref={(el) => registerButtonRef(segment.id, el)} ref={(el) => registerButtonRef(segment.id, el)}
type="button" type="button"
data-variant={segment.variant} 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" : ""}`} 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" : ""}`}
aria-current={isActive() ? "true" : undefined} data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}
onClick={() => props.onSegmentClick?.(segment)} aria-current={isActive() ? "true" : undefined}
onMouseEnter={(event) => handleMouseEnter(segment, event)} aria-hidden={isHidden() ? "true" : undefined}
onMouseLeave={handleMouseLeave} onClick={(event) => {
> if (wasLongPress) {
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span> wasLongPress = false
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span> return
</button> }
)
}} // Capture scroll anchor before selection changes may toggle
</For> // tool segment visibility, which shifts timeline layout.
<Show when={previewData()}> const btn = buttonRefs.get(segment.id)
{(data) => { let anchorOffset: number | null = null
onCleanup(() => setTooltipElement(null)) if (btn && scrollContainerRef) {
return ( anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
<div }
ref={(element) => setTooltipElement(element)}
class="message-timeline-tooltip" const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
onMouseEnter={() => clearCloseTimer()} if (event.shiftKey) {
onMouseLeave={() => scheduleClose()} props.onSelectRange?.(segment.id)
> } else if (event.ctrlKey || event.metaKey) {
<MessagePreview props.onToggleSelection?.(segment.id)
messageId={data().messageId} } else if (isMultiSelectActive) {
instanceId={props.instanceId} // In selection mode, plain click scrolls to the message
sessionId={props.sessionId} // instead of clearing. Selection is cleared by clicking
store={store} // anywhere inside the chat container or pressing Esc.
/> props.onSegmentClick?.(segment)
</div> } 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
}
}
}}
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>
)
}}
</For>
<Show when={previewData()}>
{(data) => {
onCleanup(() => setTooltipElement(null))
return (
<div
ref={(element) => setTooltipElement(element)}
class="message-timeline-tooltip"
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
onMouseEnter={() => clearCloseTimer()}
onMouseLeave={() => scheduleClose()}
>
<MessagePreview
messageId={data().messageId}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
/>
</div>
)
}}
</Show>
</div>
<Show when={isSelectionActive()}>
<div
ref={(el) => {
xrayOverlayRef = el
if (xrayOverlayRef && scrollContainerRef) {
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
}
}}
class="message-timeline-xray-overlay"
style={{ "--max-rib-width": `${maxRibWidth()}px` }}
>
<div class="message-timeline-xray-overlay-inner">
<For each={xraySegments()}>
{(segment) => {
const pos = () => {
const offset = badgeOffsets()[segment.id]
if (!offset) return null
return { top: offset.layoutTop + offset.height / 2 }
}
const tokens = () => getSegmentTokens(segment)
const relativeWeight = () => tokens() / maxTokens()
const absoluteWeight = () => Math.min(tokens() / ABSOLUTE_TOKEN_CAP, 1.0)
const isOverflow = () => tokens() > ABSOLUTE_TOKEN_CAP
const isParent = segment.type === "assistant" || segment.type === "user"
const displayTokens = () =>
isParent ? getMessageAggregateTokens(segment.messageId) : tokens()
return (
<Show when={pos()}>
<div
class="message-timeline-xray-rib"
style={{
top: `${pos()!.top}px`,
left: "var(--xray-overhang)",
}}
>
<span class="message-timeline-xray-token-label">
{formatTokenLabel(displayTokens())}
</span>
<div
class="message-timeline-relative-bar"
style={{ "--segment-weight": relativeWeight() }}
/>
<div
class={`message-timeline-absolute-bar${isOverflow() ? " message-timeline-absolute-bar-overflow" : ""}`}
style={{ "--segment-weight": absoluteWeight() }}
/>
</div>
</Show>
)
}}
</For>
</div>
</div>
</Show> </Show>
</div> </div>
) )
} }
export default MessageTimeline export default MessageTimeline

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js" import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Volume2, X } from "lucide-solid"
import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button" import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments" import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
import Kbd from "./kbd" import Kbd from "./kbd"
import { getActiveInstance } from "../stores/instances" import { getActiveInstance } from "../stores/instances"
import { agents, executeCustomCommand } from "../stores/sessions" import { agents, executeCustomCommand } from "../stores/sessions"
@@ -13,11 +13,43 @@ import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { preferences } from "../stores/preferences" import { preferences } from "../stores/preferences"
import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types" import type { ExpandState, PromptInputApi, PromptInputProps, PromptInsertMode, PromptMode } from "./prompt-input/types"
import type { Attachment } from "../types/attachment"
import { usePromptState } from "./prompt-input/usePromptState" import { usePromptState } from "./prompt-input/usePromptState"
import { usePromptAttachments } from "./prompt-input/usePromptAttachments" import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
import { usePromptPicker } from "./prompt-input/usePromptPicker" import { usePromptPicker } from "./prompt-input/usePromptPicker"
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
import { canUseConversationMode, isConversationModeEnabled, toggleConversationMode } from "../stores/conversation-speech"
const log = getLogger("actions") const log = getLogger("actions")
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
if (!text || attachments.length === 0) return []
const usedCounters = new Set<string>()
for (const match of text.matchAll(createPastedPlaceholderRegex())) {
const counter = match?.[1]
if (counter) usedCounters.add(counter)
}
if (usedCounters.size === 0) return []
const consumed = new Set<string>()
for (const attachment of attachments) {
if (!attachment?.id) continue
if (attachment?.source?.type !== "text") continue
const display = attachment.display
if (typeof display !== "string") continue
const match = display.match(pastedDisplayCounterRegex)
if (!match?.[1]) continue
if (usedCounters.has(match[1])) {
consumed.add(attachment.id)
}
}
return Array.from(consumed)
}
export default function PromptInput(props: PromptInputProps) { export default function PromptInput(props: PromptInputProps) {
const { t } = useI18n() const { t } = useI18n()
@@ -246,7 +278,12 @@ export default function PromptInput(props: PromptInputProps) {
commandName.length > 0 && commandName.length > 0 &&
getCommands(props.instanceId).some((cmd) => cmd.name === commandName) getCommands(props.instanceId).some((cmd) => cmd.name === commandName)
const resolvedPrompt = isKnownSlashCommand ? text : resolvePastedPlaceholders(text, currentAttachments) const resolvedCommandArgs = isKnownSlashCommand ? resolvePastedPlaceholders(commandArgs, currentAttachments) : ""
const resolvedPrompt = isKnownSlashCommand
? resolvedCommandArgs
? `${commandToken} ${resolvedCommandArgs}`
: commandToken
: resolvePastedPlaceholders(text, currentAttachments)
const historyEntry = resolvedPrompt const historyEntry = resolvedPrompt
const refreshHistory = () => recordHistoryEntry(historyEntry) const refreshHistory = () => recordHistoryEntry(historyEntry)
@@ -262,6 +299,10 @@ export default function PromptInput(props: PromptInputProps) {
syncAttachmentCounters("") syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} else { } else {
const consumedIds = getConsumedPastedTextAttachmentIds(commandArgs, currentAttachments)
for (const attachmentId of consumedIds) {
removeAttachment(props.instanceId, props.sessionId, attachmentId)
}
syncAttachmentCounters("") syncAttachmentCounters("")
setIgnoredAtPositions(new Set<number>()) setIgnoredAtPositions(new Set<number>())
} }
@@ -281,7 +322,7 @@ export default function PromptInput(props: PromptInputProps) {
await props.onSend(resolvedPrompt, []) await props.onSend(resolvedPrompt, [])
} }
} else if (isKnownSlashCommand) { } else if (isKnownSlashCommand) {
await executeCustomCommand(props.instanceId, props.sessionId, commandName, commandArgs) await executeCustomCommand(props.instanceId, props.sessionId, commandName, resolvedCommandArgs)
} else { } else {
await props.onSend(resolvedPrompt, currentAttachments) await props.onSend(resolvedPrompt, currentAttachments)
} }
@@ -311,6 +352,19 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef?.focus() textareaRef?.focus()
} }
function handleClearPrompt() {
clearPrompt()
clearHistoryDraft()
resetHistoryNavigation()
setShowPicker(false)
setPickerMode("mention")
setAtPosition(null)
setSearchQuery("")
setIgnoredAtPositions(new Set<number>())
syncAttachmentCounters("")
textareaRef?.focus()
}
function insertBlockContent(block: string) { function insertBlockContent(block: string) {
const textarea = textareaRef const textarea = textareaRef
const current = prompt() const current = prompt()
@@ -382,6 +436,8 @@ export default function PromptInput(props: PromptInputProps) {
return hasText || attachments().length > 0 return hasText || attachments().length > 0
} }
const canClearPrompt = () => prompt().length > 0
const shellHint = () => const shellHint = () =>
mode() === "shell" mode() === "shell"
? { key: "Esc", text: t("promptInput.hints.shell.exit") } ? { key: "Esc", text: t("promptInput.hints.shell.exit") }
@@ -411,9 +467,52 @@ export default function PromptInput(props: PromptInputProps) {
}) })
const shouldShowOverlay = () => prompt().length === 0 const shouldShowOverlay = () => prompt().length === 0
const voiceInput = usePromptVoiceInput({
prompt,
setPrompt,
getTextarea: () => textareaRef ?? null,
enabled: () => preferences().showPromptVoiceInput,
disabled: () => Boolean(props.disabled),
})
const showVoiceInput = () =>
preferences().showPromptVoiceInput &&
(voiceInput.canUseVoiceInput() || voiceInput.isRecording() || voiceInput.isTranscribing())
const conversationModeEnabled = () => isConversationModeEnabled(props.instanceId)
const showConversationToggle = () => showVoiceInput() || conversationModeEnabled()
const canToggleConversationMode = () => canUseConversationMode()
const conversationModeButtonTitle = () =>
conversationModeEnabled()
? t("promptInput.conversationMode.disable.title")
: t("promptInput.conversationMode.enable.title")
const instance = () => getActiveInstance() const instance = () => getActiveInstance()
let voiceButtonPressed = false
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
voiceButtonPressed = true
if (event instanceof PointerEvent) {
const target = event.currentTarget
if (target instanceof HTMLElement) {
try {
target.setPointerCapture(event.pointerId)
} catch {
// no-op
}
}
}
void voiceInput.startRecording()
}
const endVoicePress = () => {
if (!voiceButtonPressed) return
voiceButtonPressed = false
voiceInput.stopRecording()
}
return ( return (
<div class="prompt-input-container"> <div class="prompt-input-container">
<div <div
@@ -428,18 +527,20 @@ export default function PromptInput(props: PromptInputProps) {
onDrop={handleDrop} onDrop={handleDrop}
> >
<Show when={showPicker() && instance()}> <Show when={showPicker() && instance()}>
<UnifiedPicker <Suspense fallback={null}>
open={showPicker()} <LazyUnifiedPicker
mode={pickerMode()} open={showPicker()}
onClose={handlePickerClose} mode={pickerMode()}
onSelect={handlePickerSelect} onClose={handlePickerClose}
agents={instanceAgents()} onSelect={handlePickerSelect}
commands={getCommands(props.instanceId)} agents={instanceAgents()}
instanceClient={instance()!.client} commands={getCommands(props.instanceId)}
searchQuery={searchQuery()} instanceClient={instance()!.client}
textareaRef={textareaRef} searchQuery={searchQuery()}
workspaceId={props.instanceId} textareaRef={textareaRef}
/> workspaceId={props.instanceId}
/>
</Suspense>
</Show> </Show>
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col">
@@ -449,6 +550,7 @@ export default function PromptInput(props: PromptInputProps) {
<textarea <textarea
ref={textareaRef} ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`} class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
dir="auto"
placeholder={getPlaceholder()} placeholder={getPlaceholder()}
value={prompt()} value={prompt()}
onInput={handleInput} onInput={handleInput}
@@ -464,42 +566,111 @@ export default function PromptInput(props: PromptInputProps) {
autocomplete="off" autocomplete="off"
/> />
<div class="prompt-nav-buttons"> <div class="prompt-nav-buttons">
<ExpandButton <div class="prompt-nav-column prompt-nav-column-left">
expandState={expandState} <Show when={showVoiceInput()}>
onToggleExpand={handleExpandToggle} <button
/> type="button"
<Show when={hasHistory()}> class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
onPointerDown={(event) => {
event.preventDefault()
beginVoicePress(event)
}}
onPointerUp={(event) => {
event.preventDefault()
endVoicePress()
}}
onPointerCancel={() => endVoicePress()}
onLostPointerCapture={() => endVoicePress()}
onKeyDown={(event) => {
if (event.repeat) return
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
beginVoicePress(event)
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
endVoicePress()
}}
onBlur={() => endVoicePress()}
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()}
>
<Show
when={voiceInput.isRecording()}
fallback={
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show>
}
>
<Mic class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
</Show>
<Show when={showConversationToggle()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
onClick={() => toggleConversationMode(props.instanceId)}
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
aria-pressed={conversationModeEnabled()}
aria-label={conversationModeButtonTitle()}
title={conversationModeButtonTitle()}
>
<Volume2 class="h-4 w-4" aria-hidden="true" />
</button>
</Show>
<button <button
type="button" type="button"
class="prompt-history-button" class="prompt-clear-button"
onClick={() => onClick={handleClearPrompt}
selectPreviousHistory({ disabled={!canClearPrompt()}
force: true, aria-label={t("promptInput.clear.ariaLabel")}
isPickerOpen: showPicker(), title={t("promptInput.clear.title")}
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
> >
<ArrowBigUp class="h-5 w-5" aria-hidden="true" /> <X class="h-4 w-4" aria-hidden="true" />
</button> </button>
<button </div>
type="button" <div class="prompt-nav-column prompt-nav-column-right">
class="prompt-history-button" <ExpandButton
onClick={() => expandState={expandState}
selectNextHistory({ onToggleExpand={handleExpandToggle}
force: true, />
isPickerOpen: showPicker(), <Show when={hasHistory()}>
getTextarea: () => textareaRef, <button
}) type="button"
} class="prompt-history-button"
disabled={!canHistoryGoNext()} onClick={() =>
aria-label={t("promptInput.history.nextAriaLabel")} selectPreviousHistory({
> force: true,
<ArrowBigDown class="h-5 w-5" aria-hidden="true" /> isPickerOpen: showPicker(),
</button> getTextarea: () => textareaRef,
</Show> })
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectNextHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoNext()}
aria-label={t("promptInput.history.nextAriaLabel")}
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</Show>
</div>
</div> </div>
<Show when={shouldShowOverlay()}> <Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}> <div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>

View File

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

View File

@@ -2,7 +2,7 @@ import { Dialog } from "@kobalte/core/dialog"
import { Switch } from "@kobalte/core/switch" import { Switch } from "@kobalte/core/switch"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js" import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { toDataURL } from "qrcode" import { toDataURL } from "qrcode"
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types" import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client" import { serverApi } from "../lib/api-client"
import { restartCli } from "../lib/native/cli" import { restartCli } from "../lib/native/cli"
@@ -10,6 +10,7 @@ import { serverSettings, setListeningMode } from "../stores/preferences"
import { showConfirmDialog } from "../stores/alerts" import { showConfirmDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger" import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses"
const log = getLogger("actions") const log = getLogger("actions")
@@ -32,17 +33,17 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [passwordConfirm, setPasswordConfirm] = createSignal("") const [passwordConfirm, setPasswordConfirm] = createSignal("")
const [passwordError, setPasswordError] = createSignal<string | null>(null) const [passwordError, setPasswordError] = createSignal<string | null>(null)
const [savingPassword, setSavingPassword] = createSignal(false) const [savingPassword, setSavingPassword] = createSignal(false)
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? []) const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all") const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo(() => { const displayAddresses = createMemo<RemoteAddressGroups>(() => {
const list = addresses() const list = addresses()
if (!allowExternalConnections()) { if (!allowExternalConnections()) {
return [] return { recommended: null, hidden: [] }
} }
// Local URL is displayed separately; list only remote-friendly addresses. return splitRemoteAddresses(list)
return list.filter((address) => address.scope !== "loopback")
}) })
const refreshMeta = async () => { const refreshMeta = async () => {
@@ -53,6 +54,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()]) const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
setMeta(metaResult) setMeta(metaResult)
setAuthStatus(authResult) setAuthStatus(authResult)
setShowAllAddresses(false)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)) setError(err instanceof Error ? err.message : String(err))
} finally { } finally {
@@ -325,7 +327,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}> <Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}> <Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}> <Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
<div class="remote-address-list"> <div class="remote-address-list">
<Show when={meta()?.localUrl}> <Show when={meta()?.localUrl}>
{(url) => { {(url) => {
@@ -372,8 +374,9 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
) )
}} }}
</Show> </Show>
<For each={displayAddresses()}> <Show when={displayAddresses().recommended}>
{(address) => { {(addressAccessor) => {
const address = addressAccessor()
const url = address.remoteUrl const url = address.remoteUrl
const expandedState = () => expandedUrl() === url const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url] const qr = () => qrCodes()[url]
@@ -383,13 +386,14 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
: address.scope === "loopback" : address.scope === "loopback"
? t("remoteAccess.address.scope.loopback") ? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal") : t("remoteAccess.address.scope.internal")
return ( return (
<div class="remote-address"> <div class="remote-address">
<div class="remote-address-main"> <div class="remote-address-main">
<div> <div>
<p class="remote-address-url">{url}</p> <p class="remote-address-url">{url}</p>
<p class="remote-address-meta"> <p class="remote-address-meta">
{address.family.toUpperCase()} {scopeLabel()} {address.ip} {address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
</p> </p>
</div> </div>
<div class="remote-actions"> <div class="remote-actions">
@@ -424,7 +428,83 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
</div> </div>
) )
}} }}
</For> </Show>
<Show when={displayAddresses().hidden.length > 0}>
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
<button
class="remote-address-disclosure-trigger"
type="button"
onClick={() => setShowAllAddresses(!showAllAddresses())}
aria-expanded={showAllAddresses()}
>
<span class="remote-address-disclosure-label">
{showAllAddresses()
? t("remoteAccess.addresses.actions.hideOther")
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
</span>
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
</button>
<Show when={showAllAddresses()}>
<div class="remote-address-disclosure-content">
<For each={displayAddresses().hidden}>
{(address) => {
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} {scopeLabel()} {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url })}
class="remote-qr-img"
/>
)}
</Show>
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
</div>
</Show>
</div> </div>
</Show> </Show>
</Show> </Show>

View File

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

View File

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

View File

@@ -10,11 +10,13 @@ import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances" import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status" import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
import { deleteMessage } from "../../stores/session-actions"
import { showAlertDialog } from "../../stores/alerts" import { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api" import { requestData } from "../../lib/opencode-api"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types" import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
import { clearConversationPlaybackForSession } from "../../stores/conversation-speech"
const log = getLogger("session") const log = getLogger("session")
@@ -55,12 +57,22 @@ export const SessionView: Component<SessionViewProps> = (props) => {
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId)) const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
let promptInputApi: PromptInputApi | null = null let promptInputApi: PromptInputApi | null = null
let pendingPromptText: string | null = null let pendingPromptText: string | null = null
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
let scrollToBottomHandle: (() => void) | undefined let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined let rootRef: HTMLDivElement | undefined
function shouldScrollToBottomOnActivate() {
const current = session()
if (!current) return true
const snapshot = messageStore().getScrollSnapshot(current.id, MESSAGE_SCROLL_CACHE_SCOPE)
return !snapshot || snapshot.atBottom
}
function scheduleScrollToBottom() { function scheduleScrollToBottom() {
if (!scrollToBottomHandle) return if (!scrollToBottomHandle) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -69,6 +81,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
createEffect(() => { createEffect(() => {
if (!props.isActive) return if (!props.isActive) return
if (!shouldScrollToBottomOnActivate()) return
scheduleScrollToBottom() scheduleScrollToBottom()
}) })
@@ -76,6 +89,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
on( on(
() => props.isActive, () => props.isActive,
(isActive) => { (isActive) => {
if (!isActive) {
clearConversationPlaybackForSession(props.instanceId, props.sessionId)
return
}
if (!isActive) return if (!isActive) return
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK). // On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
@@ -225,6 +242,35 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
} }
async function handleDeleteMessagesUpTo(messageId: string) {
const ids = messageStore().getSessionMessageIds(props.sessionId)
const index = ids.indexOf(messageId)
if (index === -1) return
const restoredText = getUserMessageText(messageId)
const toDelete = ids.slice(index)
try {
for (let idx = toDelete.length - 1; idx >= 0; idx -= 1) {
await deleteMessage(props.instanceId, props.sessionId, toDelete[idx])
}
} catch (error) {
log.error("Failed to delete messages up to", error)
showAlertDialog(t("sessionView.alerts.deleteUpToFailed.message"), {
title: t("sessionView.alerts.deleteUpToFailed.title"),
variant: "error",
})
} finally {
if (restoredText) {
if (promptInputApi) {
promptInputApi.setPromptText(restoredText, { focus: true })
} else {
pendingPromptText = restoredText
}
}
}
}
async function handleFork(messageId?: string) { async function handleFork(messageId?: string) {
if (!messageId) { if (!messageId) {
log.warn("Fork requires a user message id") log.warn("Fork requires a user message id")
@@ -283,14 +329,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
<MessageSection <MessageSection
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={activeSession.id} sessionId={activeSession.id}
loading={messagesLoading()} loading={messagesLoading()}
onRevert={handleRevert} onRevert={handleRevert}
onFork={handleFork} onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
isActive={props.isActive} onFork={handleFork}
isActive={props.isActive}
registerScrollToBottom={(fn) => { registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn scrollToBottomHandle = fn
if (props.isActive) { if (props.isActive) {
scheduleScrollToBottom() if (shouldScrollToBottomOnActivate()) {
scheduleScrollToBottom()
}
} }
}} }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,373 @@
import { For, Show, createEffect, createMemo, createSignal, type Component } from "solid-js"
import { Loader2, Mic, Square, Volume2 } from "lucide-solid"
import { useConfig, type SpeechSettings } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
import { loadSpeechCapabilities, speechCapabilities, speechCapabilitiesError, speechCapabilitiesLoading } from "../../stores/speech"
import { getLogger } from "../../lib/logger"
import { useSpeech } from "../../lib/hooks/use-speech"
import { getSpeechPlaybackSupport } from "../../lib/speech-playback-support"
const log = getLogger("actions")
type DraftFields = {
apiKey: string
baseUrl: string
sttModel: string
ttsModel: string
ttsVoice: string
playbackMode: SpeechSettings["playbackMode"]
ttsFormat: SpeechSettings["ttsFormat"]
}
function createDraftFields(speech: SpeechSettings): DraftFields {
return {
apiKey: "",
baseUrl: speech.baseUrl ?? "",
sttModel: speech.sttModel,
ttsModel: speech.ttsModel,
ttsVoice: speech.ttsVoice,
playbackMode: speech.playbackMode,
ttsFormat: speech.ttsFormat,
}
}
function isDraftEqual(a: DraftFields, b: DraftFields): boolean {
return (
a.apiKey === b.apiKey &&
a.baseUrl === b.baseUrl &&
a.sttModel === b.sttModel &&
a.ttsModel === b.ttsModel &&
a.ttsVoice === b.ttsVoice &&
a.playbackMode === b.playbackMode &&
a.ttsFormat === b.ttsFormat
)
}
export const SpeechSettingsCard: Component = () => {
const { t } = useI18n()
const { serverSettings, updateSpeechSettings } = useConfig()
const initialDrafts = createDraftFields(serverSettings().speech)
const [isSaving, setIsSaving] = createSignal(false)
const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved")
const [drafts, setDrafts] = createSignal<DraftFields>(initialDrafts)
const [apiKeyTouched, setApiKeyTouched] = createSignal(false)
const [clearStoredApiKey, setClearStoredApiKey] = createSignal(false)
const testSpeech = useSpeech({
id: () => "settings-speech-test",
text: () => t("settings.speech.testPlayback.sample"),
settingsOverride: () => ({
playbackMode: drafts().playbackMode,
ttsFormat: drafts().ttsFormat,
}),
})
createEffect(() => {
const speech = serverSettings().speech
const nextDrafts = createDraftFields(speech)
if (!isSaving() && !isDirty()) {
if (!isDraftEqual(drafts(), nextDrafts)) {
setDrafts(nextDrafts)
}
if (apiKeyTouched()) {
setApiKeyTouched(false)
}
if (clearStoredApiKey()) {
setClearStoredApiKey(false)
}
}
})
createEffect(() => {
void loadSpeechCapabilities()
})
const capabilityLabel = () => {
if (speechCapabilitiesLoading()) return t("settings.speech.status.loading")
if (speechCapabilitiesError()) return t("settings.speech.status.error")
return speechCapabilities()?.configured ? t("settings.speech.status.configured") : t("settings.speech.status.missing")
}
const updateDraft = (key: keyof DraftFields, value: string) => {
setSaveStatus("idle")
if (key === "apiKey") {
setApiKeyTouched(true)
setClearStoredApiKey(false)
}
setDrafts((current) => ({ ...current, [key]: value }))
}
const apiKeyDirty = createMemo(() => clearStoredApiKey() || drafts().apiKey.trim().length > 0)
const playbackSupport = createMemo(() =>
getSpeechPlaybackSupport({
playbackMode: drafts().playbackMode,
ttsFormat: drafts().ttsFormat,
capabilities: speechCapabilities(),
}),
)
const compatibilityMessage = createMemo(() => {
const capabilities = speechCapabilities()
if (!capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
return null
}
if (drafts().playbackMode === "streaming" && !capabilities.supportsStreamingTts) {
return t("settings.speech.compatibility.streamingUnavailable")
}
if (drafts().playbackMode === "streaming" && !playbackSupport().available) {
return t("settings.speech.compatibility.browserStreamingUnavailable")
}
return t("settings.speech.compatibility.runtimeNote")
})
const isDirty = createMemo(() => {
const speech = serverSettings().speech
const current = drafts()
return (
apiKeyDirty() ||
(current.baseUrl || "") !== (speech.baseUrl || "") ||
current.sttModel !== speech.sttModel ||
current.ttsModel !== speech.ttsModel ||
current.ttsVoice !== speech.ttsVoice ||
current.playbackMode !== speech.playbackMode ||
current.ttsFormat !== speech.ttsFormat
)
})
const saveStatusLabel = () => {
if (isSaving()) return t("settings.speech.save.saving")
if (saveStatus() === "saved") return t("settings.speech.save.saved")
if (saveStatus() === "error") return t("settings.speech.save.error")
return t("settings.speech.save.unsaved")
}
async function handleSave() {
if (!isDirty() || isSaving()) return
const current = drafts()
setIsSaving(true)
setSaveStatus("idle")
try {
const trimmedApiKey = current.apiKey.trim()
await updateSpeechSettings({
...(clearStoredApiKey() ? { apiKey: null } : trimmedApiKey ? { apiKey: trimmedApiKey } : {}),
baseUrl: current.baseUrl.trim() || undefined,
sttModel: current.sttModel.trim() || undefined,
ttsModel: current.ttsModel.trim() || undefined,
ttsVoice: current.ttsVoice.trim() || undefined,
playbackMode: current.playbackMode,
ttsFormat: current.ttsFormat,
})
await loadSpeechCapabilities(true)
setDrafts({
apiKey: "",
baseUrl: current.baseUrl.trim(),
sttModel: current.sttModel.trim() || serverSettings().speech.sttModel,
ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel,
ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice,
playbackMode: current.playbackMode,
ttsFormat: current.ttsFormat,
})
setApiKeyTouched(false)
setClearStoredApiKey(false)
setSaveStatus("saved")
} catch (error) {
log.error("Failed to save speech settings", error)
setSaveStatus("error")
} finally {
setIsSaving(false)
}
}
return (
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Volume2 class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.speech.title")}</h3>
<p class="settings-card-subtitle">{t("settings.speech.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-stack">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("settings.speech.provider.title")}</div>
<div class="settings-toggle-caption">{t("settings.speech.provider.subtitle")}</div>
</div>
<div class="settings-toolbar-inline">
<span class="settings-inline-note">{t("settings.speech.provider.openaiCompatible")}</span>
<span class="settings-inline-note">{capabilityLabel()}</span>
<span class="settings-inline-note">{saveStatusLabel()}</span>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap inline-flex items-center gap-2"
onClick={() => void testSpeech.toggle()}
disabled={isSaving()}
title={testSpeech.buttonTitle()}
aria-label={testSpeech.buttonTitle()}
>
<Show
when={testSpeech.isLoading()}
fallback={
<Show when={testSpeech.isPlaying()} fallback={<Volume2 class="w-3.5 h-3.5" aria-hidden="true" />}>
<Square class="w-3.5 h-3.5" aria-hidden="true" />
</Show>
}
>
<Loader2 class="w-3.5 h-3.5 animate-spin" aria-hidden="true" />
</Show>
<span>
{testSpeech.isPlaying()
? t("settings.speech.testPlayback.stop")
: testSpeech.isLoading()
? t("settings.speech.testPlayback.generating")
: t("settings.speech.testPlayback.action")}
</span>
</button>
<button
type="button"
class="selector-button selector-button-primary w-auto whitespace-nowrap"
onClick={() => void handleSave()}
disabled={!isDirty() || isSaving()}
>
{isSaving() ? t("settings.speech.save.saving") : t("settings.speech.save.action")}
</button>
</div>
</div>
<Field
label={t("settings.speech.apiKey.title")}
caption={t("settings.speech.apiKey.subtitle")}
value={drafts().apiKey}
onInput={(value) => updateDraft("apiKey", value)}
type="password"
placeholder={serverSettings().speech.hasApiKey ? t("settings.speech.apiKey.placeholder") : undefined}
/>
<Show when={serverSettings().speech.hasApiKey && !apiKeyTouched() && drafts().apiKey.length === 0}>
<div class="settings-inline-note">
{clearStoredApiKey() ? t("settings.speech.apiKey.clearPending") : t("settings.speech.apiKey.storedNote")}{" "}
<Show when={!clearStoredApiKey()}>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
onClick={() => {
setClearStoredApiKey(true)
setSaveStatus("idle")
}}
>
{t("settings.speech.apiKey.clearAction")}
</button>
</Show>
</div>
</Show>
<Field
label={t("settings.speech.baseUrl.title")}
caption={t("settings.speech.baseUrl.subtitle")}
value={drafts().baseUrl}
onInput={(value) => updateDraft("baseUrl", value)}
placeholder={t("settings.speech.baseUrl.placeholder")}
/>
<Field
label={t("settings.speech.sttModel.title")}
caption={t("settings.speech.sttModel.subtitle")}
value={drafts().sttModel}
onInput={(value) => updateDraft("sttModel", value)}
/>
<Field
label={t("settings.speech.ttsModel.title")}
caption={t("settings.speech.ttsModel.subtitle")}
value={drafts().ttsModel}
onInput={(value) => updateDraft("ttsModel", value)}
/>
<Field
label={t("settings.speech.ttsVoice.title")}
caption={t("settings.speech.ttsVoice.subtitle")}
value={drafts().ttsVoice}
onInput={(value) => updateDraft("ttsVoice", value)}
icon={<Mic class="w-3.5 h-3.5 icon-muted flex-shrink-0" />}
/>
<SelectField
label={t("settings.speech.playbackMode.title")}
caption={t("settings.speech.playbackMode.subtitle")}
value={drafts().playbackMode}
onInput={(value) => updateDraft("playbackMode", value as DraftFields["playbackMode"])}
options={[
{ value: "streaming", label: t("settings.speech.playbackMode.streaming") },
{ value: "buffered", label: t("settings.speech.playbackMode.buffered") },
]}
/>
<SelectField
label={t("settings.speech.ttsFormat.title")}
caption={t("settings.speech.ttsFormat.subtitle")}
value={drafts().ttsFormat}
onInput={(value) => updateDraft("ttsFormat", value as DraftFields["ttsFormat"])}
options={[
{ value: "mp3", label: "MP3" },
{ value: "wav", label: "WAV" },
{ value: "opus", label: "Opus" },
{ value: "aac", label: "AAC" },
]}
/>
<div class="settings-inline-note">{t("settings.speech.help")}</div>
<Show when={compatibilityMessage()}>{(message) => <div class="settings-inline-note">{message()}</div>}</Show>
<div class="settings-inline-note">{t("settings.speech.testPlayback.note")}</div>
</div>
</div>
)
}
const Field: Component<{
label: string
caption: string
value: string
type?: string
placeholder?: string
onInput: (value: string) => void
icon?: any
}> = (props) => {
return (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div>
</div>
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full">
{props.icon}
<input
type={props.type ?? "text"}
value={props.value}
onInput={(event) => props.onInput(event.currentTarget.value)}
class="selector-input w-full"
placeholder={props.placeholder}
/>
</div>
</div>
)
}
const SelectField: Component<{
label: string
caption: string
value: string
onInput: (value: string) => void
options: Array<{ value: string; label: string }>
}> = (props) => {
return (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div>
</div>
<div class="min-w-[18rem] max-w-[24rem] 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>
</select>
</div>
</div>
)
}
export default SpeechSettingsCard

View File

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

View File

@@ -0,0 +1,34 @@
import { Loader2, Volume2 } from "lucide-solid"
import type { JSX } from "solid-js"
interface SpeechActionButtonProps {
class?: string
title: string
isLoading: boolean
isPlaying: boolean
onClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
type?: "button" | "submit" | "reset"
}
export default function SpeechActionButton(props: SpeechActionButtonProps) {
return (
<button
type={props.type ?? "button"}
class={props.class}
onClick={props.onClick}
aria-label={props.title}
title={props.title}
>
{props.isLoading ? (
<Loader2 class="w-3.5 h-3.5 animate-spin" aria-hidden="true" />
) : props.isPlaying ? (
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" />
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" stroke="none" />
</svg>
) : (
<Volume2 class="w-3.5 h-3.5" aria-hidden="true" />
)}
</button>
)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils" import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
import { tGlobal } from "../../lib/i18n" import { tGlobal } from "../../lib/i18n"

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { createMemo, Show, For, createEffect, type Accessor } from "solid-js" import { createMemo, Show, For, createEffect, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"

View File

@@ -1,5 +1,5 @@
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" 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"
import { resolveTitleForTool } from "../tool-title" import { resolveTitleForTool } from "../tool-title"
@@ -178,28 +178,116 @@ export const taskRenderer: ToolRenderer = {
void loadMessages(instanceId, id) void loadMessages(instanceId, id)
}) })
const childToolKeys = createMemo(() => { const [childToolKeys, setChildToolKeys] = createSignal<string[]>([])
const id = childSessionId()
if (!id) return [] as string[]
if (!childSessionLoaded()) return [] as string[]
// React to session changes, but do the scan untracked to avoid let indexedSessionId = ""
// subscribing to every message/part node in the store. let indexedMessageCount = 0
let indexedMessageTail = ""
const indexedPartCounts = new Map<string, number>()
function resetChildToolIndex(nextSessionId: string) {
indexedSessionId = nextSessionId
indexedMessageCount = 0
indexedMessageTail = ""
indexedPartCounts.clear()
setChildToolKeys([])
}
function scanMessageToolParts(messageId: string, startIndex: number) {
const record = store.getMessage(messageId)
if (!record) return [] as string[]
const partIds = record.partIds
const keys: string[] = []
for (let idx = startIndex; idx < partIds.length; idx += 1) {
const partId = partIds[idx]
const entry = record.parts?.[partId]
const data = entry?.data
if (!data || (data as any).type !== "tool") continue
keys.push(`${messageId}::${partId}`)
}
indexedPartCounts.set(messageId, partIds.length)
return keys
}
function fullRescanChildTools(sessionId: string, messageIds: string[]) {
indexedSessionId = sessionId
indexedMessageCount = messageIds.length
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
indexedPartCounts.clear()
const nextKeys: string[] = []
for (const messageId of messageIds) {
nextKeys.push(...scanMessageToolParts(messageId, 0))
}
setChildToolKeys(nextKeys)
}
createEffect(() => {
const id = childSessionId()
const loaded = childSessionLoaded()
if (!id || !loaded) {
if (indexedSessionId) {
resetChildToolIndex("")
}
return
}
// We use the session revision as the reactive change point, but avoid
// rescanning the entire session on every update.
store.getSessionRevision(id) store.getSessionRevision(id)
return untrack(() => {
untrack(() => {
const messageIds = store.getSessionMessageIds(id) const messageIds = store.getSessionMessageIds(id)
const keys: string[] = []
for (const messageId of messageIds) { if (!indexedSessionId || indexedSessionId !== id) {
const record = store.getMessage(messageId) fullRescanChildTools(id, messageIds)
if (!record) continue return
for (const partId of record.partIds) { }
const entry = record.parts?.[partId]
const data = entry?.data // Detect structural changes (reorder/shrink) and fall back to a full rescan.
if (!data || (data as any).type !== "tool") continue if (messageIds.length < indexedMessageCount) {
keys.push(`${messageId}::${partId}`) fullRescanChildTools(id, messageIds)
return
}
if (indexedMessageCount > 0) {
const expectedTailIndex = indexedMessageCount - 1
if (expectedTailIndex >= 0 && messageIds[expectedTailIndex] !== indexedMessageTail) {
fullRescanChildTools(id, messageIds)
return
} }
} }
return keys
const appendedKeys: string[] = []
// Scan any new messages appended since last index.
for (let idx = indexedMessageCount; idx < messageIds.length; idx += 1) {
const messageId = messageIds[idx]
appendedKeys.push(...scanMessageToolParts(messageId, 0))
}
// Scan a small window of recent messages for newly appended parts.
// Deltas typically affect the most recent tool call, so this avoids
// iterating every message on every revision.
const existingCount = Math.min(indexedMessageCount, messageIds.length)
const windowStart = Math.max(0, existingCount - 3)
for (let idx = windowStart; idx < existingCount; idx += 1) {
const messageId = messageIds[idx]
const previousPartCount = indexedPartCounts.get(messageId) ?? 0
const record = store.getMessage(messageId)
const nextPartCount = record?.partIds.length ?? 0
if (nextPartCount > previousPartCount) {
appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount))
}
}
indexedMessageCount = messageIds.length
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
if (appendedKeys.length > 0) {
setChildToolKeys((prev) => [...prev, ...appendedKeys])
}
}) })
}) })
const promptContent = createMemo(() => { const promptContent = createMemo(() => {
@@ -287,7 +375,9 @@ export const taskRenderer: ToolRenderer = {
content: promptContent()!, content: promptContent()!,
cacheKey: "task:prompt", cacheKey: "task:prompt",
disableScrollTracking: true, disableScrollTracking: true,
disableHighlight: true, // Always use the normal markdown render path for prompt (even while running)
// so the prompt doesn't visually change between running/completed states.
disableHighlight: false,
})} })}
</div> </div>
</section> </section>
@@ -352,7 +442,7 @@ export const taskRenderer: ToolRenderer = {
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
} }
> >
<div class="tool-call-task-summary"> <div class="tool-call-task-summary">
<For each={childToolKeys()}> <For each={childToolKeys()}>
{(key) => ( {(key) => (
<Show when={renderToolCall}> <Show when={renderToolCall}>

View File

@@ -1,5 +1,5 @@
import { For, Show } from "solid-js" import { For, Show } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer } from "../types" import type { ToolRenderer } from "../types"
import { readToolStatePayload } from "../utils" import { readToolStatePayload } from "../utils"
import { useI18n, tGlobal } from "../../../lib/i18n" import { useI18n, tGlobal } from "../../../lib/i18n"

View File

@@ -1,4 +1,4 @@
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types" import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils" import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
import { enMessages } from "../../lib/i18n/messages/en" import { enMessages } from "../../lib/i18n/messages/en"

View File

@@ -1,5 +1,5 @@
import type { Accessor, JSXElement } from "solid-js" import type { Accessor, JSXElement } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ClientPart } from "../../types/message" import type { ClientPart } from "../../types/message"
export type ToolCallPart = Extract<ClientPart, { type: "tool" }> export type ToolCallPart = Extract<ClientPart, { type: "tool" }>

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