Compare commits

...

111 Commits

Author SHA1 Message Date
Shantur Rathore
ab3f228d85 fix(ui): handle Windows paths in tab titles 2026-02-12 14:57:40 +00:00
Shantur Rathore
3382736f05 fix(ui): split message header into two rows
Move assistant meta below speaker label and bump speaker label size.
2026-02-11 16:02:24 +00:00
Shantur Rathore
fd5941fb36 fix(ui): show active session status in header
Fixes #139
2026-02-11 15:41:28 +00:00
Shantur Rathore
9b76521a90 fix(ui): improve recent folders path display (#147) 2026-02-11 14:24:29 +00:00
Shantur Rathore
ea92c0609d fix(server): move spawn env/args behind debug/trace (#141) 2026-02-11 14:06:39 +00:00
Shantur Rathore
612e50808a fix(ui): preserve draft across prompt history
Stop resetting history navigation on input so editing recalled entries doesn't wipe the bottom draft. Allow ArrowDown navigation while in history and persist the session draft only for fresh prompts.
2026-02-11 13:52:02 +00:00
Shantur Rathore
2c24402742 Bump v0.10.3 and min server 0.10.3 2026-02-11 13:16:23 +00:00
Shantur Rathore
d7c4bf1e45 fix(ui): render selected session diff payload
Pass the selected diff object through Solid's Show so MonacoDiffViewer receives before/after content.
2026-02-11 12:31:09 +00:00
Shantur Rathore
5bfb09c73b fix(ui): Fix gutter for Monaco 2026-02-11 11:53:27 +00:00
Shantur Rathore
fd499d95e6 fix(ui): truncate right panel paths from start
Use RTL ellipsis with bidi isolation so long paths keep the filename visible.
2026-02-11 11:27:24 +00:00
Shantur Rathore
204b2e020b docs: document i18n conventions for agents 2026-02-11 10:55:57 +00:00
Shantur Rathore
d34e0163e3 fix(ui): keep right panel layout in empty states
Render SplitFilePanel consistently and move empty/loading messages into the viewer area so the right drawer keeps its standard layout even when there are no session diffs, no git changes, or files are still loading.
2026-02-11 10:51:27 +00:00
Shantur Rathore
a93252621a refactor(ui): split prompt input into hooks and API
Extract prompt draft/history, attachments, picker, and keydown logic into co-located hooks. Introduce PromptInputApi for quote/expand/setText and migrate SessionView off DOM poking; remove legacy registerQuoteHandler.
2026-02-11 10:36:28 +00:00
Shantur Rathore
8ce7a9b4ee refactor(ui): modularize instance shell
Split InstanceShell2 into focused shell modules (drawer chrome/resize, session context/cache, sidebar, right panel tabs/components) to improve maintainability while preserving behavior.
2026-02-11 08:16:44 +00:00
Shantur Rathore
63ffb86ea7 fix(ui): raise Workbox precache size limit 2026-02-10 21:50:43 +00:00
Shantur Rathore
bd9a8d9788 feat(ui): add Git Changes tab
Adds repo-wide git changes view with refresh controls and keeps right drawer shortcuts fixed while tabs scroll.
2026-02-10 21:44:08 +00:00
Shantur Rathore
d291c2f074 fix(ui): avoid Monaco overlay dimming on phone 2026-02-10 20:37:41 +00:00
Shantur Rathore
16c2eeca3e feat(ui): improve right panel changes/files layout 2026-02-10 18:31:12 +00:00
Shantur Rathore
d9d281af8c fix(ui): load Monaco basic language tokenizers correctly 2026-02-10 13:53:00 +00:00
Shantur Rathore
56a6364f99 fix(ui): avoid loading missing Monaco _.contribution module 2026-02-10 11:34:10 +00:00
Shantur Rathore
ba20dd6f2f fix(ui): ensure Monaco editor CSS loads 2026-02-10 11:04:16 +00:00
Shantur Rathore
0d96a9f9ff refactor(build): share Monaco public asset copy helper 2026-02-10 10:49:05 +00:00
Shantur Rathore
ee9da95044 fix(electron): always proxy UI dev server for CLI in dev 2026-02-10 10:38:47 +00:00
Shantur Rathore
0511d92cbf fix(electron): start CLI in dev when renderer dev server set 2026-02-10 09:56:29 +00:00
Shantur Rathore
e666ac333c fix(electron): prepare Monaco public assets in dev 2026-02-10 09:29:46 +00:00
Shantur Rathore
8495dcd021 fix(ui): generate Monaco public assets in dev 2026-02-10 00:05:12 +00:00
Shantur Rathore
01ab2f2794 fix(ui): boot Monaco diff workers via workerMain 2026-02-09 23:56:33 +00:00
Shantur Rathore
b59e85abda feat(ui): add Monaco changes/files right drawer viewers
Use OpenCode v2 file APIs for browsing and Monaco DiffEditor for session snapshot diffs, with local baseline language metadata and optional CDN language loading.
2026-02-09 21:00:40 +00:00
Shantur Rathore
4eded9e204 fix(ui): tighten session changes row spacing 2026-02-09 16:24:49 +00:00
Shantur Rathore
90164aa507 fix(ui): remove reasoning header focus ring 2026-02-09 16:23:32 +00:00
Shantur Rathore
f87c83cadd feat(ui): show session changes list in Status tab 2026-02-09 16:21:53 +00:00
Shantur Rathore
01300a81de fix(ui): unify thinking controls with icon buttons 2026-02-09 16:20:33 +00:00
Shantur Rathore
d143faf8eb feat(ui): add right panel Changes/Status tabs 2026-02-09 16:12:46 +00:00
Shantur Rathore
8c29741830 feat(ui): render session changes list in one line
Show each changed file as a single-line row with end-truncated path and right-aligned +additions/-deletions stats for better scanning.
2026-02-09 13:08:42 +00:00
Shantur Rathore
d360089b80 feat(ui): add Session Changes sidebar section
Show session-level file changes in the right drawer with per-file +additions/-deletions and a Show changes button that appears only when diffs exist.
2026-02-09 13:03:44 +00:00
Shantur Rathore
4279b25ff4 feat(ui): hydrate session diffs on open
Fetch session-level diffs when a session is opened and keep them updated via session.diff SSE events so UI state stays in sync with server changes.
2026-02-09 12:02:15 +00:00
Shantur Rathore
0e755b721c fix(ui): exclude routes from service worker cache
Configure Workbox to precache only static UI assets and ignore HTML documents, preventing route responses like / and /login from being served out of cache.
2026-02-09 01:04:15 +00:00
Shantur Rathore
b244d9f98c Min version 0.10.2 2026-02-09 00:58:28 +00:00
Shantur Rathore
9e3dbc5dfb Bump v0.10.2 2026-02-09 00:57:30 +00:00
Shantur Rathore
4cf980fb97 fix(permissions): reply in originating worktree
Track the worktree slug when permissions are enqueued and send permission replies through a worktree-scoped client so x-opencode-directory matches the originating context.
2026-02-09 00:56:20 +00:00
Shantur Rathore
5bde55f8d4 feat(ui): add session status notifications 2026-02-09 00:42:33 +00:00
Shantur Rathore
0d4a4ccad7 fix(ui): expand launch error modal
Let the 'Unable to launch OpenCode' dialog grow up to 80vh and keep only the error output pane scrollable so longer stderr is visible without cramped nested scrolling.
2026-02-08 21:46:36 +00:00
Shantur Rathore
56a0e8aa6e fix(ui): refresh timeline when parts change
Track per-message part count changes and rebuild timeline segments so deletions or streaming updates don't leave stale entries in the message timeline.
2026-02-08 21:32:35 +00:00
Shantur Rathore
2a5bb6304d fix(ui): keep timeline preview tooltip interactive
Allow pointer interaction with the message preview tooltip and delay hover dismissal so users can move from the timeline segment onto the preview to copy or delete.
2026-02-08 21:06:32 +00:00
Shantur Rathore
322a880a02 fix(dev): avoid localhost dual-stack collisions 2026-02-08 20:44:43 +00:00
Shantur Rathore
ded31078d4 fix(opencode-config): tolerate self-signed HTTPS for plugin bridge 2026-02-08 19:45:27 +00:00
Shantur Rathore
dcbe3475ed chore(proxy): trace upstream requests
Log the exact upstream OpenCode target URL, redacted headers, and JSON body (best-effort for streams) when trace logging is enabled.
2026-02-08 17:54:12 +00:00
Shantur Rathore
338a88fb5a feat(server): add HTTPS with self-signed certs
Default to HTTPS with optional loopback HTTP, generate/rotate self-signed certs via node-forge, and surface Local/Remote connection URLs. Update /api/meta schema, UI remote access overlay, and desktop shells to follow the new startup output.
2026-02-08 15:48:00 +00:00
Shantur Rathore
7eb1551e4b Min server 0.10.2 2026-02-07 23:40:14 +00:00
Shantur Rathore
0414f924e6 Bump version to 0.10.1 2026-02-07 23:39:39 +00:00
Shantur Rathore
9456871271 chore(deps): install tauri keepawake api 2026-02-07 22:58:35 +00:00
Shantur Rathore
5b4edef785 feat(desktop): prevent sleep while instances busy 2026-02-07 22:53:46 +00:00
Shantur Rathore
6b81d0d703 fix(ui): keep command picker highlight in sync 2026-02-07 22:38:17 +00:00
Shantur Rathore
4097637169 fix(ui): preserve question custom input on refocus 2026-02-07 22:08:38 +00:00
Shantur Rathore
9bd66e7297 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-07 21:37:50 +00:00
Shantur Rathore
883b0724e0 Merge pull request #121 from jderehag/dev
feat(ui): add PWA support with vite-plugin-pwa
2026-02-07 21:34:29 +00:00
Shantur Rathore
7b6ed88be4 fix(ui): integrate PWA build and avoid api caching
Move PWA config into the default Vite build, ensure the PWA icon source is generated, and restrict Workbox caching to static assets only. Update server UI build wiring and clarify TLS requirements in docs.
2026-02-07 21:33:14 +00:00
Shantur Rathore
e0bb867948 feat(ui): add enter-to-submit toggle for prompt input 2026-02-07 19:18:39 +00:00
Shantur Rathore
ca28f503b7 chore(ui): refine thinking command palette copy 2026-02-07 18:58:23 +00:00
Shantur Rathore
c83028abc2 feat(ui): label root worktree as workspace
Display the root checkout as 'Workspace' in the worktree selector to avoid confusing 'root' terminology.
2026-02-07 16:17:34 +00:00
Shantur Rathore
60406ca8fb feat(ui): show worktree badge in session list
Render a worktree pill on parent sessions using the session status chip styling, with a distinct icon and selection-aware colors.
2026-02-07 16:15:16 +00:00
Shantur Rathore
e878c3c83b fix(instance-events): unwrap payload-only SSE events
Accept OpenCode SSE chunks shaped as { payload: { type, ... } } even when no directory is present, and attach directory when available to avoid dropping heartbeat events as malformed.
2026-02-07 16:00:28 +00:00
Shantur Rathore
bdd3fe8899 fix(worktrees): prune stale worktree mappings
Fall back to root when a mapped worktree slug is missing and persistently remove missing slugs from the worktree map to prevent proxy 404s.
2026-02-07 15:55:35 +00:00
Shantur Rathore
3cfaf689e7 fix(worktrees): disable selector outside git repos
Expose isGitRepo on worktree listing and show Worktree: Unavailable while disabling the dropdown when a workspace folder is not a Git repository.
2026-02-07 15:23:27 +00:00
Shantur Rathore
b41da03e8a feat(worktrees): refine worktree selector UX 2026-02-07 14:57:34 +00:00
Shantur Rathore
ef14b9acb6 worktrees - Implementation 2026-02-07 11:46:56 +00:00
Jesper Derehag
99474955af feat(ui): add PWA support with vite-plugin-pwa
- Add vite.config.pwa.ts extending the base config with VitePWA plugin
- Generate PWA icons at build time from source logo via @vite-pwa/assets-generator
- Add web app manifest with name, theme color, display overrides
- Add Workbox runtime caching: NetworkFirst for API, CacheFirst for assets
- Set navigateFallback to null to preserve server-side auth redirects
- Server build uses build:pwa for PWA-enabled output; Electron/Tauri use
  the base build without PWA

Signed-off-by: Jesper Derehag <jderehag@hotmail.com>
2026-02-07 00:18:28 +01:00
Shantur Rathore
6f73adaef6 feat(ui): move context usage pills to right drawer header 2026-02-06 10:34:44 +00:00
Shantur Rathore
e2ff758003 feat(ui): add toggleable session search in left drawer 2026-02-06 10:25:37 +00:00
Shantur Rathore
748a99c9c4 fix(ui): split left drawer header into two rows 2026-02-06 10:18:12 +00:00
Shantur Rathore
db2d764cce fix(ui): refine instance drawer layout and controls 2026-02-06 10:10:42 +00:00
Shantur Rathore
157fe9d6b4 feat(ui): switch message actions to icon buttons 2026-02-05 23:42:48 +00:00
Shantur Rathore
6c42b64466 feat(ui): copy tool call header title 2026-02-05 23:30:38 +00:00
Shantur Rathore
88605a4617 feat(ui): add copy option for selected text 2026-02-05 23:20:13 +00:00
Shantur Rathore
e8f8e7bd65 fix(ui): avoid trailing blank line after quote insert 2026-02-05 23:17:22 +00:00
Shantur Rathore
750a87ef45 fix(ui): render task steps from child session 2026-02-05 23:08:59 +00:00
Shantur Rathore
8fda9aed71 fix(ui): focus prompt on session activate 2026-02-04 14:20:50 +00:00
Shantur Rathore
7e1dab8384 fix(electron): stop server process tree on quit 2026-02-04 10:28:51 +00:00
Shantur Rathore
5b24f0cd40 fix(ui): tighten question tool layout
Remove the redundant header row, tighten spacing, and square off question cards. Also adjust answered question container styling to match tool call layout.
2026-02-04 00:34:40 +00:00
Shantur Rathore
a6b1f4ba19 fix(ui): improve question tool contrast
Make question tool prompt, labels, and the type pill use primary text color for readability in light mode, and bump the Q header line to text-sm.
2026-02-04 00:20:19 +00:00
Shantur Rathore
df02b7cdca fix(ui): repair question tool styling
Use token-backed surface/background classes for the question tool cards and ensure radio/checkbox inputs use accent-color so the view renders correctly in both light and dark themes.
2026-02-04 00:14:50 +00:00
Shantur Rathore
06b0d03c31 fix(ui): align stop button icon contrast
Use --text-inverted for stop button icon color in dark mode so it matches send button styling, with a safe fallback in CSS.
2026-02-03 22:22:47 +00:00
Shantur Rathore
fd22a5ed9d fix(ui): restore stop button styling
Avoid color-mix for the stop button danger palette so it renders consistently across runtimes; add safe rgba fallbacks for the background colors.
2026-02-03 22:15:03 +00:00
Shantur Rathore
86db407c0b fix(ui): restore tool call colors in dark mode
Use a dedicated --text-on-accent token for accent chips/checkmarks and tweak task list item surfaces so task/todo renderers keep contrast in dark mode.
2026-02-03 22:09:02 +00:00
Shantur Rathore
f1520be777 Bump version to 0.9.5 2026-02-03 22:01:41 +00:00
Shantur Rathore
8a91e04ff9 Bump to v0.9.4 2026-02-03 20:22:17 +00:00
Shantur Rathore
76b1134c95 fix(ui): apply theme before initial render 2026-02-03 20:12:02 +00:00
Shantur Rathore
d98d519fd3 feat(ui): persist theme preference
Persist system/light/dark theme mode in app config and default new installs to system so the UI follows OS theme unless overridden.
2026-02-03 19:42:24 +00:00
Shantur Rathore
02407e0f7a fix(ui): restore dark tab and tool output styling
Use tokenized border contrast so dark mode borders stay subtle, keep instance tab status dots vivid in dark themes, and adjust tool-call code block header background via a dedicated token.
2026-02-03 19:02:47 +00:00
Shantur Rathore
0261154a5e feat(ui): add delete action for message parts 2026-02-03 18:32:54 +00:00
Shantur Rathore
d2b68159be chore(opencode-config): bump @opencode-ai/plugin 2026-02-03 17:37:02 +00:00
Shantur Rathore
aab0692403 fix(ui): tune light mode contrast 2026-02-03 17:37:02 +00:00
Shantur Rathore
17a3e43ac7 feat(ui): add system/light/dark theme toggle
Add a 3-state theme toggle in folder selection and instance tabs, and update tokens/styles so light mode has readable contrast. Sync MUI surfaces and Shiki highlighting to CSS variables to prevent stale colors when switching themes.
2026-02-03 16:49:42 +00:00
Shantur Rathore
a2127a11ac fix(server): include symlink directories in listings
Fixes https://github.com/NeuralNomadsAI/CodeNomad/issues/106
2026-02-03 15:22:49 +00:00
Shantur Rathore
ea4c687125 chore: add MIT License 2026-02-03 15:08:24 +00:00
Shantur Rathore
de20b3adf3 fix(ui): allow collapsing active parent thread 2026-02-03 15:07:05 +00:00
Shantur Rathore
929e79befd chore(license): add MIT license
Clarifies usage and redistribution terms across the monorepo.
2026-02-02 11:22:49 +00:00
Shantur Rathore
3522d3dff5 fix(electron): quit on last window close 2026-01-31 11:24:56 +00:00
Shantur Rathore
1af01680ee feat(ui): add session sidebar search and bulk selection
Adds an optional session filter bar to the left sidebar with title search across parent/child sessions and a scoped Select All. Introduces multi-select checkboxes, bulk delete with clear selection controls, and confirmation dialogs for both single and bulk deletions using the existing alert dialog flow. Updates session i18n strings across supported locales.
2026-01-30 17:34:25 +00:00
Shantur Rathore
67f5f830a3 Bump to v0.9.3 2026-01-29 22:37:34 +00:00
Shantur Rathore
81102cc6bf fix(ui): rename forked session to parent title 2026-01-29 22:34:30 +00:00
Shantur Rathore
afa7243eab feat(server): allow skipping internal auth
Add --dangerously-skip-auth / CODENOMAD_SKIP_AUTH for trusted-perimeter deployments so users behind SSO/VPN don't need a second login.
2026-01-29 20:38:05 +00:00
Shantur Rathore
37b7c1e53c fix(server): enforce workspace directory via x-opencode-directory 2026-01-28 23:41:32 +00:00
Shantur Rathore
ba61ab79e2 fix(tauri): prevent quit deadlock and exit loop 2026-01-28 20:19:57 +00:00
Shantur Rathore
37d075fbb3 fix(tauri): allow tauri.localhost internal navigation 2026-01-28 19:41:39 +00:00
Shantur Rathore
2961d41be3 fix(ui): open external toast links via system browser 2026-01-28 19:24:33 +00:00
Shantur Rathore
1bb5aedfdb chore(ui): widen left sidebar width limits 2026-01-28 18:50:05 +00:00
Shantur Rathore
0a793fb1c6 refactor(ui): consolidate sidebar selector shortcut hints 2026-01-28 18:03:20 +00:00
Shantur Rathore
a401eeec11 fix(ui): stabilize streaming message/tool rendering
Avoid remounting message blocks on part updates so tool call UI state persists. Render tool/message content from store and stabilize tool output scrolling during streaming.
2026-01-28 17:55:44 +00:00
Shantur Rathore
d9bcc66930 Merge pull request #102 from bizzkoot/fix/question-tool-ux-improvements
fix(ui): Improve Question Tool UX (Enter Key & Auto-focus)
2026-01-28 15:50:57 +00:00
bizzkoot
01921e3454 fix(ui): improve question tool UX (enter key & autofocus) 2026-01-28 21:01:49 +08:00
184 changed files with 18927 additions and 3152 deletions

View File

@@ -15,6 +15,35 @@
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
## Multi-Language Support (i18n)
The UI uses a small custom i18n layer (no ICU/messageformat). When building features, never hardcode user-visible strings.
- **Runtime API:** use `useI18n()` in components (`const { t } = useI18n();`) and `tGlobal(...)` in stores/non-component code.
- Implementation: `packages/ui/src/lib/i18n/index.tsx`
- **Where messages live:** `packages/ui/src/lib/i18n/messages/<locale>/` as TypeScript objects (`"flat.dot.keys": "string"`).
- Each locale has an `index.ts` that merges message parts; duplicate keys throw at build time.
- Merge helper: `packages/ui/src/lib/i18n/messages/merge.ts`
- **Adding a new string:** add it to the appropriate `.../messages/en/*.ts` part file, then add the same key to each other locales corresponding file.
- Missing translations fall back to English (and finally to the key), so gaps can be easy to miss.
- **Interpolation:** placeholders are simple `{name}` replacements (word characters only). Avoid placeholders like `{file-name}`.
- **Pluralization:** handle manually via separate keys like `something.one` / `something.other` and choose in code.
- **Adding a new language:** add a new `messages/<locale>/` folder + `index.ts`, register it in `packages/ui/src/lib/i18n/index.tsx`, and add it to the language picker in `packages/ui/src/components/folder-selection-view.tsx`.
- **Locale persistence:** the selected locale is stored in app preferences (`locale`) and persisted via the server config (default `~/.config/codenomad/config.json`).
- **Avoid English-only paths:** do not import `enMessages` directly in feature code; always go through `t(...)` so locale changes apply.
## File Length Guidelines (Highlight Only)
We track file size as a refactoring signal. When you touch or create files, highlight oversized files so the team can plan refactors when time permits.
- Source files: warn after ~500 lines; target limit ~800 lines
- Test files: highlight after ~1000 lines
Behavior for agents:
- Do not refactor solely to satisfy these thresholds.
- When a change touches a file that exceeds the warning/limit, mention it in your final response and include the file path and approximate line count.
- When creating new files, aim to stay under the thresholds unless there's a clear reason.
## Tooling Preferences
- Use the `edit` tool for modifying existing files; prefer it over other editing methods.
- Use the `write` tool only when creating new files from scratch.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Neural Nomads
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

4704
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
{
"name": "codenomad-workspace",
"version": "0.9.2",
"version": "0.10.3",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",
"workspaces": {
"packages": [
"packages/server",

View File

@@ -1,6 +1,7 @@
{
"name": "@codenomad/ui-host-worker",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"build:manifest": "node ./scripts/build-manifest.mjs",

View File

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

View File

@@ -1,6 +1,7 @@
import { defineConfig, externalizeDepsPlugin } from "electron-vite"
import solid from "vite-plugin-solid"
import { resolve } from "path"
import { copyMonacoPublicAssets } from "../ui/scripts/monaco-public-assets.js"
const uiRoot = resolve(__dirname, "../ui")
const uiSrc = resolve(uiRoot, "src")
@@ -8,6 +9,32 @@ const uiRendererRoot = resolve(uiRoot, "src/renderer")
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
function prepareMonacoPublicAssets() {
return {
name: "prepare-monaco-public-assets",
configureServer(server: any) {
copyMonacoPublicAssets({
uiRendererRoot: uiRendererRoot,
warn: (msg: string) => server.config.logger.warn(msg),
sourceRoots: [
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
],
})
},
buildStart(this: any) {
copyMonacoPublicAssets({
uiRendererRoot: uiRendererRoot,
warn: (msg: string) => this.warn(msg),
sourceRoots: [
resolve(__dirname, "../../node_modules/monaco-editor/min/vs"),
resolve(uiRoot, "node_modules/monaco-editor/min/vs"),
],
})
},
}
}
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
@@ -40,7 +67,7 @@ export default defineConfig({
},
renderer: {
root: uiRendererRoot,
plugins: [solid()],
plugins: [solid(), prepareMonacoPublicAssets()],
css: {
postcss: resolve(uiRoot, "postcss.config.js"),
},

View File

@@ -1,6 +1,8 @@
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import type { CliProcessManager, CliStatus } from "./process-manager"
let wakeLockId: number | null = null
interface DialogOpenRequest {
mode: "directory" | "file"
title?: string
@@ -62,4 +64,50 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { canceled: result.canceled, paths: result.filePaths }
})
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
const next = Boolean(enabled)
if (next) {
if (wakeLockId !== null && powerSaveBlocker.isStarted(wakeLockId)) {
return { enabled: true }
}
try {
wakeLockId = powerSaveBlocker.start("prevent-display-sleep")
} catch {
wakeLockId = null
return { enabled: false }
}
return { enabled: true }
}
if (wakeLockId !== null) {
try {
if (powerSaveBlocker.isStarted(wakeLockId)) {
powerSaveBlocker.stop(wakeLockId)
}
} finally {
wakeLockId = null
}
}
return { enabled: false }
})
ipcMain.handle(
"notifications:show",
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
if (!Notification.isSupported()) {
return { ok: false, reason: "unsupported" }
}
const title = typeof payload?.title === "string" ? payload.title : "CodeNomad"
const body = typeof payload?.body === "string" ? payload.body : ""
try {
const notification = new Notification({ title, body })
notification.show()
return { ok: true }
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
}
},
)
}

View File

@@ -399,7 +399,11 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
async function startCli() {
try {
const devMode = process.env.NODE_ENV === "development"
// In desktop dev workflows we always want the CLI to run in dev mode so it:
// - uses plain HTTP
// - proxies UI requests to the renderer dev server
// Monaco's AMD assets are served from that dev server.
const devMode = !app.isPackaged
console.info("[cli] start requested (dev mode:", devMode, ")")
await cliManager.start({ dev: devMode })
} catch (error) {
@@ -473,6 +477,14 @@ if (isMac) {
}
app.whenReady().then(() => {
// Required for Windows notifications / taskbar grouping.
// Keep in sync with desktop app identifier.
try {
app.setAppUserModelId("ai.neuralnomads.codenomad.client")
} catch {
// ignore
}
startCli()
if (isMac) {
@@ -505,7 +517,6 @@ app.on("before-quit", async (event) => {
})
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit()
}
// CodeNomad supports a single window; closing it should quit the app on all platforms.
app.quit()
})

View File

@@ -1,4 +1,4 @@
import { spawn, type ChildProcess } from "child_process"
import { spawn, spawnSync, type ChildProcess } from "child_process"
import { app } from "electron"
import { createRequire } from "module"
import { EventEmitter } from "events"
@@ -82,6 +82,7 @@ export class CliProcessManager extends EventEmitter {
private stdoutBuffer = ""
private stderrBuffer = ""
private bootstrapToken: string | null = null
private requestedStop = false
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
@@ -91,6 +92,7 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.bootstrapToken = null
this.requestedStop = false
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
@@ -109,11 +111,13 @@ export class CliProcessManager extends EventEmitter {
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32"
const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
detached,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
@@ -175,12 +179,89 @@ export class CliProcessManager extends EventEmitter {
return
}
this.requestedStop = true
const pid = child.pid
if (!pid) {
this.child = undefined
this.updateStatus({ state: "stopped" })
return
}
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
try {
// Negative PID targets the process group (POSIX).
process.kill(-pid, signal)
return true
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "ESRCH") {
return true
}
return false
}
}
const tryKillSinglePid = (signal: NodeJS.Signals) => {
try {
process.kill(pid, signal)
return true
} catch (error) {
const err = error as NodeJS.ErrnoException
if (err?.code === "ESRCH") {
return true
}
return false
}
}
const tryTaskkill = (force: boolean) => {
const args = ["/PID", String(pid), "/T"]
if (force) {
args.push("/F")
}
try {
const result = spawnSync("taskkill", args, { encoding: "utf8" })
const exitCode = result.status
if (exitCode === 0) {
return true
}
// If the PID is already gone, treat it as success.
const stderr = (result.stderr ?? "").toString().toLowerCase()
const stdout = (result.stdout ?? "").toString().toLowerCase()
const combined = `${stdout}\n${stderr}`
if (combined.includes("not found") || combined.includes("no running instance")) {
return true
}
return false
} catch {
return false
}
}
const sendStopSignal = (signal: NodeJS.Signals) => {
if (process.platform === "win32") {
tryTaskkill(signal === "SIGKILL")
return
}
// Prefer process-group signaling so wrapper launchers (shell/tsx) don't outlive Electron.
const groupOk = tryKillPosixGroup(signal)
if (!groupOk) {
tryKillSinglePid(signal)
}
}
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
console.warn(
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
)
child.kill("SIGKILL")
sendStopSignal("SIGKILL")
}, 30000)
child.on("exit", () => {
@@ -191,7 +272,15 @@ export class CliProcessManager extends EventEmitter {
resolve()
})
child.kill("SIGTERM")
if (isAlreadyExited()) {
clearTimeout(killTimeout)
this.child = undefined
this.updateStatus({ state: "stopped" })
resolve()
return
}
sendStopSignal("SIGTERM")
})
}
@@ -205,7 +294,16 @@ export class CliProcessManager extends EventEmitter {
private handleTimeout() {
if (this.child) {
this.child.kill("SIGKILL")
const pid = this.child.pid
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGKILL")
} catch {
this.child.kill("SIGKILL")
}
} else {
this.child.kill("SIGKILL")
}
this.child = undefined
}
this.updateStatus({ state: "error", error: "CLI did not start in time" })
@@ -249,38 +347,27 @@ export class CliProcessManager extends EventEmitter {
console.info(`[cli][${stream}] ${trimmed}`)
this.emit("log", { stream, message: trimmed })
const port = this.extractPort(trimmed)
if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`)
this.updateStatus({ state: "ready", port, url })
const localUrl = this.extractLocalUrl(trimmed)
if (localUrl && this.status.state === "starting") {
let port: number | undefined
try {
port = Number(new URL(localUrl).port) || undefined
} catch {
port = undefined
}
console.info(`[cli] ready on ${localUrl}`)
this.updateStatus({ state: "ready", port, url: localUrl })
this.emit("ready", this.status)
}
}
}
private extractPort(line: string): number | null {
const readyMatch = line.match(/CodeNomad Server is ready at http:\/\/[^:]+:(\d+)/i)
if (readyMatch) {
return parseInt(readyMatch[1], 10)
private extractLocalUrl(line: string): string | null {
const match = line.match(/^Local\s+Connection\s+URL\s*:\s*(https?:\/\/\S+)\s*$/i)
if (!match) {
return null
}
if (line.toLowerCase().includes("http server listening")) {
const httpMatch = line.match(/:(\d{2,5})(?!.*:\d)/)
if (httpMatch) {
return parseInt(httpMatch[1], 10)
}
try {
const parsed = JSON.parse(line)
if (typeof parsed.port === "number") {
return parsed.port
}
} catch {
// not JSON, ignore
}
}
return null
return match[1] ?? null
}
private updateStatus(patch: Partial<CliStatus>) {
@@ -289,10 +376,22 @@ export class CliProcessManager extends EventEmitter {
}
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--port", "0", "--generate-token"]
const args = ["serve", "--host", host, "--generate-token"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
// Dev: run plain HTTP + Vite dev server proxy.
args.push("--https", "false", "--http", "true")
// Avoid collisions with an already-running server (and dual-stack ::/0.0.0.0 quirks)
// by forcing an ephemeral port in dev.
args.push("--http-port", "0")
} else {
// Prod desktop: always keep loopback HTTP enabled.
args.push("--https", "true", "--http", "true")
}
if (options.dev) {
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")
}
return args

View File

@@ -12,6 +12,8 @@ const electronAPI = {
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -1,7 +1,8 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.9.2",
"version": "0.10.3",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"

View File

@@ -2,7 +2,8 @@
"name": "@codenomad/opencode-config",
"version": "0.5.0",
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.1.36"
"@opencode-ai/plugin": "1.1.53"
}
}
}

View File

@@ -1,3 +1,7 @@
import http from "http"
import https from "https"
import { Readable } from "stream"
export type PluginEvent = {
type: string
properties?: Record<string, unknown>
@@ -16,7 +20,8 @@ export function getCodeNomadConfig(): CodeNomadConfig {
}
export function createCodeNomadRequester(config: CodeNomadConfig) {
const baseUrl = config.baseUrl.replace(/\/+$/, "")
const rawBaseUrl = (config.baseUrl ?? "").trim()
const baseUrl = rawBaseUrl.replace(/\/+$/, "")
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
const authorization = buildInstanceAuthorizationHeader()
@@ -42,10 +47,10 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
const hasBody = init?.body !== undefined
const headers = buildHeaders(init?.headers, hasBody)
return fetch(url, {
...init,
headers,
})
// The CodeNomad plugin only talks to the local CodeNomad server.
// Use a single request implementation that tolerates custom/self-signed certs
// without disabling TLS verification for the whole Node process.
return nodeFetch(url, { ...init, headers }, { rejectUnauthorized: false })
}
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
@@ -87,6 +92,91 @@ export function createCodeNomadRequester(config: CodeNomadConfig) {
}
}
async function nodeFetch(
url: string,
init: RequestInit & { headers?: Record<string, string> },
tls: { rejectUnauthorized: boolean },
): Promise<Response> {
const parsed = new URL(url)
const isHttps = parsed.protocol === "https:"
const requestFn = isHttps ? https.request : http.request
const method = (init.method ?? "GET").toUpperCase()
const headers = init.headers ?? {}
const body = init.body
return await new Promise<Response>((resolve, reject) => {
const req = requestFn(
{
protocol: parsed.protocol,
hostname: parsed.hostname,
port: parsed.port ? Number(parsed.port) : undefined,
path: `${parsed.pathname}${parsed.search}`,
method,
headers,
...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}),
},
(res) => {
const responseHeaders = new Headers()
for (const [key, value] of Object.entries(res.headers)) {
if (value === undefined) continue
if (Array.isArray(value)) {
responseHeaders.set(key, value.join(", "))
} else {
responseHeaders.set(key, String(value))
}
}
// Convert Node stream -> Web ReadableStream for Response.
const webBody = Readable.toWeb(res) as unknown as ReadableStream<Uint8Array>
resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders }))
},
)
const signal = init.signal
const abort = () => {
const err = new Error("Request aborted")
;(err as any).name = "AbortError"
req.destroy(err)
reject(err)
}
if (signal) {
if (signal.aborted) {
abort()
return
}
signal.addEventListener("abort", abort, { once: true })
req.once("close", () => signal.removeEventListener("abort", abort))
}
req.once("error", reject)
if (body === undefined || body === null) {
req.end()
return
}
if (typeof body === "string") {
req.end(body)
return
}
if (body instanceof Uint8Array) {
req.end(Buffer.from(body))
return
}
if (body instanceof ArrayBuffer) {
req.end(Buffer.from(new Uint8Array(body)))
return
}
// Fallback for less common BodyInit types.
req.end(String(body))
})
}
function requireEnv(key: string): string {
const value = process.env[key]
if (!value || !value.trim()) {

View File

@@ -31,6 +31,11 @@ You can run CodeNomad directly without installing it:
npx @neuralnomads/codenomad --launch
```
On startup, CodeNomad prints two URLs:
- `Local Connection URL : ...` (used by desktop shells)
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
### Install Globally
Or install it globally to use the `codenomad` command:
@@ -44,15 +49,78 @@ You can configure the server using flags or environment variables:
| Flag | Env Variable | Description |
|------|--------------|-------------|
| `--port <number>` | `CLI_PORT` | HTTP port (default 9898) |
| `--https <enabled>` | `CLI_HTTPS` | Enable HTTPS listener (default `true`) |
| `--http <enabled>` | `CLI_HTTP` | Enable HTTP listener (default `false`) |
| `--https-port <number>` | `CLI_HTTPS_PORT` | HTTPS port (default `9898`, use `0` for auto) |
| `--http-port <number>` | `CLI_HTTP_PORT` | HTTP port (default `9899`, use `0` for auto) |
| `--tls-key <path>` | `CLI_TLS_KEY` | TLS private key (PEM). Requires `--tls-cert`. |
| `--tls-cert <path>` | `CLI_TLS_CERT` | TLS certificate (PEM). Requires `--tls-key`. |
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces |
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
| `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
### HTTP vs HTTPS
- Default: `--https=true --http=false` (HTTPS only).
- To run plain HTTP only (useful for development):
```sh
codenomad --https=false --http=true
```
- To run both HTTPS (for remote) and HTTP loopback (for desktop):
```sh
codenomad --https=true --http=true
```
### Remote Access Binding Rules
- When remote access is enabled (bind host is non-loopback, e.g. `--host 0.0.0.0`):
- HTTP listens on `127.0.0.1` only.
- HTTPS listens on `--host` (LAN/all interfaces).
- When remote access is disabled (bind host is loopback, e.g. `--host 127.0.0.1`):
- Both HTTP and HTTPS listen on `127.0.0.1`.
### Self-Signed Certificates
If `--https=true` and you do not provide `--tls-key/--tls-cert`, CodeNomad generates a local certificate automatically under your config directory:
- `~/.config/codenomad/tls/ca-cert.pem`
- `~/.config/codenomad/tls/server-cert.pem`
Certificates are valid for about 30 days and rotate automatically on startup when needed. You can add extra SANs via:
```sh
codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
```
### Authentication
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
### Progressive Web App (PWA)
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
2. Click the install icon in the address bar, or use the browser menu → "Install CodeNomad".
3. The app will open in a standalone window and appear in your OS app list.
> **TLS requirement**
> Browsers require a secure (`https://`) connection for PWA installation.
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
### Data Storage
- **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

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

View File

@@ -1,7 +1,8 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.9.2",
"version": "0.10.3",
"description": "CodeNomad Server",
"license": "MIT",
"author": {
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"
@@ -20,7 +21,7 @@
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"dev": "cross-env CODENOMAD_DEV=1 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 CLI_HTTPS=false CLI_HTTP=true tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
@@ -30,12 +31,14 @@
"commander": "^12.1.0",
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yauzl": "^2.10.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node-forge": "^1.3.14",
"@types/yauzl": "^2.10.0",
"cross-env": "^7.0.3",
"ts-node": "^10.9.2",

View File

@@ -50,6 +50,38 @@ export interface WorkspaceDeleteResponse {
status: WorkspaceStatus
}
export type WorktreeKind = "root" | "worktree"
export interface WorktreeDescriptor {
/** Stable identifier used by CodeNomad + clients ("root" for repo root). */
slug: string
/** Absolute directory path on the server host. */
directory: string
kind: WorktreeKind
/** Optional VCS branch name when available. */
branch?: string
}
export interface WorktreeListResponse {
worktrees: WorktreeDescriptor[]
/** True when the workspace folder resolves to a Git repository. */
isGitRepo?: boolean
}
export interface WorktreeCreateRequest {
slug: string
/** Optional branch name (defaults to slug). */
branch?: string
}
export interface WorktreeMap {
version: 1
/** Default worktree to use for new sessions and as fallback. */
defaultWorktreeSlug: string
/** Mapping of *parent* session IDs to a worktree slug. */
parentSessionWorktreeSlug: Record<string, string>
}
export type LogLevel = "debug" | "info" | "warn" | "error"
export interface WorkspaceLogEntry {
@@ -204,7 +236,8 @@ export interface NetworkAddress {
ip: string
family: "ipv4" | "ipv6"
scope: "external" | "internal" | "loopback"
url: string
/** Remote URL using the server's remote protocol/port for this IP. */
remoteUrl: string
}
export interface LatestReleaseInfo {
@@ -230,16 +263,20 @@ export interface SupportMeta {
}
export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */
httpBaseUrl: string
/** URL desktop apps should use to connect (prefers loopback HTTP when enabled). */
localUrl: string
/** URL remote clients should use (prefers HTTPS when enabled). */
remoteUrl?: string
/** SSE endpoint advertised to clients (`/api/events` by default). */
eventsUrl: string
/** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */
host: string
/** Listening mode derived from host binding. */
listeningMode: "local" | "all"
/** Actual port in use after binding. */
port: number
/** Actual local port in use after binding. */
localPort: number
/** Actual remote port in use after binding (when remoteUrl is set). */
remotePort?: number
/** Display label for the host (e.g., hostname or friendly name). */
hostLabel: string
/** Absolute path of the filesystem root exposed to clients. */

View File

@@ -15,15 +15,25 @@ export interface AuthManagerInit {
username: string
password?: string
generateToken: boolean
dangerouslySkipAuth?: boolean
}
export class AuthManager {
private readonly authStore: AuthStore
private readonly authStore: AuthStore | null
private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
private readonly authEnabled: boolean
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
if (!this.authEnabled) {
this.authStore = null
this.tokenManager = null
return
}
const authFilePath = resolveAuthFilePath(init.configPath)
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
@@ -37,6 +47,10 @@ export class AuthManager {
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
}
isAuthEnabled(): boolean {
return this.authEnabled
}
getCookieName(): string {
return this.cookieName
}
@@ -56,19 +70,31 @@ export class AuthManager {
}
validateLogin(username: string, password: string): boolean {
return this.authStore.validateCredentials(username, password)
if (!this.authEnabled) {
return true
}
return this.requireAuthStore().validateCredentials(username, password)
}
createSession(username: string) {
if (!this.authEnabled) {
return { id: "auth-disabled", createdAt: Date.now(), username: this.init.username }
}
return this.sessionManager.createSession(username)
}
getStatus() {
return this.authStore.getStatus()
if (!this.authEnabled) {
return { username: this.init.username, passwordUserProvided: false }
}
return this.requireAuthStore().getStatus()
}
setPassword(password: string) {
return this.authStore.setPassword({ password, markUserProvided: true })
if (!this.authEnabled) {
throw new Error("Internal authentication is disabled")
}
return this.requireAuthStore().setPassword({ password, markUserProvided: true })
}
isLoopbackRequest(request: FastifyRequest): boolean {
@@ -76,6 +102,12 @@ export class AuthManager {
}
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
if (!this.authEnabled) {
// When auth is disabled, treat all requests as authenticated.
// We still return a stable username so callers can display it.
return { username: this.init.username, sessionId: "auth-disabled" }
}
const cookies = parseCookies(request.headers.cookie)
const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId)
@@ -87,9 +119,24 @@ export class AuthManager {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
}
setSessionCookieWithOptions(reply: FastifyReply, sessionId: string, options?: { secure?: boolean }) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId, options))
}
clearSessionCookie(reply: FastifyReply) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
}
clearSessionCookieWithOptions(reply: FastifyReply, options?: { secure?: boolean }) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0, ...options }))
}
private requireAuthStore(): AuthStore {
if (!this.authStore) {
throw new Error("Auth store is unavailable")
}
return this.authStore
}
}
function resolveAuthFilePath(configPath: string) {
@@ -104,8 +151,11 @@ function resolvePath(filePath: string) {
return path.resolve(filePath)
}
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number; secure?: boolean }) {
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
if (options?.secure) {
parts.push("Secure")
}
if (options?.maxAgeSeconds !== undefined) {
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
}

View File

@@ -12,6 +12,7 @@ const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true),
promptSubmitOnEnter: z.boolean().default(false),
lastUsedBinary: z.string().optional(),
locale: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
@@ -24,6 +25,12 @@ const PreferencesSchema = z.object({
showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"),
// OS notifications
osNotificationsEnabled: z.boolean().default(false),
osNotificationsAllowWhenVisible: z.boolean().default(false),
notifyOnNeedsInput: z.boolean().default(true),
notifyOnIdle: z.boolean().default(true),
})
const RecentFolderSchema = z.object({

View File

@@ -222,20 +222,18 @@ export class FileSystemBrowser {
const results: FileSystemEntry[] = []
for (const entry of dirents) {
if (!options.includeFiles && !entry.isDirectory()) {
continue
}
const absoluteEntryPath = path.join(directory, entry.name)
let stats: fs.Stats
try {
// Use fs.statSync (not Dirent.isDirectory) so symlinks to directories
// are treated as directories in directory-only listings.
stats = fs.statSync(absoluteEntryPath)
} catch {
// Skip entries we cannot stat (insufficient permissions, etc.)
continue
}
const isDirectory = entry.isDirectory()
const isDirectory = stats.isDirectory()
if (!options.includeFiles && !isDirectory) {
continue
}

View File

@@ -19,6 +19,8 @@ import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses"
const require = createRequire(import.meta.url)
@@ -28,8 +30,15 @@ const __dirname = path.dirname(__filename)
const DEFAULT_UI_STATIC_DIR = path.resolve(__dirname, "../public")
interface CliOptions {
port: number
host: string
https: boolean
http: boolean
httpsPort: number
httpPort: number
tlsKeyPath?: string
tlsCertPath?: string
tlsCaPath?: string
tlsSANs?: string
rootDir: string
configPath: string
unrestrictedRoot: boolean
@@ -44,11 +53,13 @@ interface CliOptions {
authUsername: string
authPassword?: string
generateToken: boolean
dangerouslySkipAuth: boolean
}
const DEFAULT_PORT = 9898
const DEFAULT_HOST = "127.0.0.1"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
const DEFAULT_HTTPS_PORT = 9898
const DEFAULT_HTTP_PORT = 9899
function parseCliOptions(argv: string[]): CliOptions {
const program = new Command()
@@ -56,7 +67,14 @@ function parseCliOptions(argv: string[]): CliOptions {
.description("CodeNomad CLI server")
.version(packageJson.version, "-v, --version", "Show the CLI version")
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
.addOption(new Option("--https <enabled>", "Enable HTTPS listener (true|false)").env("CLI_HTTPS").default("true"))
.addOption(new Option("--http <enabled>", "Enable HTTP listener (true|false)").env("CLI_HTTP").default("false"))
.addOption(new Option("--https-port <number>", "HTTPS port (0 for auto)").env("CLI_HTTPS_PORT").default(DEFAULT_HTTPS_PORT).argParser(parsePort))
.addOption(new Option("--http-port <number>", "HTTP port (0 for auto)").env("CLI_HTTP_PORT").default(DEFAULT_HTTP_PORT).argParser(parsePort))
.addOption(new Option("--tls-key <path>", "TLS private key (PEM)").env("CLI_TLS_KEY"))
.addOption(new Option("--tls-cert <path>", "TLS certificate (PEM)").env("CLI_TLS_CERT"))
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
.addOption(
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
)
@@ -84,11 +102,26 @@ function parseCliOptions(argv: string[]): CliOptions {
.env("CODENOMAD_GENERATE_TOKEN")
.default(false),
)
.addOption(
new Option(
"--dangerously-skip-auth",
"Disable CodeNomad's internal auth. Use only behind a trusted perimeter (SSO/VPN/etc).",
)
.env("CODENOMAD_SKIP_AUTH")
.default(false),
)
program.parse(argv, { from: "user" })
const parsed = program.opts<{
host: string
port: number
https?: string
http?: string
httpsPort: number
httpPort: number
tlsKey?: string
tlsCert?: string
tlsCa?: string
tlsSANs?: string
workspaceRoot?: string
root?: string
unrestrictedRoot?: boolean
@@ -104,8 +137,14 @@ function parseCliOptions(argv: string[]): CliOptions {
username: string
password?: string
generateToken?: boolean
dangerouslySkipAuth?: boolean
}>()
const parseBooleanEnv = (value: string | undefined): boolean => {
const normalized = (value ?? "").trim().toLowerCase()
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "y" || normalized === "on"
}
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host)
@@ -113,9 +152,23 @@ function parseCliOptions(argv: string[]): CliOptions {
const autoUpdateString = (parsed.uiAutoUpdate ?? "true").trim().toLowerCase()
const uiAutoUpdate = autoUpdateString === "1" || autoUpdateString === "true" || autoUpdateString === "yes"
const httpsEnabled = parseBooleanEnv(parsed.https)
const httpEnabled = parseBooleanEnv(parsed.http)
if (!httpsEnabled && !httpEnabled) {
throw new InvalidArgumentError("At least one listener must be enabled (--https or --http)")
}
return {
port: parsed.port,
host: normalizedHost,
https: httpsEnabled,
http: httpEnabled,
httpsPort: parsed.httpsPort,
httpPort: parsed.httpPort,
tlsKeyPath: parsed.tlsKey,
tlsCertPath: parsed.tlsCert,
tlsCaPath: parsed.tlsCa,
tlsSANs: parsed.tlsSANs,
rootDir: resolvedRoot,
configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
@@ -130,6 +183,7 @@ function parseCliOptions(argv: string[]): CliOptions {
authUsername: parsed.username,
authPassword: parsed.password,
generateToken: Boolean(parsed.generateToken),
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
}
}
@@ -156,6 +210,13 @@ function resolveHost(input: string | undefined): string {
return trimmed
}
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function programHasArg(argv: string[], flag: string): boolean {
return argv.includes(flag)
}
@@ -174,16 +235,30 @@ async function main() {
logger.info({ options: logOptions }, "Starting CodeNomad CLI server")
if (options.dangerouslySkipAuth) {
logger.warn(
"DANGEROUS: internal authentication is disabled (--dangerously-skip-auth / CODENOMAD_SKIP_AUTH).",
)
}
const eventBus = new EventBus(eventLogger)
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const configDir = path.dirname(resolvePath(options.configPath))
if ((options.tlsKeyPath && !options.tlsCertPath) || (!options.tlsKeyPath && options.tlsCertPath)) {
throw new InvalidArgumentError("--tls-key and --tls-cert must be provided together")
}
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
localUrl: "http://localhost:0",
remoteUrl: undefined,
eventsUrl: `/api/events`,
host: options.host,
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
port: options.port,
localPort: 0,
remotePort: undefined,
hostLabel: options.host,
workspaceRoot: options.rootDir,
addresses: [],
@@ -195,17 +270,31 @@ async function main() {
username: options.authUsername,
password: options.authPassword,
generateToken: options.generateToken,
dangerouslySkipAuth: options.dangerouslySkipAuth,
},
logger.child({ component: "auth" }),
)
if (options.generateToken) {
if (options.generateToken && !options.dangerouslySkipAuth) {
const token = authManager.issueBootstrapToken()
if (token) {
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
}
}
const tlsResolution = resolveHttpsOptions({
enabled: options.https,
configDir,
host: options.host,
tlsKeyPath: options.tlsKeyPath,
tlsCertPath: options.tlsCertPath,
tlsCaPath: options.tlsCaPath,
tlsSANs: options.tlsSANs,
logger: logger.child({ component: "tls" }),
})
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({
@@ -214,7 +303,8 @@ async function main() {
binaryRegistry,
eventBus,
logger: workspaceLogger,
getServerBaseUrl: () => serverMeta.httpBaseUrl,
getServerBaseUrl: () => serverMeta.localUrl,
nodeExtraCaCertsPath,
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
@@ -254,28 +344,125 @@ async function main() {
minServerVersion: uiResolution.minServerVersion,
}
const server = createHttpServer({
host: options.host,
port: options.port,
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
})
if (uiResolution.uiDevServerUrl && options.https) {
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
}
const startInfo = await server.start()
logger.info({ port: startInfo.port, host: options.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${startInfo.url}`)
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
const httpsBindPort = httpsPortExplicit ? options.httpsPort : 0
const httpBindPort = httpPortExplicit ? options.httpPort : 0
// Listener binding rules:
// - Remote access enabled: HTTP listens on loopback, HTTPS on all IPs (host=0.0.0.0 / LAN IP).
// - Remote access disabled: both listen on loopback.
// - HTTP-only mode: respect --host (used for dev/testing).
const httpsBindHost = remoteAccessEnabled ? options.host : "127.0.0.1"
const httpBindHost = options.http ? (options.https ? "127.0.0.1" : options.host) : "127.0.0.1"
const servers: Array<ReturnType<typeof createHttpServer>> = []
const httpServer = options.http
? createHttpServer({
bindHost: httpBindHost,
bindPort: httpBindPort,
defaultPort: options.httpPort,
protocol: "http",
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
logger,
})
: null
const httpsServer = options.https
? createHttpServer({
bindHost: httpsBindHost,
bindPort: httpsBindPort,
defaultPort: options.httpsPort,
protocol: "https",
httpsOptions: tlsResolution?.httpsOptions,
workspaceManager,
configStore,
binaryRegistry,
fileSystemBrowser,
eventBus,
serverMeta,
instanceStore,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,
logger,
})
: null
if (httpServer) servers.push(httpServer)
if (httpsServer) servers.push(httpsServer)
const [httpStart, httpsStart] = await Promise.all([
httpServer ? httpServer.start() : Promise.resolve(null),
httpsServer ? httpsServer.start() : Promise.resolve(null),
])
const localStart = httpStart ?? httpsStart
if (!localStart) {
throw new Error("No listeners started")
}
const remoteStart = httpsStart ?? httpStart
const localProtocol: "http" | "https" = httpStart ? "http" : "https"
const remoteProtocol: "http" | "https" = httpsStart ? "https" : "http"
// Use an explicit IPv4 loopback address for the "local" URL.
// On macOS, `localhost` often resolves to ::1 first, and it is possible to have
// another instance bound on IPv6 while this instance binds IPv4 (or vice versa),
// which can lead clients to talk to the wrong process.
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
let remoteUrl: string | undefined
if (remoteStart) {
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
let remoteHost = options.host
if (wantsAll) {
if (options.host === "0.0.0.0") {
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
}
} else {
remoteHost = "localhost"
}
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
}
serverMeta.localUrl = localUrl
serverMeta.localPort = localStart.port
serverMeta.remoteUrl = remoteUrl
serverMeta.remotePort = remoteStart?.port
serverMeta.host = options.host
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
if (serverMeta.remotePort && remoteUrl) {
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
} else {
serverMeta.addresses = []
}
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
if (serverMeta.remoteUrl) {
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
}
if (options.launch) {
await launchInBrowser(startInfo.url, logger.child({ component: "launcher" }))
await launchInBrowser(serverMeta.localUrl, logger.child({ component: "launcher" }))
}
let shuttingDown = false
@@ -305,8 +492,8 @@ async function main() {
const shutdownHttp = (async () => {
try {
await server.stop()
logger.info("HTTP server stopped")
await Promise.allSettled(servers.map((srv) => srv.stop()))
logger.info("HTTP server(s) stopped")
} catch (error) {
logger.error({ err: error }, "Failed to stop HTTP server")
}

View File

@@ -7,6 +7,7 @@ import path from "path"
import { fetch } from "undici"
import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
@@ -20,6 +21,7 @@ import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
@@ -28,8 +30,12 @@ import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
interface HttpServerDeps {
host: string
port: number
bindHost: string
bindPort: number
/** When bindPort is 0, try this first. */
defaultPort: number
protocol: "http" | "https"
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
workspaceManager: WorkspaceManager
configStore: ConfigStore
binaryRegistry: BinaryRegistry
@@ -49,10 +55,15 @@ interface HttpServerStartResult {
displayHost: string
}
const DEFAULT_HTTP_PORT = 9898
export function createHttpServer(deps: HttpServerDeps) {
const app = Fastify({ logger: false })
// Fastify's type-level RawServer inference gets noisy when toggling HTTP vs HTTPS.
// We keep the runtime behavior correct and cast the instance to a generic FastifyInstance.
const app = Fastify(
({
logger: false,
...(deps.protocol === "https" && deps.httpsOptions ? { https: deps.httpsOptions } : {}),
} as unknown) as any,
) as unknown as FastifyInstance
const proxyLogger = deps.logger.child({ component: "proxy" })
const apiLogger = deps.logger.child({ component: "http" })
const sseLogger = deps.logger.child({ component: "sse" })
@@ -95,6 +106,27 @@ export function createHttpServer(deps: HttpServerDeps) {
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
const getSelfOrigins = (): Set<string> => {
const origins = new Set<string>()
const candidates: Array<string | undefined> = [deps.serverMeta.localUrl, deps.serverMeta.remoteUrl]
for (const candidate of candidates) {
if (!candidate) continue
try {
origins.add(new URL(candidate).origin)
} catch {
// ignore
}
}
for (const addr of deps.serverMeta.addresses ?? []) {
try {
origins.add(new URL(addr.remoteUrl).origin)
} catch {
// ignore
}
}
return origins
}
app.register(cors, {
origin: (origin, cb) => {
if (!origin) {
@@ -102,14 +134,8 @@ export function createHttpServer(deps: HttpServerDeps) {
return
}
let selfOrigin: string | null = null
try {
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
} catch {
selfOrigin = null
}
if (selfOrigin && origin === selfOrigin) {
const selfOrigins = getSelfOrigins()
if (selfOrigins.has(origin)) {
cb(null, true)
return
}
@@ -120,7 +146,7 @@ export function createHttpServer(deps: HttpServerDeps) {
}
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
if (deps.bindHost === "0.0.0.0" || !isLoopbackHost(deps.bindHost)) {
cb(null, true)
return
}
@@ -222,6 +248,7 @@ export function createHttpServer(deps: HttpServerDeps) {
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
registerStorageRoutes(app, {
instanceStore: deps.instanceStore,
eventBus: deps.eventBus,
@@ -242,12 +269,12 @@ export function createHttpServer(deps: HttpServerDeps) {
instance: app,
start: async (): Promise<HttpServerStartResult> => {
const attemptListen = async (requestedPort: number) => {
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
const addressInfo = await app.listen({ port: requestedPort, host: deps.bindHost })
return { addressInfo, requestedPort }
}
const autoPortRequested = deps.port === 0
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
const autoPortRequested = deps.bindPort === 0
const primaryPort = autoPortRequested ? deps.defaultPort : deps.bindPort
const shouldRetryWithEphemeral = (error: unknown) => {
if (!autoPortRequested) return false
@@ -283,15 +310,10 @@ export function createHttpServer(deps: HttpServerDeps) {
}
}
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
const serverUrl = `http://${displayHost}:${actualPort}`
const displayHost = deps.bindHost === "127.0.0.1" ? "localhost" : deps.bindHost
const serverUrl = `${deps.protocol}://${displayHost}:${actualPort}`
deps.serverMeta.httpBaseUrl = serverUrl
deps.serverMeta.host = deps.host
deps.serverMeta.port = actualPort
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${serverUrl}`)
deps.logger.info({ port: actualPort, host: deps.bindHost, protocol: deps.protocol }, "HTTP server listening")
return { port: actualPort, url: serverUrl, displayHost }
},
@@ -312,31 +334,36 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
instance.removeAllContentTypeParsers()
instance.addContentTypeParser("*", (req, body, done) => done(null, body))
const proxyBaseHandler = async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
pathSuffix: "",
logger: deps.logger,
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; "*": string } }>,
const proxyBaseHandler = async (
request: FastifyRequest<{ Params: { id: string; slug: string } }>,
reply: FastifyReply,
) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
worktreeSlug: request.params.slug,
pathSuffix: "",
logger: deps.logger,
})
}
const proxyWildcardHandler = async (
request: FastifyRequest<{ Params: { id: string; slug: string; "*": string } }>,
reply: FastifyReply,
) => {
await proxyWorkspaceRequest({
request,
reply,
workspaceManager: deps.workspaceManager,
worktreeSlug: request.params.slug,
pathSuffix: request.params["*"] ?? "",
logger: deps.logger,
})
}
instance.all("/workspaces/:id/instance", proxyBaseHandler)
instance.all("/workspaces/:id/instance/*", proxyWildcardHandler)
instance.all("/workspaces/:id/worktrees/:slug/instance", proxyBaseHandler)
instance.all("/workspaces/:id/worktrees/:slug/instance/*", proxyWildcardHandler)
})
}
@@ -347,12 +374,75 @@ async function proxyWorkspaceRequest(args: {
reply: FastifyReply
workspaceManager: WorkspaceManager
logger: Logger
worktreeSlug: string
pathSuffix?: string
}) {
const { request, reply, workspaceManager, logger } = args
const { request, reply, workspaceManager, logger, worktreeSlug } = args
const workspaceId = (request.params as { id: string }).id
const workspace = workspaceManager.get(workspaceId)
const bodyToJson = (body: unknown): unknown => {
if (body == null) return null
const anyBody = body as any
if (anyBody && typeof anyBody.pipe === "function") {
// Don't consume streams (would break proxying).
// Best-effort: if the stream already has buffered chunks, parse those.
try {
const buffered = anyBody?._readableState?.buffer
if (Array.isArray(buffered) && buffered.length > 0) {
const chunks: Buffer[] = []
for (const entry of buffered) {
if (!entry) continue
if (Buffer.isBuffer(entry)) {
chunks.push(entry)
continue
}
const data = (entry as any).data
if (Buffer.isBuffer(data)) {
chunks.push(data)
}
}
if (chunks.length > 0) {
const text = Buffer.concat(chunks).toString("utf-8")
try {
return JSON.parse(text)
} catch {
return { __raw: text }
}
}
}
} catch {
// fall through
}
return { __stream: true }
}
const maybeParse = (input: string): unknown => {
try {
return JSON.parse(input)
} catch {
return { __raw: input }
}
}
if (Buffer.isBuffer(body)) {
return maybeParse(body.toString("utf-8"))
}
if (typeof body === "string") {
return maybeParse(body)
}
if (typeof body === "object") {
return body
}
return body
}
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
@@ -364,6 +454,23 @@ async function proxyWorkspaceRequest(args: {
return
}
if (!isValidWorktreeSlug(worktreeSlug)) {
reply.code(400).send({ error: "Invalid worktree slug" })
return
}
const directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return
}
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
@@ -380,6 +487,43 @@ async function proxyWorkspaceRequest(args: {
if (instanceAuthHeader) {
headers.authorization = instanceAuthHeader
}
// OpenCode expects the *full* path; we send it via header to avoid query tampering.
const isNonASCII = /[^\x00-\x7F]/.test(directory)
const encodedDirectory = isNonASCII ? encodeURIComponent(directory) : directory
// Overwrite any client-provided value (case-insensitive headers are normalized by Node).
;(headers as Record<string, unknown>)["x-opencode-directory"] = encodedDirectory
if (logger.isLevelEnabled("trace")) {
const outgoing: Record<string, unknown> = {}
for (const [key, value] of Object.entries(headers as Record<string, unknown>)) {
outgoing[key] = value
}
// Redact sensitive headers.
for (const key of Object.keys(outgoing)) {
const lower = key.toLowerCase()
if (lower === "authorization" || lower === "cookie" || lower === "set-cookie") {
outgoing[key] = "<redacted>"
}
}
logger.trace(
{
workspaceId,
method: request.method,
targetUrl,
worktreeSlug,
directory,
contentType: request.headers["content-type"],
body: bodyToJson(request.body),
headers: outgoing,
},
"Proxy -> OpenCode request",
)
}
return headers
},
onError: (proxyReply, { error }) => {
@@ -399,6 +543,52 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
type WorktreeCacheEntry = {
expiresAt: number
repoRoot: string
worktrees: Array<{ slug: string; directory: string }>
}
const WORKTREE_CACHE_TTL_MS = 2000
const worktreeCache = new Map<string, WorktreeCacheEntry>()
async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger: Logger }) {
const cached = worktreeCache.get(params.workspaceId)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached
}
const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger })
const entry: WorktreeCacheEntry = {
expiresAt: now + WORKTREE_CACHE_TTL_MS,
repoRoot,
worktrees: worktrees.map((wt) => ({ slug: wt.slug, directory: wt.directory })),
}
worktreeCache.set(params.workspaceId, entry)
return entry
}
async function resolveWorktreeDirectory(params: {
workspaceId: string
workspacePath: string
worktreeSlug: string
logger: Logger
}): Promise<string | null> {
const { worktreeSlug } = params
const cached = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
const match = cached.worktrees.find((wt) => wt.slug === worktreeSlug)
if (match) {
return match.directory
}
// If the slug is new (e.g., created moments ago), refresh once.
worktreeCache.delete(params.workspaceId)
const refreshed = await getCachedWorktrees({ workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger })
return refreshed.worktrees.find((wt) => wt.slug === worktreeSlug)?.directory ?? null
}
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")

View File

@@ -0,0 +1,75 @@
import os from "os"
import type { NetworkAddress } from "../api-types"
export function resolveNetworkAddresses(args: {
host: string
protocol: "http" | "https"
port: number
}): NetworkAddress[] {
const { host, protocol, port } = args
const interfaces = os.networkInterfaces()
const seen = new Set<string>()
const results: NetworkAddress[] = []
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
if (!ip || ip === "0.0.0.0") return
const key = `ipv4-${ip}`
if (seen.has(key)) return
seen.add(key)
results.push({ ip, family: "ipv4", scope, remoteUrl: `${protocol}://${ip}:${port}` })
}
const normalizeFamily = (value: string | number) => {
if (typeof value === "string") {
const lowered = value.toLowerCase()
if (lowered === "ipv4") {
return "ipv4" as const
}
}
if (value === 4) return "ipv4" as const
return null
}
if (host === "0.0.0.0") {
// Enumerate system interfaces (IPv4 only)
for (const entries of Object.values(interfaces)) {
if (!entries) continue
for (const entry of entries) {
const family = normalizeFamily(entry.family)
if (!family) continue
if (!entry.address || entry.address === "0.0.0.0") continue
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
addAddress(entry.address, scope)
}
}
}
// Always include loopback address
addAddress("127.0.0.1", "loopback")
// Include explicitly configured host if it was IPv4
if (isIPv4Address(host) && host !== "0.0.0.0") {
const isLoopback = host.startsWith("127.")
addAddress(host, isLoopback ? "loopback" : "external")
}
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
})
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (part.length === 0 || part.length > 3) return false
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}

View File

@@ -88,7 +88,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
}
const session = deps.authManager.createSession(body.username)
deps.authManager.setSessionCookie(reply, session.id)
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
reply.send({ ok: true })
})
@@ -112,12 +112,12 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
const username = deps.authManager.getStatus().username
const session = deps.authManager.createSession(username)
deps.authManager.setSessionCookie(reply, session.id)
deps.authManager.setSessionCookieWithOptions(reply, session.id, { secure: isSecureRequest(request) })
reply.send({ ok: true })
})
app.post("/api/auth/logout", async (_request, reply) => {
deps.authManager.clearSessionCookie(reply)
app.post("/api/auth/logout", async (request, reply) => {
deps.authManager.clearSessionCookieWithOptions(reply, { secure: isSecureRequest(request) })
reply.send({ ok: true })
})
@@ -139,6 +139,13 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
})
}
function isSecureRequest(request: any) {
if (request.protocol === "https") {
return true
}
return Boolean(request.raw?.socket && request.raw.socket.encrypted)
}
function escapeHtml(value: string) {
return value.replace(/[&<>"]/g, (char) => {
switch (char) {

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify"
import os from "os"
import { NetworkAddress, ServerMeta } from "../../api-types"
import { ServerMeta } from "../../api-types"
import { resolveNetworkAddresses } from "../network-addresses"
interface RouteDeps {
serverMeta: ServerMeta
@@ -11,23 +11,25 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
}
function buildMetaResponse(meta: ServerMeta): ServerMeta {
const port = resolvePort(meta)
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
const localPort = resolveLocalPort(meta)
const remote = resolveRemote(meta)
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
return {
...meta,
port,
localPort,
remotePort: remote?.port,
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
addresses,
}
}
function resolvePort(meta: ServerMeta): number {
if (Number.isInteger(meta.port) && meta.port > 0) {
return meta.port
function resolveLocalPort(meta: ServerMeta): number {
if (Number.isInteger(meta.localPort) && meta.localPort > 0) {
return meta.localPort
}
try {
const parsed = new URL(meta.httpBaseUrl)
const parsed = new URL(meta.localUrl)
const port = Number(parsed.port)
return Number.isInteger(port) && port > 0 ? port : 0
} catch {
@@ -35,74 +37,22 @@ function resolvePort(meta: ServerMeta): number {
}
}
function resolveRemote(meta: ServerMeta): { protocol: "http" | "https"; port: number } | null {
if (!meta.remoteUrl) {
return null
}
try {
const parsed = new URL(meta.remoteUrl)
const protocol = parsed.protocol === "https:" ? "https" : "http"
const port = Number(parsed.port)
return { protocol, port: Number.isInteger(port) && port > 0 ? port : 0 }
} catch {
return null
}
}
function isLoopbackHost(host: string): boolean {
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
}
function resolveAddresses(port: number, host: string): NetworkAddress[] {
const interfaces = os.networkInterfaces()
const seen = new Set<string>()
const results: NetworkAddress[] = []
const addAddress = (ip: string, scope: NetworkAddress["scope"]) => {
if (!ip || ip === "0.0.0.0") return
const key = `ipv4-${ip}`
if (seen.has(key)) return
seen.add(key)
results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` })
}
const normalizeFamily = (value: string | number) => {
if (typeof value === "string") {
const lowered = value.toLowerCase()
if (lowered === "ipv4") {
return "ipv4" as const
}
}
if (value === 4) return "ipv4" as const
return null
}
if (host === "0.0.0.0") {
// Enumerate system interfaces (IPv4 only)
for (const entries of Object.values(interfaces)) {
if (!entries) continue
for (const entry of entries) {
const family = normalizeFamily(entry.family)
if (!family) continue
if (!entry.address || entry.address === "0.0.0.0") continue
const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external"
addAddress(entry.address, scope)
}
}
}
// Always include loopback address
addAddress("127.0.0.1", "loopback")
// Include explicitly configured host if it was IPv4
if (isIPv4Address(host) && host !== "0.0.0.0") {
const isLoopback = host.startsWith("127.")
addAddress(host, isLoopback ? "loopback" : "external")
}
const scopeWeight: Record<NetworkAddress["scope"], number> = { external: 0, internal: 1, loopback: 2 }
return results.sort((a, b) => {
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
if (scopeDelta !== 0) return scopeDelta
return a.ip.localeCompare(b.ip)
})
}
function isIPv4Address(value: string | undefined): value is string {
if (!value) return false
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (part.length === 0 || part.length > 3) return false
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}
// NetworkAddress shape is resolved in ../network-addresses

View File

@@ -0,0 +1,195 @@
import type { FastifyInstance, FastifyReply } from "fastify"
import { z } from "zod"
import { WorkspaceManager } from "../../workspaces/manager"
import {
resolveRepoRoot,
listWorktrees,
isValidWorktreeSlug,
createManagedWorktree,
removeWorktree,
} from "../../workspaces/git-worktrees"
import type { WorktreeListResponse, WorktreeMap } from "../../api-types"
import { ensureCodenomadGitExclude, readWorktreeMap, writeWorktreeMap } from "../../workspaces/worktree-map"
interface RouteDeps {
workspaceManager: WorkspaceManager
}
const WorktreeMapSchema = z.object({
version: z.literal(1),
defaultWorktreeSlug: z.string().min(1).default("root"),
parentSessionWorktreeSlug: z.record(z.string(), z.string()).default({}),
})
const WorktreeCreateSchema = z.object({
slug: z.string().trim().min(1),
branch: z.string().trim().min(1).optional(),
})
export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
const response: WorktreeListResponse = { worktrees, isGitRepo }
return response
})
app.post<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
try {
const body = WorktreeCreateSchema.parse(request.body ?? {})
const slug = body.slug
if (!isValidWorktreeSlug(slug) || slug === "root") {
reply.code(400)
return { error: "Invalid worktree slug" }
}
if (body.branch) {
if (!isValidWorktreeSlug(body.branch) || body.branch === "root") {
reply.code(400)
return { error: "Invalid worktree branch" }
}
if (body.branch !== slug) {
reply.code(400)
return { error: "Branch must match slug" }
}
}
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
if (!isGitRepo) {
reply.code(400)
return { error: "Workspace is not a Git repository" }
}
await ensureCodenomadGitExclude(workspace.path, request.log).catch(() => undefined)
const created = await createManagedWorktree({
repoRoot,
workspaceFolder: workspace.path,
slug,
logger: request.log,
})
reply.code(201)
return created
} catch (error) {
return handleError(error, reply)
}
})
app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>(
"/api/workspaces/:id/worktrees/:slug",
async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
const slug = (request.params.slug ?? "").trim()
if (!isValidWorktreeSlug(slug) || slug === "root") {
reply.code(400)
return { error: "Invalid worktree slug" }
}
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log)
if (!isGitRepo) {
reply.code(400)
return { error: "Workspace is not a Git repository" }
}
const force = (request.query?.force ?? "").toString().toLowerCase() === "true"
try {
const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log })
const match = worktrees.find((wt) => wt.slug === slug)
if (!match || match.kind === "root") {
reply.code(404)
return { error: "Worktree not found" }
}
await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log })
// Best-effort: prune any mappings that point at the deleted worktree.
const current = await readWorktreeMap(workspace.path, request.log)
let changed = false
const nextMapping: Record<string, string> = { ...(current.parentSessionWorktreeSlug ?? {}) }
for (const [sessionId, mapped] of Object.entries(nextMapping)) {
if (mapped === slug) {
delete nextMapping[sessionId]
changed = true
}
}
const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug
if (nextDefault !== current.defaultWorktreeSlug) {
changed = true
}
if (changed) {
await writeWorktreeMap(
workspace.path,
{
version: 1,
defaultWorktreeSlug: nextDefault,
parentSessionWorktreeSlug: nextMapping,
},
request.log,
)
}
reply.code(204)
} catch (error) {
return handleError(error, reply)
}
},
)
app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
return await readWorktreeMap(workspace.path, request.log)
})
app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404)
return { error: "Workspace not found" }
}
try {
const parsed = WorktreeMapSchema.parse(request.body ?? {}) as WorktreeMap
if (!isValidWorktreeSlug(parsed.defaultWorktreeSlug)) {
reply.code(400)
return { error: "Invalid defaultWorktreeSlug" }
}
for (const slug of Object.values(parsed.parentSessionWorktreeSlug ?? {})) {
if (!isValidWorktreeSlug(slug)) {
reply.code(400)
return { error: "Invalid worktree slug in mapping" }
}
}
await writeWorktreeMap(workspace.path, parsed, request.log)
reply.code(204)
} catch (error) {
return handleError(error, reply)
}
})
}
function handleError(error: unknown, reply: FastifyReply) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Unable to fulfill request" }
}

View File

@@ -0,0 +1,283 @@
import crypto from "crypto"
import fs from "fs"
import path from "path"
import { createRequire } from "module"
import type { Logger } from "../logger"
const require = createRequire(import.meta.url)
type Forge = typeof import("node-forge")
function loadForge(): Forge {
// node-forge is CJS in many installs; require keeps this compatible with our ESM output.
return require("node-forge") as Forge
}
export interface ResolvedHttpsOptions {
httpsOptions: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
/** Path to CA certificate suitable for NODE_EXTRA_CA_CERTS. */
caCertPath?: string
mode: "provided" | "generated"
}
export interface ResolveHttpsOptionsArgs {
enabled: boolean
configDir: string
host: string
tlsKeyPath?: string
tlsCertPath?: string
tlsCaPath?: string
tlsSANs?: string
logger: Logger
}
const LEAF_VALIDITY_DAYS = 30
const ROTATE_IF_EXPIRES_WITHIN_DAYS = 3
const CA_VALIDITY_DAYS = 365
export function resolveHttpsOptions(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions | null {
if (!args.enabled) {
return null
}
const hasProvided = Boolean(args.tlsKeyPath && args.tlsCertPath)
if (hasProvided) {
const key = fs.readFileSync(args.tlsKeyPath!, "utf-8")
const cert = fs.readFileSync(args.tlsCertPath!, "utf-8")
const ca = args.tlsCaPath ? fs.readFileSync(args.tlsCaPath, "utf-8") : undefined
return {
httpsOptions: { key, cert, ca },
caCertPath: args.tlsCaPath,
mode: "provided",
}
}
return ensureGeneratedTls(args)
}
function ensureGeneratedTls(args: ResolveHttpsOptionsArgs): ResolvedHttpsOptions {
const tlsDir = path.join(args.configDir, "tls")
const caKeyPath = path.join(tlsDir, "ca-key.pem")
const caCertPath = path.join(tlsDir, "ca-cert.pem")
const keyPath = path.join(tlsDir, "server-key.pem")
const certPath = path.join(tlsDir, "server-cert.pem")
fs.mkdirSync(tlsDir, { recursive: true })
const shouldRotateLeaf = () => {
try {
if (!fs.existsSync(certPath)) return true
const pem = fs.readFileSync(certPath, "utf-8")
const x509 = new crypto.X509Certificate(pem)
const validToMs = Date.parse(x509.validTo)
if (!Number.isFinite(validToMs)) return true
const rotateAt = validToMs - ROTATE_IF_EXPIRES_WITHIN_DAYS * 24 * 60 * 60 * 1000
return Date.now() >= rotateAt
} catch {
return true
}
}
const shouldRotateCa = () => {
try {
if (!fs.existsSync(caCertPath)) return true
const pem = fs.readFileSync(caCertPath, "utf-8")
const x509 = new crypto.X509Certificate(pem)
const validToMs = Date.parse(x509.validTo)
if (!Number.isFinite(validToMs)) return true
// CA rotates only when expired.
return Date.now() >= validToMs
} catch {
return true
}
}
if (shouldRotateCa() || !fs.existsSync(caKeyPath)) {
const { caKeyPem, caCertPem } = generateCaCertificate()
writePemFile(caKeyPath, caKeyPem, 0o600)
writePemFile(caCertPath, caCertPem, 0o644)
args.logger.info({ caCertPath }, "Generated self-signed CodeNomad CA certificate")
}
if (shouldRotateLeaf() || !fs.existsSync(keyPath)) {
const caKeyPem = fs.readFileSync(caKeyPath, "utf-8")
const caCertPem = fs.readFileSync(caCertPath, "utf-8")
const { keyPem, certPem } = generateServerCertificate({
host: args.host,
tlsSANs: args.tlsSANs,
caKeyPem,
caCertPem,
})
writePemFile(keyPath, keyPem, 0o600)
writePemFile(certPath, certPem, 0o644)
args.logger.info({ certPath }, "Generated CodeNomad HTTPS certificate")
}
const key = fs.readFileSync(keyPath, "utf-8")
const cert = fs.readFileSync(certPath, "utf-8")
const ca = fs.readFileSync(caCertPath, "utf-8")
// Present the CA as part of the chain.
const chainedCert = `${cert.trim()}\n${ca.trim()}\n`
return {
httpsOptions: {
key,
cert: chainedCert,
},
caCertPath,
mode: "generated",
}
}
function writePemFile(filePath: string, content: string, mode: number) {
fs.writeFileSync(filePath, content, { encoding: "utf-8", mode })
try {
fs.chmodSync(filePath, mode)
} catch {
// best effort on platforms that ignore chmod
}
}
function generateCaCertificate(): { caKeyPem: string; caCertPem: string } {
const forge = loadForge()
const keys = forge.pki.rsa.generateKeyPair(2048)
const cert = forge.pki.createCertificate()
cert.publicKey = keys.publicKey
cert.serialNumber = crypto.randomBytes(16).toString("hex")
const now = new Date()
const notBefore = new Date(now.getTime() - 60_000)
const notAfter = new Date(now.getTime() + CA_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
cert.validity.notBefore = notBefore
cert.validity.notAfter = notAfter
const attrs = [{ name: "commonName", value: "CodeNomad Local CA" }]
cert.setSubject(attrs)
cert.setIssuer(attrs)
cert.setExtensions([
{ name: "basicConstraints", cA: true },
{ name: "keyUsage", keyCertSign: true, cRLSign: true, digitalSignature: true },
{ name: "subjectKeyIdentifier" },
])
cert.sign(keys.privateKey, forge.md.sha256.create())
return {
caKeyPem: forge.pki.privateKeyToPem(keys.privateKey),
caCertPem: forge.pki.certificateToPem(cert),
}
}
function generateServerCertificate(args: {
host: string
tlsSANs?: string
caKeyPem: string
caCertPem: string
}): { keyPem: string; certPem: string } {
const forge = loadForge()
const caKey = forge.pki.privateKeyFromPem(args.caKeyPem)
const caCert = forge.pki.certificateFromPem(args.caCertPem)
const keys = forge.pki.rsa.generateKeyPair(2048)
const cert = forge.pki.createCertificate()
cert.publicKey = keys.publicKey
cert.serialNumber = crypto.randomBytes(16).toString("hex")
const now = new Date()
const notBefore = new Date(now.getTime() - 60_000)
const notAfter = new Date(now.getTime() + LEAF_VALIDITY_DAYS * 24 * 60 * 60 * 1000)
cert.validity.notBefore = notBefore
cert.validity.notAfter = notAfter
const commonName = pickCommonName(args.host)
cert.setSubject([{ name: "commonName", value: commonName }])
cert.setIssuer(caCert.subject.attributes)
const san = buildSubjectAltNames(args.host, args.tlsSANs)
cert.setExtensions([
{ name: "basicConstraints", cA: false },
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
{ name: "extKeyUsage", serverAuth: true },
{ name: "subjectAltName", altNames: san },
{ name: "subjectKeyIdentifier" },
])
cert.sign(caKey, forge.md.sha256.create())
return {
keyPem: forge.pki.privateKeyToPem(keys.privateKey),
certPem: forge.pki.certificateToPem(cert),
}
}
function pickCommonName(host: string): string {
if (!host || host === "0.0.0.0") {
return "localhost"
}
if (host === "127.0.0.1") {
return "localhost"
}
return host
}
function buildSubjectAltNames(host: string, tlsSANs?: string): Array<{ type: number; value?: string; ip?: string }> {
const dns = new Set<string>()
const ips = new Set<string>()
dns.add("localhost")
ips.add("127.0.0.1")
if (host && host !== "0.0.0.0") {
if (isIPv4(host)) {
ips.add(host)
} else {
dns.add(host)
}
}
for (const token of splitList(tlsSANs)) {
if (isIPv4(token)) {
ips.add(token)
} else if (token) {
dns.add(token)
}
}
const altNames: Array<{ type: number; value?: string; ip?: string }> = []
// 2 = DNS, 7 = IP
for (const name of Array.from(dns)) {
altNames.push({ type: 2, value: name })
}
for (const ip of Array.from(ips)) {
altNames.push({ type: 7, ip })
}
return altNames
}
function splitList(input: string | undefined): string[] {
if (!input) return []
return input
.split(",")
.map((part) => part.trim())
.filter(Boolean)
}
function isIPv4(value: string): boolean {
const parts = value.split(".")
if (parts.length !== 4) return false
return parts.every((part) => {
if (!/^[0-9]+$/.test(part)) return false
const num = Number(part)
return Number.isInteger(num) && num >= 0 && num <= 255
})
}

View File

@@ -0,0 +1,241 @@
import path from "path"
import { spawn } from "child_process"
import type { WorktreeDescriptor } from "../api-types"
import { promises as fsp } from "fs"
export interface LogLike {
debug?: (obj: any, msg?: string) => void
warn?: (obj: any, msg?: string) => void
}
type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string }
function runGit(args: string[], cwd: string): Promise<GitResult> {
return new Promise((resolve) => {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
let stdout = ""
let stderr = ""
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString()
})
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString()
})
child.once("error", (error) => {
resolve({ ok: false, error, stdout, stderr })
})
child.once("close", (code) => {
if (code === 0) {
resolve({ ok: true, stdout })
} else {
const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`)
resolve({ ok: false, error, stdout, stderr })
}
})
})
}
export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> {
const result = await runGit(["rev-parse", "--show-toplevel"], folder)
if (!result.ok) {
logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root")
return { repoRoot: folder, isGitRepo: false }
}
const repoRoot = result.stdout.trim()
if (!repoRoot) {
return { repoRoot: folder, isGitRepo: false }
}
return { repoRoot, isGitRepo: true }
}
function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> {
const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = []
const lines = output.split(/\r?\n/)
let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {}
const flush = () => {
if (current.worktree) {
records.push({ worktree: current.worktree, branch: current.branch })
}
current = {}
}
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) {
flush()
continue
}
const [key, ...rest] = trimmed.split(" ")
const value = rest.join(" ").trim()
if (key === "worktree") {
current.worktree = value
} else if (key === "branch") {
// branch is like refs/heads/foo
current.branch = value.replace(/^refs\/heads\//, "")
} else if (key === "HEAD") {
current.head = value
} else if (key === "detached") {
current.detached = true
}
}
flush()
return records
}
export async function listWorktrees(params: {
repoRoot: string
workspaceFolder: string
logger?: LogLike
}): Promise<WorktreeDescriptor[]> {
const { repoRoot, workspaceFolder, logger } = params
const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: repoRoot, kind: "root" }
const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder)
if (!result.ok) {
logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only")
return [rootDescriptor]
}
const records = parseWorktreePorcelain(result.stdout)
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
const seen = new Set<string>(["root"])
const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => {
const branch = (record.branch ?? "").trim()
if (branch) {
return branch
}
const head = (record.head ?? "").trim()
if (head && /^[0-9a-f]{7,40}$/i.test(head)) {
return `detached-${head.slice(0, 7)}`
}
// Fallback: stable-ish identifier derived from directory basename.
const base = path.basename(record.worktree || "")
return base ? `worktree-${base}` : "worktree"
}
for (const record of records) {
const abs = record.worktree
if (!abs || typeof abs !== "string") continue
// Skip the root record (we always expose it as slug="root").
if (path.resolve(abs) === path.resolve(repoRoot)) {
continue
}
const slug = normalizeSlug(record)
if (!slug || slug === "root") {
continue
}
if (seen.has(slug)) {
continue
}
seen.add(slug)
worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch })
}
return worktrees
}
export function isValidWorktreeSlug(slug: string): boolean {
if (!slug) return false
const trimmed = slug.trim()
if (!trimmed) return false
if (trimmed.length > 200) return false
// Disallow control characters; allow branch-like slugs including '/'.
if (/[\x00-\x1F\x7F]/.test(trimmed)) return false
return true
}
export async function createManagedWorktree(params: {
repoRoot: string
workspaceFolder: string
slug: string
logger?: LogLike
}): Promise<{ slug: string; directory: string; branch?: string }> {
const { repoRoot, workspaceFolder, logger } = params
const branch = params.slug.trim()
if (!branch || branch === "root" || !isValidWorktreeSlug(branch)) {
throw new Error("Invalid worktree slug")
}
const sanitizeDirName = (input: string): string => {
const normalized = input
.trim()
.replace(/[\\/]+/g, "-")
.replace(/\s+/g, "-")
.replace(/[^a-zA-Z0-9_.-]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-+|-+$/g, "")
return normalized || "worktree"
}
const worktreesDir = path.join(repoRoot, ".codenomad", "worktrees")
const targetDir = path.join(worktreesDir, sanitizeDirName(branch))
await fsp.mkdir(worktreesDir, { recursive: true })
try {
const stat = await fsp.stat(targetDir)
if (stat.isDirectory()) {
throw new Error("Worktree directory already exists")
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "ENOENT") {
throw error
}
}
logger?.debug?.({ slug: branch, branch, targetDir }, "Creating managed git worktree")
// Prefer creating a new branch from HEAD.
const first = await runGit(["worktree", "add", "-b", branch, targetDir, "HEAD"], workspaceFolder)
if (first.ok) {
return { slug: branch, directory: targetDir, branch }
}
const message = first.stderr?.toLowerCase() ?? first.error.message.toLowerCase()
if (message.includes("already exists")) {
// If the branch already exists, add worktree for that branch.
const second = await runGit(["worktree", "add", targetDir, branch], workspaceFolder)
if (second.ok) {
return { slug: branch, directory: targetDir, branch }
}
throw second.error
}
throw first.error
}
export async function removeWorktree(params: {
workspaceFolder: string
directory: string
force?: boolean
logger?: LogLike
}): Promise<void> {
const { workspaceFolder, logger } = params
const directory = (params.directory ?? "").trim()
if (!directory) {
throw new Error("Invalid worktree directory")
}
logger?.debug?.({ directory, force: Boolean(params.force) }, "Removing git worktree")
const args = ["worktree", "remove"]
if (params.force) {
args.push("--force")
}
args.push(directory)
const result = await runGit(args, workspaceFolder)
if (!result.ok) {
throw result.error
}
// Best-effort cleanup of stale metadata.
await runGit(["worktree", "prune"], workspaceFolder).catch(() => undefined)
}

View File

@@ -95,7 +95,7 @@ export class InstanceEventBridge {
}
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event`
const url = `http://${INSTANCE_HOST}:${port}/global/event`
const headers: Record<string, string> = { Accept: "text/event-stream" }
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
@@ -165,8 +165,32 @@ export class InstanceEventBridge {
}
try {
const event = JSON.parse(payload) as InstanceStreamEvent
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
const parsed = JSON.parse(payload) as any
if (!parsed || typeof parsed !== "object") {
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
return
}
// OpenCode SSE payload shapes vary across versions.
// Common variants:
// - { type, properties, ... }
// - { payload: { type, properties, ... }, directory: "/abs/path" }
// - { payload: { type, properties, ... } }
const base = parsed.payload && typeof parsed.payload === "object" ? parsed.payload : parsed
const event: InstanceStreamEvent | null = base && typeof base === "object" ? ({ ...base } as any) : null
// Attach directory when available (don't overwrite if already present).
if (event && !(event as any).directory && typeof (parsed as any).directory === "string") {
;(event as any).directory = (parsed as any).directory
}
if (!event || typeof (event as any).type !== "string") {
this.options.logger.warn({ workspaceId, chunk: payload }, "Dropped malformed instance event")
return
}
this.options.logger.debug({ workspaceId, eventType: (event as any).type }, "Instance SSE event received")
if (this.options.logger.isLevelEnabled("trace")) {
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
}

View File

@@ -28,6 +28,8 @@ interface WorkspaceManagerOptions {
eventBus: EventBus
logger: Logger
getServerBaseUrl: () => string
/** Optional CA bundle path to trust CodeNomad HTTPS certs. */
nodeExtraCaCertsPath?: string
}
interface WorkspaceRecord extends WorkspaceDescriptor {}
@@ -91,7 +93,7 @@ export class WorkspaceManager {
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance`
const proxyPath = `/workspaces/${id}/worktrees/root/instance`
const descriptor: WorkspaceRecord = {
@@ -132,6 +134,7 @@ export class WorkspaceManager {
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
CODENOMAD_INSTANCE_ID: id,
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
...(this.options.nodeExtraCaCertsPath ? { NODE_EXTRA_CA_CERTS: this.options.nodeExtraCaCertsPath } : {}),
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
}

View File

@@ -116,12 +116,26 @@ export class WorkspaceRuntime {
folder: options.folder,
binary: options.binaryPath,
spawnCommand: spec.command,
spawnArgs: spec.args,
commandLine,
env: redactEnvironment(env),
},
"Launching OpenCode process",
)
this.logger.debug(
{
workspaceId: options.workspaceId,
spawnArgs: spec.args,
},
"OpenCode spawn args",
)
this.logger.trace(
{
workspaceId: options.workspaceId,
env: redactEnvironment(env),
},
"OpenCode spawn environment",
)
const detached = process.platform !== "win32"
const child = spawn(spec.command, spec.args, {
cwd: options.folder,

View File

@@ -0,0 +1,129 @@
import fs from "fs"
import { promises as fsp } from "fs"
import path from "path"
import type { WorktreeMap } from "../api-types"
import { resolveRepoRoot } from "./git-worktrees"
import type { LogLike } from "./git-worktrees"
const DEFAULT_MAP: WorktreeMap = {
version: 1,
defaultWorktreeSlug: "root",
parentSessionWorktreeSlug: {},
}
function getMapPath(repoRoot: string): string {
return path.join(repoRoot, ".codenomad", "worktreeMap.json")
}
function getGitExcludePath(repoRoot: string): string {
return path.join(repoRoot, ".git", "info", "exclude")
}
async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise<void> {
const excludePath = getGitExcludePath(repoRoot)
try {
await fsp.mkdir(path.dirname(excludePath), { recursive: true })
} catch {
return
}
const entries = [
".codenomad/worktrees/",
".codenomad/worktreeMap.json",
]
let existing = ""
try {
existing = await fsp.readFile(excludePath, "utf-8")
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "ENOENT") {
logger?.debug?.({ err: error, excludePath }, "Failed to read .git/info/exclude")
return
}
existing = ""
}
const lines = new Set(existing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean))
const missing = entries.filter((e) => !lines.has(e))
if (missing.length === 0) {
return
}
const header = existing.includes("# codenomad") ? "" : (existing.trim() ? "\n" : "") + "# codenomad\n"
const suffix = missing.map((e) => `${e}\n`).join("")
await fsp.writeFile(excludePath, `${existing}${header}${suffix}`, "utf-8")
}
export async function ensureCodenomadGitExclude(workspaceFolder: string, logger?: LogLike): Promise<void> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
if (!isGitRepo) {
return
}
await ensureGitExclude(repoRoot, logger)
}
export async function readWorktreeMap(workspaceFolder: string, logger?: LogLike): Promise<WorktreeMap> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
const filePath = getMapPath(repoRoot)
try {
const raw = await fsp.readFile(filePath, "utf-8")
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") {
return DEFAULT_MAP
}
const version = (parsed as any).version
if (version !== 1) {
return DEFAULT_MAP
}
const defaultWorktreeSlug = typeof (parsed as any).defaultWorktreeSlug === "string" ? (parsed as any).defaultWorktreeSlug : "root"
const parentSessionWorktreeSlug = (parsed as any).parentSessionWorktreeSlug
const mapping = parentSessionWorktreeSlug && typeof parentSessionWorktreeSlug === "object" ? parentSessionWorktreeSlug : {}
return {
version: 1,
defaultWorktreeSlug,
parentSessionWorktreeSlug: { ...mapping },
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === "ENOENT") {
if (isGitRepo) {
// Best-effort ignore setup on first use.
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
}
return DEFAULT_MAP
}
logger?.warn?.({ err: error, filePath }, "Failed to read worktree map")
return DEFAULT_MAP
}
}
export async function writeWorktreeMap(workspaceFolder: string, next: WorktreeMap, logger?: LogLike): Promise<void> {
const { repoRoot, isGitRepo } = await resolveRepoRoot(workspaceFolder, logger)
const filePath = getMapPath(repoRoot)
await fsp.mkdir(path.dirname(filePath), { recursive: true })
// Ensure ignore rules are present (local-only).
if (isGitRepo) {
await ensureGitExclude(repoRoot, logger).catch(() => undefined)
}
const payload: WorktreeMap = {
version: 1,
defaultWorktreeSlug: next.defaultWorktreeSlug || "root",
parentSessionWorktreeSlug: next.parentSessionWorktreeSlug ?? {},
}
// Write atomically.
const tmpPath = `${filePath}.${process.pid}.tmp`
await fsp.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf-8")
await fsp.rename(tmpPath, filePath)
}
export function worktreeMapExists(repoRoot: string): boolean {
try {
return fs.existsSync(getMapPath(repoRoot))
} catch {
return false
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{
"name": "@codenomad/tauri-app",
"version": "0.9.2",
"version": "0.10.3",
"private": true,
"license": "MIT",
"scripts": {
"dev": "tauri dev",
"dev:ui": "npm run dev --workspace @codenomad/ui",

View File

@@ -3,6 +3,7 @@
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const { pathToFileURL } = require("url")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
@@ -10,6 +11,20 @@ const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
async function ensureMonacoAssets() {
const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js")
const helperUrl = pathToFileURL(helperPath).href
const { copyMonacoPublicAssets } = await import(helperUrl)
copyMonacoPublicAssets({
uiRendererRoot: path.join(uiRoot, "src", "renderer"),
warn: (msg) => console.warn(`[dev-prep] ${msg}`),
sourceRoots: [
path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"),
path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"),
],
})
}
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
@@ -42,5 +57,11 @@ function copyUiLoadingAssets() {
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
}
ensureUiBuild()
copyUiLoadingAssets()
;(async () => {
await ensureMonacoAssets()
ensureUiBuild()
copyUiLoadingAssets()
})().catch((err) => {
console.error("[dev-prep] failed:", err)
process.exit(1)
})

View File

@@ -2,6 +2,7 @@
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const { pathToFileURL } = require("url")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
@@ -37,6 +38,20 @@ const braceExpansionPath = path.join(
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
async function ensureMonacoAssets() {
const helperPath = path.join(uiRoot, "scripts", "monaco-public-assets.js")
const helperUrl = pathToFileURL(helperPath).href
const { copyMonacoPublicAssets } = await import(helperUrl)
copyMonacoPublicAssets({
uiRendererRoot: path.join(uiRoot, "src", "renderer"),
warn: (msg) => console.warn(`[prebuild] ${msg}`),
sourceRoots: [
path.resolve(workspaceRoot, "node_modules", "monaco-editor", "min", "vs"),
path.resolve(uiRoot, "node_modules", "monaco-editor", "min", "vs"),
],
})
}
function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public")
@@ -223,12 +238,18 @@ function copyUiLoadingAssets() {
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
}
ensureServerDevDependencies()
ensureUiDevDependencies()
ensureRollupPlatformBinary()
ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
copyServerArtifacts()
stripNodeModuleBins()
copyUiLoadingAssets()
;(async () => {
ensureServerDevDependencies()
ensureUiDevDependencies()
await ensureMonacoAssets()
ensureRollupPlatformBinary()
ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
copyServerArtifacts()
stripNodeModuleBins()
copyUiLoadingAssets()
})().catch((err) => {
console.error("[prebuild] failed:", err)
process.exit(1)
})

View File

@@ -2,6 +2,7 @@
name = "codenomad-tauri"
version = "0.1.0"
edition = "2021"
license = "MIT"
[build-dependencies]
tauri-build = { version = "2.5.2", features = [] }
@@ -21,3 +22,5 @@ tauri-plugin-dialog = "2"
dirs = "5"
tauri-plugin-opener = "2"
url = "2"
tauri-plugin-keepawake = "0.1.1"
tauri-plugin-notification = "2"

View File

@@ -3,7 +3,7 @@
"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:*"]
"urls": ["http://127.0.0.1:*", "http://localhost:*", "http://tauri.localhost/*", "https://tauri.localhost/*"]
},
"windows": ["main"],
"permissions": [
@@ -11,6 +11,10 @@
"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"
]
}

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:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","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","notification:allow-is-permission-granted","notification:allow-request-permission","notification:allow-notify","notification:allow-show","core:webview:allow-set-webview-zoom"]}}

View File

@@ -2378,6 +2378,234 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "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": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",

View File

@@ -2378,6 +2378,234 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`",
"type": "string",
"const": "keepawake:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start`\n- `allow-stop`"
},
{
"description": "Enables the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-start",
"markdownDescription": "Enables the start command without any pre-configured scope."
},
{
"description": "Enables the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:allow-stop",
"markdownDescription": "Enables the stop command without any pre-configured scope."
},
{
"description": "Denies the start command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-start",
"markdownDescription": "Denies the start command without any pre-configured scope."
},
{
"description": "Denies the stop command without any pre-configured scope.",
"type": "string",
"const": "keepawake:deny-stop",
"markdownDescription": "Denies the stop command without any pre-configured scope."
},
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "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": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",

View File

@@ -464,13 +464,33 @@ impl CliProcessManager {
let status_clone = status.clone();
let app_clone = app.clone();
thread::spawn(move || {
let code = {
let mut guard = child_holder.lock();
if let Some(child) = guard.as_mut() {
child.wait().ok()
} else {
None
// Do not hold the child mutex while waiting for process exit.
// Holding the lock across `wait()` deadlocks `stop()`, which needs the
// same lock to send SIGTERM/SIGKILL when the user quits the app.
let code = loop {
let maybe_exited = {
let mut guard = child_holder.lock();
if guard.is_none() {
return;
}
match guard
.as_mut()
.and_then(|child| child.try_wait().ok().flatten())
{
Some(status) => {
// Drop the handle after the process exits so other callers
// don't attempt to stop/kill a finished process.
*guard = None;
Some(status)
}
None => None,
}
};
if let Some(status) = maybe_exited {
break Some(status);
}
thread::sleep(Duration::from_millis(100));
};
let mut locked = status_clone.lock();
@@ -511,7 +531,7 @@ impl CliProcessManager {
bootstrap_token: &Arc<Mutex<Option<String>>>,
) {
let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
@@ -539,12 +559,12 @@ impl CliProcessManager {
continue;
}
if let Some(port) = port_regex
if let Some(url) = local_url_regex
.as_ref()
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
.map(|m| m.as_str().to_string())
{
Self::mark_ready(app, status, ready, bootstrap_token, port);
Self::mark_ready(app, status, ready, bootstrap_token, url);
continue;
}
@@ -554,13 +574,13 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, bootstrap_token, port);
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{port}"));
continue;
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
if let Some(port) = value.get("port").and_then(|p| p.as_u64()) {
Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
Self::mark_ready(app, status, ready, bootstrap_token, format!("http://localhost:{}", port));
continue;
}
}
@@ -577,12 +597,15 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
port: u16,
base_url: String,
) {
ready.store(true, Ordering::SeqCst);
let base_url = format!("http://127.0.0.1:{port}");
let port = Url::parse(&base_url)
.ok()
.and_then(|u| u.port_or_known_default())
.map(|p| p as u16);
let mut locked = status.lock();
locked.port = Some(port);
locked.port = port;
locked.url = Some(base_url.clone());
locked.state = CliState::Ready;
locked.error = None;
@@ -591,22 +614,29 @@ impl CliProcessManager {
let token = bootstrap_token.lock().take();
if let Some(token) = token {
match exchange_bootstrap_token(&base_url, &token) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
navigate_main(app, &base_url);
// Token exchange is only implemented for loopback HTTP. If localUrl is HTTPS,
// skip the exchange and let the user authenticate normally.
let scheme = Url::parse(&base_url).ok().map(|u| u.scheme().to_string());
if scheme.as_deref() != Some("http") {
navigate_main(app, &base_url);
} else {
match exchange_bootstrap_token(&base_url, &token) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
navigate_main(app, &base_url);
}
}
Ok(None) => {
log_line("bootstrap token exchange failed (invalid token)");
navigate_main(app, &format!("{base_url}/login"));
}
Err(err) => {
log_line(&format!("bootstrap token exchange failed: {err}"));
navigate_main(app, &format!("{base_url}/login"));
}
}
Ok(None) => {
log_line("bootstrap token exchange failed (invalid token)");
navigate_main(app, &format!("{base_url}/login"));
}
Err(err) => {
log_line(&format!("bootstrap token exchange failed: {err}"));
navigate_main(app, &format!("{base_url}/login"));
}
}
} else {
@@ -689,19 +719,24 @@ impl CliEntry {
}
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
host.to_string(),
"--port".to_string(),
"0".to_string(),
"--generate-token".to_string(),
];
let mut args = vec!["serve".to_string(), "--host".to_string(), host.to_string(), "--generate-token".to_string()];
if dev {
// Dev: plain HTTP + Vite dev server proxy.
args.push("--https".to_string());
args.push("false".to_string());
args.push("--http".to_string());
args.push("true".to_string());
args.push("--ui-dev-server".to_string());
args.push("http://localhost:3000".to_string());
args.push("--log-level".to_string());
args.push("debug".to_string());
} else {
// Prod desktop: always keep loopback HTTP enabled.
args.push("--https".to_string());
args.push("true".to_string());
args.push("--http".to_string());
args.push("true".to_string());
}
args
}

View File

@@ -4,6 +4,7 @@ mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus};
use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview;
@@ -11,6 +12,8 @@ use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri_plugin_opener::OpenerExt;
use url::Url;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
#[derive(Clone)]
pub struct AppState {
pub manager: CliProcessManager,
@@ -32,6 +35,7 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
Ok(state.manager.status())
}
fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
}
@@ -39,7 +43,10 @@ fn is_dev_mode() -> bool {
fn should_allow_internal(url: &Url) -> bool {
match url.scheme() {
"tauri" | "asset" | "file" => true,
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "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
// 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")),
_ => false,
}
}
@@ -67,6 +74,8 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_keepawake::init())
.plugin(tauri_plugin_notification::init())
.plugin(navigation_guard)
.manage(AppState {
manager: CliProcessManager::new(),
@@ -164,6 +173,11 @@ fn main() {
.expect("error while building tauri application")
.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { api, .. } => {
// `app_handle.exit(0)` triggers another `ExitRequested`. Without a guard, we can
// prevent exit forever and the app never quits (Cmd+Q / Quit menu appears stuck).
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
return;
}
api.prevent_exit();
let app = app_handle.clone();
std::thread::spawn(move || {
@@ -178,6 +192,9 @@ fn main() {
..
} => {
// Ensure we have time to stop the CLI process before the app exits.
if QUIT_REQUESTED.swap(true, Ordering::SeqCst) {
return;
}
api.prevent_close();
let app = app_handle.clone();
std::thread::spawn(move || {

View File

@@ -1,3 +1,5 @@
node_modules/
dist/
.vite/
src/renderer/public/logo.png
src/renderer/public/monaco/

View File

@@ -1,7 +1,8 @@
{
"name": "@codenomad/ui",
"version": "0.9.2",
"version": "0.10.3",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -17,22 +18,28 @@
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-notification": "^2.3.3",
"ansi-sequence-parser": "^1.1.3",
"debug": "^4.4.3",
"github-markdown-css": "^5.8.1",
"lucide-solid": "^0.300.0",
"marked": "^12.0.0",
"monaco-editor": "^0.52.2",
"qrcode": "^1.5.3",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0"
"solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0"
},
"devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2",
"autoprefixer": "10.4.21",
"postcss": "8.5.6",
"tailwindcss": "3",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-solid": "^2.10.0"
}
}

View File

@@ -0,0 +1,7 @@
export type CopyMonacoPublicAssetsParams = {
uiRendererRoot: string
warn?: (message: string) => void
sourceRoots?: string[]
}
export function copyMonacoPublicAssets(params: CopyMonacoPublicAssetsParams): void

View File

@@ -0,0 +1,97 @@
import fs from "fs"
import { resolve } from "path"
/**
* Copy Monaco's AMD `min/vs` assets into the UI renderer public folder.
*
* Monaco is loaded at runtime via `/monaco/vs/loader.js`. These assets are gitignored
* and generated on demand in dev/build so the repo stays clean.
*
* @param {object} params
* @param {string} params.uiRendererRoot Absolute path to `packages/ui/src/renderer`.
* @param {(message: string) => void} [params.warn] Warning logger.
* @param {string[]} [params.sourceRoots] Optional override list of `.../monaco-editor/min/vs` roots.
*/
export function copyMonacoPublicAssets(params) {
const uiRendererRoot = params?.uiRendererRoot
if (!uiRendererRoot) {
throw new Error("copyMonacoPublicAssets: uiRendererRoot is required")
}
const warn = params?.warn ?? ((message) => console.warn(message))
const publicDir = resolve(uiRendererRoot, "public")
const destRoot = resolve(publicDir, "monaco/vs")
const candidates =
params?.sourceRoots?.length > 0
? params.sourceRoots
: [
// Workspace root hoisted deps.
resolve(process.cwd(), "node_modules/monaco-editor/min/vs"),
// UI package local deps (covers non-hoisted installs).
resolve(process.cwd(), "packages/ui/node_modules/monaco-editor/min/vs"),
]
const sourceRoot = candidates.find((p) => fs.existsSync(resolve(p, "loader.js")))
if (!sourceRoot) {
warn("Monaco source directory not found; skipping copy")
return
}
const copyRecursive = (src, dest) => {
const stat = fs.statSync(src)
if (stat.isDirectory()) {
fs.mkdirSync(dest, { recursive: true })
for (const entry of fs.readdirSync(src)) {
copyRecursive(resolve(src, entry), resolve(dest, entry))
}
return
}
fs.copyFileSync(src, dest)
}
// Keep the working tree clean; these assets are generated.
try {
fs.rmSync(destRoot, { recursive: true, force: true })
} catch {
// ignore
}
fs.mkdirSync(destRoot, { recursive: true })
// Copy core Monaco runtime.
for (const dir of ["base", "editor", "platform"]) {
const src = resolve(sourceRoot, dir)
if (fs.existsSync(src)) {
copyRecursive(src, resolve(destRoot, dir))
}
}
// loader.js is required.
copyRecursive(resolve(sourceRoot, "loader.js"), resolve(destRoot, "loader.js"))
// Copy baseline rich language packages + workers.
for (const lang of ["typescript", "html", "json", "css"]) {
const src = resolve(sourceRoot, "language", lang)
if (fs.existsSync(src)) {
copyRecursive(src, resolve(destRoot, "language", lang))
}
}
// Copy baseline basic tokenizers.
for (const lang of ["python", "markdown", "cpp", "kotlin"]) {
const src = resolve(sourceRoot, "basic-languages", lang)
if (fs.existsSync(src)) {
copyRecursive(src, resolve(destRoot, "basic-languages", lang))
}
}
// Copy monaco.contribution.js entrypoints (needed by some loads).
const monacoContribution = resolve(sourceRoot, "basic-languages", "monaco.contribution.js")
if (fs.existsSync(monacoContribution)) {
copyRecursive(monacoContribution, resolve(destRoot, "basic-languages", "monaco.contribution.js"))
}
const underscoreContribution = resolve(sourceRoot, "basic-languages", "_.contribution.js")
if (fs.existsSync(underscoreContribution)) {
copyRecursive(underscoreContribution, resolve(destRoot, "basic-languages", "_.contribution.js"))
}
}

View File

@@ -19,6 +19,7 @@ import { getLogger } from "./lib/logger"
import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n"
import { setWakeLockDesired } from "./lib/native/wake-lock"
import {
hasInstances,
isSelectingFolder,
@@ -48,6 +49,8 @@ import {
updateSessionModel,
} from "./stores/sessions"
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
const log = getLogger("actions")
const App: Component = () => {
@@ -60,6 +63,7 @@ const App: Component = () => {
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -90,6 +94,26 @@ const App: Component = () => {
initReleaseNotifications()
})
const shouldHoldWakeLock = createMemo(() => {
const map = instances()
for (const id of map.keys()) {
const status = getInstanceSessionIndicatorStatus(id)
if (status !== "idle") {
return true
}
}
return false
})
createEffect(() => {
const hold = shouldHoldWakeLock()
void setWakeLockDesired(hold)
})
onCleanup(() => {
void setWakeLockDesired(false)
})
createEffect(() => {
instances()
hasInstances()
@@ -271,6 +295,7 @@ const App: Component = () => {
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -329,32 +354,34 @@ const App: Component = () => {
<Dialog open={Boolean(launchError())} modal>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
{t("app.launchError.description")}
</Dialog.Description>
</div>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-3xl p-6 flex flex-col gap-6 max-h-[80vh] min-h-0 overflow-hidden">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">{t("app.launchError.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
{t("app.launchError.description")}
</Dialog.Description>
</div>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</div>
<div class={`flex flex-col gap-4 ${launchErrorMessage() ? "flex-1 min-h-0" : ""}`}>
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex-shrink-0">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.binaryPathLabel")}</p>
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
</div>
<Show when={launchErrorMessage()}>
<div class="rounded-lg border border-base bg-surface-secondary p-4 flex flex-col gap-2 flex-1 min-h-0">
<p class="text-xs font-medium text-muted uppercase tracking-wide">{t("app.launchError.errorOutputLabel")}</p>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words overflow-auto flex-1 min-h-0">{launchErrorMessage()}</pre>
</div>
</Show>
</div>
<Show when={launchErrorMessage()}>
<div class="rounded-lg border border-base bg-surface-secondary p-4">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("app.launchError.errorOutputLabel")}</p>
<pre class="text-sm font-mono text-primary whitespace-pre-wrap break-words max-h-48 overflow-y-auto">{launchErrorMessage()}</pre>
</div>
</Show>
<div class="flex justify-end gap-2">
<Show when={launchError()?.missingBinary}>
<button
type="button"
class="selector-button selector-button-secondary"
<div class="flex justify-end gap-2">
<Show when={launchError()?.missingBinary}>
<button
type="button"
class="selector-button selector-button-secondary"
onClick={handleLaunchErrorAdvanced}
>
{t("app.launchError.openAdvancedSettings")}

View File

@@ -5,7 +5,6 @@ import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import Kbd from "./kbd"
const log = getLogger("session")
@@ -113,9 +112,6 @@ export default function AgentSelector(props: AgentSelectorProps) {
)}
</Select.Value>
</div>
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
<Kbd shortcut="cmd+shift+a" />
</span>
<Select.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Select.Icon>

View File

@@ -55,7 +55,7 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
const highlighted = highlighter.codeToHtml(props.code, {
lang: props.language as CodeToHtmlOptions["lang"],
theme: isDark() ? "github-dark" : "github-light",
theme: isDark() ? "github-dark" : "github-light-high-contrast",
})
setHtml(highlighted)
} catch {

View File

@@ -0,0 +1,116 @@
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { loadMonaco } from "../../lib/monaco/setup"
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
import { inferMonacoLanguageId } from "../../lib/monaco/language"
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
import { useTheme } from "../../lib/theme"
interface MonacoDiffViewerProps {
scopeKey: string
path: string
before: string
after: string
viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed"
}
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
const { isDark } = useTheme()
let host: HTMLDivElement | undefined
let diffEditor: any = null
let monaco: any = null
const [ready, setReady] = createSignal(false)
const disposeEditor = () => {
try {
diffEditor?.setModel(null as any)
} catch {
// ignore
}
try {
diffEditor?.dispose()
} catch {
// ignore
}
diffEditor = null
}
onMount(() => {
let cancelled = false
void (async () => {
monaco = await loadMonaco()
if (cancelled) return
if (!host || !monaco) return
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
diffEditor = monaco.editor.createDiffEditor(host, {
readOnly: true,
automaticLayout: true,
renderSideBySide: true,
renderSideBySideInlineBreakpoint: 0,
renderMarginRevertIcon: false,
minimap: { enabled: false },
scrollBeyondLastLine: false,
renderWhitespace: "selection",
fontSize: 13,
wordWrap: "off",
glyphMargin: false,
folding: false,
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
lineNumbersMinChars: 4,
lineDecorationsWidth: 12,
})
setReady(true)
})()
onCleanup(() => {
cancelled = true
setReady(false)
disposeEditor()
})
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const viewMode = props.viewMode === "unified" ? "unified" : "split"
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
diffEditor.updateOptions({
renderSideBySide: viewMode === "split",
renderSideBySideInlineBreakpoint: 0,
hideUnchangedRegions:
contextMode === "collapsed"
? { enabled: true }
: { enabled: false },
})
})
createEffect(() => {
if (!ready() || !monaco || !diffEditor) return
const languageId = inferMonacoLanguageId(monaco, props.path)
const beforeKey = `${props.scopeKey}:diff:${props.path}:before`
const afterKey = `${props.scopeKey}:diff:${props.path}:after`
const original = getOrCreateTextModel({ monaco, cacheKey: beforeKey, value: props.before, languageId })
const modified = getOrCreateTextModel({ monaco, cacheKey: afterKey, value: props.after, languageId })
diffEditor.setModel({ original, modified })
void ensureMonacoLanguageLoaded(languageId).then(() => {
try {
monaco.editor.setModelLanguage(original, languageId)
monaco.editor.setModelLanguage(modified, languageId)
} catch {
// ignore
}
})
})
return <div class="monaco-viewer" ref={host} />
}

View File

@@ -0,0 +1,89 @@
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { loadMonaco } from "../../lib/monaco/setup"
import { getOrCreateTextModel } from "../../lib/monaco/model-cache"
import { inferMonacoLanguageId } from "../../lib/monaco/language"
import { ensureMonacoLanguageLoaded } from "../../lib/monaco/setup"
import { useTheme } from "../../lib/theme"
interface MonacoFileViewerProps {
scopeKey: string
path: string
content: string
}
export function MonacoFileViewer(props: MonacoFileViewerProps) {
const { isDark } = useTheme()
let host: HTMLDivElement | undefined
let editor: any = null
let monaco: any = null
const [ready, setReady] = createSignal(false)
const disposeEditor = () => {
try {
editor?.setModel(null)
} catch {
// ignore
}
try {
editor?.dispose()
} catch {
// ignore
}
editor = null
}
onMount(() => {
let cancelled = false
void (async () => {
monaco = await loadMonaco()
if (cancelled) return
if (!host || !monaco) return
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
editor = monaco.editor.create(host, {
value: "",
language: "plaintext",
readOnly: true,
automaticLayout: true,
lineNumbers: "on",
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: "off",
renderWhitespace: "selection",
fontSize: 13,
})
setReady(true)
})()
onCleanup(() => {
cancelled = true
setReady(false)
disposeEditor()
})
})
createEffect(() => {
if (!ready() || !monaco || !editor) return
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
})
createEffect(() => {
if (!ready() || !monaco || !editor) return
const languageId = inferMonacoLanguageId(monaco, props.path)
const cacheKey = `${props.scopeKey}:file:${props.path}`
const model = getOrCreateTextModel({ monaco, cacheKey, value: props.content, languageId })
editor.setModel(model)
void ensureMonacoLanguageLoaded(languageId).then(() => {
try {
monaco.editor.setModelLanguage(model, languageId)
} catch {
// ignore
}
})
})
return <div class="monaco-viewer" ref={host} />
}

View File

@@ -5,6 +5,7 @@ import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { ThemeModeToggle } from "./theme-mode-toggle"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import VersionPill from "./version-pill"
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
@@ -253,12 +254,63 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
function getDisplayPath(path: string): string {
if (!path) return path
// macOS: /Users/<name>/...
if (path.startsWith("/Users/")) {
return path.replace(/^\/Users\/[^/]+/, "~")
}
// Linux: /home/<name>/...
if (path.startsWith("/home/")) {
return path.replace(/^\/home\/[^/]+/, "~")
}
// Windows: C:\Users\<name>\... (and the forward-slash variant)
if (/^[A-Za-z]:\\Users\\/.test(path)) {
return path.replace(/^[A-Za-z]:\\Users\\[^\\]+/, "~")
}
if (/^[A-Za-z]:\/Users\//.test(path)) {
return path.replace(/^[A-Za-z]:\/Users\/[^/]+/, "~")
}
return path
}
function looksLikeWindowsPath(value: string): boolean {
if (!value) return false
// Drive letter (C:\...) or UNC (\\server\share\...)
return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]+\\[^\\]+/.test(value)
}
function splitFolderPath(rawPath: string): { baseName: string; dirName: string } {
if (!rawPath) return { baseName: "", dirName: "" }
const isWindows = looksLikeWindowsPath(rawPath)
const trimmed = rawPath.replace(/[\\/]+$/, "")
// Root edge-cases ("/", "C:\\", "\\\\server\\share\\")
if (!trimmed) {
return { baseName: rawPath, dirName: "" }
}
if (isWindows && /^[A-Za-z]:$/.test(trimmed)) {
return { baseName: `${trimmed}\\`, dirName: "" }
}
const lastSlash = trimmed.lastIndexOf("/")
const lastBackslash = isWindows ? trimmed.lastIndexOf("\\") : -1
const lastSep = Math.max(lastSlash, lastBackslash)
if (lastSep < 0) {
return { baseName: trimmed, dirName: "" }
}
const baseName = trimmed.slice(lastSep + 1) || trimmed
const dirName = trimmed.slice(0, lastSep)
return { baseName, dirName }
}
return (
<>
<div
@@ -313,8 +365,9 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Select.Portal>
</Select>
</div>
<Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6">
<div class="absolute top-4 right-6 flex items-center gap-2">
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
<Show when={props.onOpenRemoteAccess}>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
@@ -322,8 +375,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
>
<MonitorUp class="w-4 h-4" />
</button>
</div>
</Show>
</Show>
</div>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt={t("folderSelection.logoAlt")} class="h-32 w-auto sm:h-48" loading="lazy" />
@@ -439,14 +492,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="flex items-center gap-2 mb-1">
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
<span class="text-sm font-medium truncate text-primary">
{folder.path.split("/").pop()}
{splitFolderPath(folder.path).baseName}
</span>
</div>
<div class="text-xs font-mono truncate pl-6 text-muted">
{getDisplayPath(folder.path)}
</div>
<div class="text-xs mt-1 pl-6 text-muted">
{formatRelativeTime(folder.lastAccessed)}
<div class="flex items-center gap-2 pl-6 text-xs text-muted min-w-0">
<span class="font-mono truncate-start flex-1 min-w-0">
{getDisplayPath(folder.path)}
</span>
<span class="flex-shrink-0">{formatRelativeTime(folder.lastAccessed)}</span>
</div>
</div>
<Show when={focusMode() === "recent" && selectedIndex() === index()}>

View File

@@ -11,20 +11,11 @@ interface InstanceTabProps {
onClose: () => void
}
function formatFolderName(path: string, instances: Instance[], currentInstance: Instance): string {
const name = path.split("/").pop() || path
const duplicates = instances.filter((i) => {
const iName = i.folder.split("/").pop() || i.folder
return iName === name
})
if (duplicates.length > 1) {
const index = duplicates.findIndex((i) => i.id === currentInstance.id)
return `~/${name} (${index + 1})`
}
return `~/${name}`
function getPathBasename(path: string): string {
// Instance folders can be POSIX-like (/Users/...) on macOS/Linux or Windows-like (C:\Users\...).
// Normalize by trimming trailing separators and then splitting on both '/' and '\\'.
const normalized = path.replace(/[\\/]+$/, "")
return normalized.split(/[\\/]/).pop() || path
}
const InstanceTab: Component<InstanceTabProps> = (props) => {
@@ -58,7 +49,7 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
>
<FolderOpen class="w-4 h-4 flex-shrink-0" />
<span class="tab-label">
{props.instance.folder.split("/").pop() || props.instance.folder}
{getPathBasename(props.instance.folder)}
</span>
<span
class={`status-indicator session-status ml-auto ${statusClassName()}`}

View File

@@ -1,10 +1,15 @@
import { Component, For, Show } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import { Dynamic } from "solid-js/web"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus, MonitorUp } from "lucide-solid"
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { useI18n } from "../lib/i18n"
import { ThemeModeToggle } from "./theme-mode-toggle"
import NotificationsSettingsModal from "./notifications-settings-modal"
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { useConfig } from "../stores/preferences"
interface InstanceTabsProps {
instances: Map<string, Instance>
@@ -17,6 +22,21 @@ interface InstanceTabsProps {
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
const notificationIcon = createMemo(() => {
if (!notificationsSupported()) return BellOff
return notificationsEnabled() ? Bell : BellOff
})
const notificationTitle = createMemo(() => {
if (!notificationsSupported()) return "Notifications unsupported"
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
})
return (
<div class="tab-bar tab-bar-instance">
<div class="tab-container" role="tablist">
@@ -52,6 +72,17 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
/>
</div>
</Show>
<ThemeModeToggle class="new-tab-button" />
<button
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
onClick={() => setNotificationsOpen(true)}
title={notificationTitle()}
aria-label={notificationTitle()}
>
<Dynamic component={notificationIcon()} class="w-4 h-4" />
</button>
<Show when={Boolean(props.onOpenRemoteAccess)}>
<button
class="new-tab-button tab-remote-button"
@@ -65,6 +96,8 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
</div>
</div>
</div>
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
</div>
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,168 @@
import { Show, type Accessor, type Component } from "solid-js"
import type { SessionThread } from "../../../stores/session-state"
import type { Session } from "../../../types/session"
import type { KeyboardShortcut } from "../../../lib/keyboard-registry"
import type { DrawerViewState } from "./types"
import { Search } from "lucide-solid"
import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin"
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
import InfoOutlinedIcon from "@suid/icons-material/InfoOutlined"
import SessionList from "../../session-list"
import KeyboardHint from "../../keyboard-hint"
import Kbd from "../../kbd"
import WorktreeSelector from "../../worktree-selector"
import AgentSelector from "../../agent-selector"
import ModelSelector from "../../model-selector"
import ThinkingSelector from "../../thinking-selector"
import { getLogger } from "../../../lib/logger"
const log = getLogger("session")
interface SessionSidebarProps {
t: (key: string) => string
instanceId: string
threads: Accessor<SessionThread[]>
activeSessionId: Accessor<string | null>
activeSession: Accessor<Session | null>
showSearch: Accessor<boolean>
onToggleSearch: () => void
keyboardShortcuts: Accessor<KeyboardShortcut[]>
isPhoneLayout: Accessor<boolean>
drawerState: Accessor<DrawerViewState>
leftPinned: Accessor<boolean>
onSelectSession: (sessionId: string) => void
onNewSession: () => Promise<void> | void
onSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
onSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
onPinLeftDrawer: () => void
onUnpinLeftDrawer: () => void
onCloseLeftDrawer: () => void
setContentEl: (el: HTMLElement | null) => void
}
const SessionSidebar: Component<SessionSidebarProps> = (props) => (
<div class="flex flex-col h-full min-h-0" ref={props.setContentEl}>
<div class="flex flex-col gap-2 px-4 py-3 border-b border-base">
<div class="flex items-center justify-between gap-2">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">
{props.t("instanceShell.leftPanel.sessionsTitle")}
</span>
<div class="flex items-center gap-2 text-primary">
<IconButton
size="small"
color="inherit"
aria-label={props.t("sessionList.filter.ariaLabel")}
title={props.t("sessionList.filter.ariaLabel")}
aria-pressed={props.showSearch()}
onClick={props.onToggleSearch}
sx={{
color: props.showSearch() ? "var(--text-primary)" : "inherit",
backgroundColor: props.showSearch() ? "var(--surface-hover)" : "transparent",
"&:hover": {
backgroundColor: "var(--surface-hover)",
},
}}
>
<Search class={props.showSearch() ? "w-4 h-4" : "w-4 h-4 opacity-70"} />
</IconButton>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftPanel.instanceInfo")}
title={props.t("instanceShell.leftPanel.instanceInfo")}
onClick={() => props.onSelectSession("info")}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.leftPinned() ? props.t("instanceShell.leftDrawer.unpin") : props.t("instanceShell.leftDrawer.pin")}
onClick={() => (props.leftPinned() ? props.onUnpinLeftDrawer() : props.onPinLeftDrawer())}
>
{props.leftPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
<Show when={props.drawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.leftDrawer.toggle.close")}
title={props.t("instanceShell.leftDrawer.toggle.close")}
onClick={props.onCloseLeftDrawer}
>
<MenuOpenIcon fontSize="small" />
</IconButton>
</Show>
</div>
</div>
<div class="session-sidebar-shortcuts">
<Show when={props.keyboardShortcuts().length}>
<KeyboardHint shortcuts={props.keyboardShortcuts()} separator=" " showDescription={false} />
</Show>
</div>
</div>
<div class="session-sidebar flex flex-col flex-1 min-h-0">
<SessionList
instanceId={props.instanceId}
threads={props.threads()}
activeSessionId={props.activeSessionId()}
onSelect={props.onSelectSession}
onNew={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => log.error("Failed to create session:", error))
}
}}
enableFilterBar={props.showSearch()}
showHeader={false}
showFooter={false}
/>
<div class="session-sidebar-separator" />
<Show when={props.activeSession()}>
{(activeSession) => (
<>
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
<WorktreeSelector instanceId={props.instanceId} sessionId={activeSession().id} />
<AgentSelector
instanceId={props.instanceId}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={(agent) => props.onSidebarAgentChange(activeSession().id, agent)}
/>
<ModelSelector
instanceId={props.instanceId}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={(model) => props.onSidebarModelChange(activeSession().id, model)}
/>
<ThinkingSelector instanceId={props.instanceId} currentModel={activeSession().model} />
<div class="session-sidebar-selector-hints" aria-hidden="true">
<Kbd shortcut="cmd+shift+a" />
<Kbd shortcut="cmd+shift+m" />
<Kbd shortcut="cmd+shift+t" />
</div>
</div>
</>
)}
</Show>
</div>
</div>
)
export default SessionSidebar

View File

@@ -0,0 +1,829 @@
import {
Show,
createEffect,
createMemo,
createSignal,
onCleanup,
type Accessor,
type Component,
} from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen"
import PushPinIcon from "@suid/icons-material/PushPin"
import PushPinOutlinedIcon from "@suid/icons-material/PushPinOutlined"
import type { Instance } from "../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
import type { Session } from "../../../../types/session"
import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types"
import ChangesTab from "./tabs/ChangesTab"
import FilesTab from "./tabs/FilesTab"
import GitChangesTab from "./tabs/GitChangesTab"
import StatusTab from "./tabs/StatusTab"
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api"
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import {
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY,
RIGHT_PANEL_TAB_STORAGE_KEY,
readStoredBool,
readStoredEnum,
readStoredPanelWidth,
readStoredRightPanelTab,
} from "../storage"
interface RightPanelProps {
t: (key: string, vars?: Record<string, any>) => string
instanceId: string
instance: Instance
activeSessionId: Accessor<string | null>
activeSession: Accessor<Session | null>
activeSessionDiffs: Accessor<any[] | undefined>
latestTodoState: Accessor<ToolState | null>
backgroundProcessList: Accessor<BackgroundProcess[]>
onOpenBackgroundOutput: (process: BackgroundProcess) => void
onStopBackgroundProcess: (processId: string) => Promise<void> | void
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
isPhoneLayout: Accessor<boolean>
rightDrawerWidth: Accessor<number>
rightDrawerWidthInitialized: Accessor<boolean>
rightDrawerState: Accessor<DrawerViewState>
rightPinned: Accessor<boolean>
onCloseRightDrawer: () => void
onPinRightDrawer: () => void
onUnpinRightDrawer: () => void
setContentEl: (el: HTMLElement | null) => void
}
const RightPanel: Component<RightPanelProps> = (props) => {
const [rightPanelTab, setRightPanelTab] = createSignal<RightPanelTab>(readStoredRightPanelTab("changes"))
const [rightPanelExpandedItems, setRightPanelExpandedItems] = createSignal<string[]>([
"plan",
"background-processes",
"mcp",
"lsp",
"plugins",
])
const [selectedFile, setSelectedFile] = createSignal<string | null>(null)
const [browserPath, setBrowserPath] = createSignal(".")
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
const [browserLoading, setBrowserLoading] = createSignal(false)
const [browserError, setBrowserError] = createSignal<string | null>(null)
const [browserSelectedPath, setBrowserSelectedPath] = createSignal<string | null>(null)
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
)
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
)
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
const [gitChangesSplitWidth, setGitChangesSplitWidth] = createSignal(320)
const [activeSplitResize, setActiveSplitResize] = createSignal<"changes" | "git-changes" | "files" | null>(null)
const [splitResizeStartX, setSplitResizeStartX] = createSignal(0)
const [splitResizeStartWidth, setSplitResizeStartWidth] = createSignal(0)
const [filesListOpen, setFilesListOpen] = createSignal(true)
const [filesListTouched, setFilesListTouched] = createSignal(false)
const [changesListOpen, setChangesListOpen] = createSignal(true)
const [changesListTouched, setChangesListTouched] = createSignal(false)
const [gitChangesListOpen, setGitChangesListOpen] = createSignal(true)
const [gitChangesListTouched, setGitChangesListTouched] = createSignal(false)
const listLayoutKey = createMemo(() => (props.isPhoneLayout() ? "phone" : "nonphone"))
const listOpenStorageKey = (tab: "changes" | "git-changes" | "files") => {
const layout = listLayoutKey()
if (tab === "changes") {
return layout === "phone" ? RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY
}
if (tab === "git-changes") {
return layout === "phone"
? RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY
: RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY
}
return layout === "phone" ? RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY : RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY
}
const persistListOpen = (tab: "changes" | "git-changes" | "files", value: boolean) => {
if (typeof window === "undefined") return
window.localStorage.setItem(listOpenStorageKey(tab), value ? "true" : "false")
}
createEffect(() => {
// Refresh persisted visibility when layout changes (phone vs non-phone).
const layout = listLayoutKey()
layout
const filesPersisted = readStoredBool(listOpenStorageKey("files"))
if (filesPersisted !== null) {
setFilesListOpen(filesPersisted)
setFilesListTouched(true)
} else {
setFilesListOpen(true)
setFilesListTouched(false)
}
const changesPersisted = readStoredBool(listOpenStorageKey("changes"))
if (changesPersisted !== null) {
setChangesListOpen(changesPersisted)
setChangesListTouched(true)
} else {
setChangesListOpen(true)
setChangesListTouched(false)
}
const gitPersisted = readStoredBool(listOpenStorageKey("git-changes"))
if (gitPersisted !== null) {
setGitChangesListOpen(gitPersisted)
setGitChangesListTouched(true)
} else {
setGitChangesListOpen(true)
setGitChangesListTouched(false)
}
})
createEffect(() => {
// Default behavior: when nothing is selected, keep the file list open.
// Once the user explicitly toggles it, we stop auto-opening.
if (rightPanelTab() !== "files") return
if (filesListTouched()) return
if (!browserSelectedPath()) {
setFilesListOpen(true)
}
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_TAB_STORAGE_KEY, rightPanelTab())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, diffViewMode())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
})
const clampSplitWidth = (value: number) => {
const min = 200
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
const max = Math.min(560, maxByDrawer)
return Math.min(max, Math.max(min, Math.floor(value)))
}
const [splitWidthsInitialized, setSplitWidthsInitialized] = createSignal(false)
createEffect(() => {
if (splitWidthsInitialized()) return
if (!props.rightDrawerWidthInitialized()) return
setSplitWidthsInitialized(true)
setChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, 320)))
setFilesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY, 320)))
setGitChangesSplitWidth(clampSplitWidth(readStoredPanelWidth(RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY, 320)))
})
const persistSplitWidth = (mode: "changes" | "git-changes" | "files", width: number) => {
if (typeof window === "undefined") return
const key =
mode === "changes"
? RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY
: mode === "git-changes"
? RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY
: RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY
window.localStorage.setItem(key, String(width))
}
function stopSplitResize() {
setActiveSplitResize(null)
if (typeof document === "undefined") return
splitPointerDrag.stop()
}
function splitMouseMove(event: MouseEvent) {
const mode = activeSplitResize()
if (!mode) return
event.preventDefault()
const delta = event.clientX - splitResizeStartX()
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next)
else setFilesSplitWidth(next)
}
function splitMouseUp() {
const mode = activeSplitResize()
if (mode) {
const width =
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
persistSplitWidth(mode, width)
}
stopSplitResize()
}
function splitTouchMove(event: TouchEvent) {
const mode = activeSplitResize()
if (!mode) return
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
const delta = touch.clientX - splitResizeStartX()
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next)
else setFilesSplitWidth(next)
}
function splitTouchEnd() {
const mode = activeSplitResize()
if (mode) {
const width =
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth()
persistSplitWidth(mode, width)
}
stopSplitResize()
}
const splitPointerDrag = useGlobalPointerDrag({
onMouseMove: splitMouseMove,
onMouseUp: splitMouseUp,
onTouchMove: splitTouchMove,
onTouchEnd: splitTouchEnd,
})
const startSplitResize = (mode: "changes" | "git-changes" | "files", clientX: number) => {
if (typeof document === "undefined") return
setActiveSplitResize(mode)
setSplitResizeStartX(clientX)
setSplitResizeStartWidth(
mode === "changes" ? changesSplitWidth() : mode === "git-changes" ? gitChangesSplitWidth() : filesSplitWidth(),
)
splitPointerDrag.start()
}
const handleSplitResizeMouseDown = (mode: "changes" | "git-changes" | "files") => (event: MouseEvent) => {
event.preventDefault()
startSplitResize(mode, event.clientX)
}
const handleSplitResizeTouchStart = (mode: "changes" | "git-changes" | "files") => (event: TouchEvent) => {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
startSplitResize(mode, touch.clientX)
}
onCleanup(() => {
stopSplitResize()
})
const worktreeSlugForViewer = createMemo(() => {
const sessionId = props.activeSessionId()
if (sessionId && sessionId !== "info") {
return getWorktreeSlugForSession(props.instanceId, sessionId)
}
return getDefaultWorktreeSlug(props.instanceId)
})
const browserClient = createMemo(() => getOrCreateWorktreeClient(props.instanceId, worktreeSlugForViewer()))
const [gitStatusEntries, setGitStatusEntries] = createSignal<GitFileStatus[] | null>(null)
const [gitStatusLoading, setGitStatusLoading] = createSignal(false)
const [gitStatusError, setGitStatusError] = createSignal<string | null>(null)
const [gitSelectedPath, setGitSelectedPath] = createSignal<string | null>(null)
const [gitSelectedLoading, setGitSelectedLoading] = createSignal(false)
const [gitSelectedError, setGitSelectedError] = createSignal<string | null>(null)
const [gitSelectedBefore, setGitSelectedBefore] = createSignal<string | null>(null)
const [gitSelectedAfter, setGitSelectedAfter] = createSignal<string | null>(null)
const gitMostChangedPath = createMemo<string | null>(() => {
const entries = gitStatusEntries()
if (!Array.isArray(entries) || entries.length === 0) return null
const candidates = entries.filter((item) => item && item.status !== "deleted")
if (candidates.length === 0) return null
const best = candidates.reduce((currentBest, item) => {
const bestScore = (currentBest?.added ?? 0) + (currentBest?.removed ?? 0)
const score = (item?.added ?? 0) + (item?.removed ?? 0)
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.path || "").localeCompare(String(currentBest?.path || "")) < 0 ? item : currentBest
}, candidates[0])
return typeof best?.path === "string" ? best.path : null
})
createEffect(() => {
// Reset tab state when worktree context changes.
worktreeSlugForViewer()
setBrowserPath(".")
setBrowserEntries(null)
setBrowserError(null)
setBrowserSelectedPath(null)
setBrowserSelectedContent(null)
setBrowserSelectedError(null)
setBrowserSelectedLoading(false)
setGitStatusEntries(null)
setGitStatusError(null)
setGitStatusLoading(false)
setGitSelectedPath(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
})
const loadGitStatus = async (force = false) => {
if (!force && gitStatusEntries() !== null) return
setGitStatusLoading(true)
setGitStatusError(null)
try {
const list = await requestData<GitFileStatus[]>(browserClient().file.status(), "file.status")
setGitStatusEntries(Array.isArray(list) ? list : [])
} catch (error) {
setGitStatusError(error instanceof Error ? error.message : "Failed to load git status")
setGitStatusEntries([])
} finally {
setGitStatusLoading(false)
}
}
async function openGitFile(path: string) {
setGitSelectedPath(path)
setGitSelectedLoading(true)
setGitSelectedError(null)
setGitSelectedBefore(null)
setGitSelectedAfter(null)
const list = gitStatusEntries() || []
const entry = list.find((item) => item.path === path) || null
if (entry?.status === "deleted") {
setGitSelectedError("Deleted file diff is not available yet")
setGitSelectedLoading(false)
return
}
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
setGitChangesListOpen(false)
}
try {
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const afterText = typeof (content as any)?.content === "string" ? ((content as any).content as string) : null
if (afterText === null) {
throw new Error("Unsupported file type")
}
setGitSelectedAfter(afterText)
if (entry?.status === "added") {
setGitSelectedBefore("")
return
}
const diffText =
typeof (content as any)?.diff === "string" && String((content as any).diff).trim().length > 0
? String((content as any).diff)
: (content as any)?.patch
? buildUnifiedDiffFromSdkPatch((content as any).patch)
: ""
const beforeText = tryReverseApplyUnifiedDiff(afterText, diffText)
if (beforeText === null) {
throw new Error("Unable to calculate diff for this file")
}
setGitSelectedBefore(beforeText)
} catch (error) {
setGitSelectedError(error instanceof Error ? error.message : "Failed to load file changes")
} finally {
setGitSelectedLoading(false)
}
}
createEffect(() => {
if (rightPanelTab() !== "git-changes") return
const entries = gitStatusEntries()
if (entries === null) return
if (gitSelectedPath()) return
const next = gitMostChangedPath()
if (!next) return
void openGitFile(next)
})
const refreshGitStatus = async () => {
await loadGitStatus(true)
const selected = gitSelectedPath()
if (selected) {
void openGitFile(selected)
}
}
const bestDiffFile = createMemo<string | null>(() => {
const diffs = props.activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return null
const best = diffs.reduce((currentBest, item) => {
const bestAdd = typeof (currentBest as any)?.additions === "number" ? (currentBest as any).additions : 0
const bestDel = typeof (currentBest as any)?.deletions === "number" ? (currentBest as any).deletions : 0
const bestScore = bestAdd + bestDel
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 score = add + del
if (score > bestScore) return item
if (score < bestScore) return currentBest
return String(item.file || "").localeCompare(String((currentBest as any)?.file || "")) < 0 ? item : currentBest
}, diffs[0])
return typeof (best as any)?.file === "string" ? (best as any).file : null
})
createEffect(() => {
const next = bestDiffFile()
if (!next) return
const diffs = props.activeSessionDiffs()
if (!Array.isArray(diffs) || diffs.length === 0) return
const current = selectedFile()
if (current && diffs.some((d) => d.file === current)) return
setSelectedFile(next)
})
const normalizeBrowserPath = (input: string) => {
const raw = String(input || ".").trim()
if (!raw || raw === "./") return "."
const cleaned = raw.replace(/\\/g, "/").replace(/\/+$/, "")
return cleaned === "" ? "." : cleaned
}
const getParentPath = (path: string): string | null => {
const current = normalizeBrowserPath(path)
if (current === ".") return null
const parts = current.split("/").filter(Boolean)
parts.pop()
return parts.length ? parts.join("/") : "."
}
const loadBrowserEntries = async (path: string) => {
const normalized = normalizeBrowserPath(path)
setBrowserLoading(true)
setBrowserError(null)
try {
const nodes = await requestData<FileNode[]>(browserClient().file.list({ path: normalized }), "file.list")
setBrowserPath(normalized)
setBrowserEntries(Array.isArray(nodes) ? nodes : [])
} catch (error) {
setBrowserError(error instanceof Error ? error.message : "Failed to load files")
setBrowserEntries([])
} finally {
setBrowserLoading(false)
}
}
const openBrowserFile = async (path: string) => {
setBrowserSelectedPath(path)
setBrowserSelectedLoading(true)
setBrowserSelectedError(null)
setBrowserSelectedContent(null)
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
setFilesListOpen(false)
}
try {
const content = await requestData<FileContent>(browserClient().file.read({ path }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const text = (content as any)?.content
if (typeof text !== "string") {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally {
setBrowserSelectedLoading(false)
}
}
createEffect(() => {
if (rightPanelTab() !== "files") return
if (browserLoading()) return
if (browserEntries() !== null) return
void loadBrowserEntries(browserPath())
})
createEffect(() => {
if (rightPanelTab() !== "git-changes") return
if (gitStatusLoading()) return
if (gitStatusEntries() !== null) return
void loadGitStatus()
})
const handleSelectChangesFile = (file: string, closeList: boolean) => {
setSelectedFile(file)
if (closeList) {
setChangesListOpen(false)
}
}
const toggleChangesList = () => {
setChangesListTouched(true)
setChangesListOpen((current) => {
const next = !current
persistListOpen("changes", next)
return next
})
}
const toggleFilesList = () => {
setFilesListTouched(true)
setFilesListOpen((current) => {
const next = !current
persistListOpen("files", next)
return next
})
}
const toggleGitList = () => {
setGitChangesListTouched(true)
setGitChangesListOpen((current) => {
const next = !current
persistListOpen("git-changes", next)
return next
})
}
const refreshFilesTab = async () => {
void loadBrowserEntries(browserPath())
const selected = browserSelectedPath()
if (selected) {
// Refresh file content without altering overlay state.
setBrowserSelectedLoading(true)
setBrowserSelectedError(null)
try {
const content = await requestData<FileContent>(browserClient().file.read({ path: selected }), "file.read")
const type = (content as any)?.type
const encoding = (content as any)?.encoding
if (type && type !== "text") {
throw new Error("Binary file cannot be displayed")
}
if (encoding === "base64") {
throw new Error("Binary file cannot be displayed")
}
const text = (content as any)?.content
if (typeof text !== "string") {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally {
setBrowserSelectedLoading(false)
}
}
}
const browserParentPath = createMemo(() => getParentPath(browserPath()))
const browserScopeKey = createMemo(() => `${props.instanceId}:${worktreeSlugForViewer()}`)
const gitScopeKey = createMemo(() => `${props.instanceId}:git:${worktreeSlugForViewer()}`)
const openChangesTabFromStatus = (file?: string) => {
if (file) {
setSelectedFile(file)
}
setRightPanelTab("changes")
}
const statusSectionIds = ["session-changes", "plan", "background-processes", "mcp", "lsp", "plugins"]
createEffect(() => {
const currentExpanded = new Set(rightPanelExpandedItems())
if (statusSectionIds.every((id) => currentExpanded.has(id))) return
setRightPanelExpandedItems(statusSectionIds)
})
const handleAccordionChange = (values: string[]) => {
setRightPanelExpandedItems(values)
}
const tabClass = (tab: RightPanelTab) =>
`right-panel-tab ${rightPanelTab() === tab ? "right-panel-tab-active" : "right-panel-tab-inactive"}`
return (
<div class="flex flex-col h-full" ref={props.setContentEl}>
<div class="right-panel-tab-bar">
<div class="tab-container">
<div class="tab-strip-shortcuts text-primary">
<Show when={props.rightDrawerState() === "floating-open"}>
<IconButton
size="small"
color="inherit"
aria-label={props.t("instanceShell.rightDrawer.toggle.close")}
title={props.t("instanceShell.rightDrawer.toggle.close")}
onClick={props.onCloseRightDrawer}
>
<MenuOpenIcon fontSize="small" sx={{ transform: "scaleX(-1)" }} />
</IconButton>
</Show>
<Show when={!props.isPhoneLayout()}>
<IconButton
size="small"
color="inherit"
aria-label={props.rightPinned() ? props.t("instanceShell.rightDrawer.unpin") : props.t("instanceShell.rightDrawer.pin")}
onClick={() => (props.rightPinned() ? props.onUnpinRightDrawer() : props.onPinRightDrawer())}
>
{props.rightPinned() ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</IconButton>
</Show>
</div>
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs" role="tablist" aria-label={props.t("instanceShell.rightPanel.tabs.ariaLabel")}>
<button
type="button"
role="tab"
class={tabClass("changes")}
aria-selected={rightPanelTab() === "changes"}
onClick={() => setRightPanelTab("changes")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.changes")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("git-changes")}
aria-selected={rightPanelTab() === "git-changes"}
onClick={() => setRightPanelTab("git-changes")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.gitChanges")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("files")}
aria-selected={rightPanelTab() === "files"}
onClick={() => setRightPanelTab("files")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.files")}</span>
</button>
<button
type="button"
role="tab"
class={tabClass("status")}
aria-selected={rightPanelTab() === "status"}
onClick={() => setRightPanelTab("status")}
>
<span class="tab-label">{props.t("instanceShell.rightPanel.tabs.status")}</span>
</button>
</div>
<div class="tab-strip-spacer" />
</div>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto">
<Show when={rightPanelTab() === "changes"}>
<ChangesTab
t={props.t}
instanceId={props.instanceId}
activeSessionId={props.activeSessionId}
activeSessionDiffs={props.activeSessionDiffs}
selectedFile={selectedFile}
onSelectFile={handleSelectChangesFile}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
listOpen={changesListOpen}
onToggleList={toggleChangesList}
splitWidth={changesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("changes")}
onResizeTouchStart={handleSplitResizeTouchStart("changes")}
isPhoneLayout={props.isPhoneLayout}
/>
</Show>
<Show when={rightPanelTab() === "git-changes"}>
<GitChangesTab
t={props.t}
activeSessionId={props.activeSessionId}
entries={gitStatusEntries}
statusLoading={gitStatusLoading}
statusError={gitStatusError}
selectedPath={gitSelectedPath}
selectedLoading={gitSelectedLoading}
selectedError={gitSelectedError}
selectedBefore={gitSelectedBefore}
selectedAfter={gitSelectedAfter}
mostChangedPath={gitMostChangedPath}
scopeKey={gitScopeKey}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onOpenFile={(path) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()}
listOpen={gitChangesListOpen}
onToggleList={toggleGitList}
splitWidth={gitChangesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
isPhoneLayout={props.isPhoneLayout}
/>
</Show>
<Show when={rightPanelTab() === "files"}>
<FilesTab
t={props.t}
browserPath={browserPath}
browserEntries={browserEntries}
browserLoading={browserLoading}
browserError={browserError}
browserSelectedPath={browserSelectedPath}
browserSelectedContent={browserSelectedContent}
browserSelectedLoading={browserSelectedLoading}
browserSelectedError={browserSelectedError}
parentPath={browserParentPath}
scopeKey={browserScopeKey}
onLoadEntries={(path) => void loadBrowserEntries(path)}
onOpenFile={(path) => void openBrowserFile(path)}
onRefresh={() => void refreshFilesTab()}
listOpen={filesListOpen}
onToggleList={toggleFilesList}
splitWidth={filesSplitWidth}
onResizeMouseDown={handleSplitResizeMouseDown("files")}
onResizeTouchStart={handleSplitResizeTouchStart("files")}
isPhoneLayout={props.isPhoneLayout}
/>
</Show>
<Show when={rightPanelTab() === "status"}>
<StatusTab
t={props.t}
instanceId={props.instanceId}
instance={props.instance}
activeSessionId={props.activeSessionId}
activeSession={props.activeSession}
activeSessionDiffs={props.activeSessionDiffs}
latestTodoState={props.latestTodoState}
backgroundProcessList={props.backgroundProcessList}
onOpenBackgroundOutput={props.onOpenBackgroundOutput}
onStopBackgroundProcess={props.onStopBackgroundProcess}
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
expandedItems={rightPanelExpandedItems}
onExpandedItemsChange={handleAccordionChange}
onOpenChangesTab={openChangesTabFromStatus}
/>
</Show>
</div>
</div>
)
}
export default RightPanel

View File

@@ -0,0 +1,53 @@
import type { Component } from "solid-js"
import type { DiffContextMode, DiffViewMode } from "../types"
interface DiffToolbarProps {
viewMode: DiffViewMode
contextMode: DiffContextMode
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
}
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
return (
<div class="file-viewer-toolbar">
<button
type="button"
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
aria-pressed={props.viewMode === "split"}
onClick={() => props.onViewModeChange("split")}
>
Split
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
aria-pressed={props.viewMode === "unified"}
onClick={() => props.onViewModeChange("unified")}
>
Unified
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
aria-pressed={props.contextMode === "collapsed"}
onClick={() => props.onContextModeChange("collapsed")}
title="Hide unchanged regions"
>
Collapsed
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
aria-pressed={props.contextMode === "expanded"}
onClick={() => props.onContextModeChange("expanded")}
title="Show full file"
>
Expanded
</button>
</div>
)
}
export default DiffToolbar

View File

@@ -0,0 +1,16 @@
import type { Component, JSX } from "solid-js"
interface OverlayListProps {
ariaLabel: string
children: JSX.Element
}
const OverlayList: Component<OverlayListProps> = (props) => {
return (
<div class="file-list-overlay" role="dialog" aria-label={props.ariaLabel}>
<div class="file-list-scroll">{props.children}</div>
</div>
)
}
export default OverlayList

View File

@@ -0,0 +1,70 @@
import { Show, type Component, type JSX } from "solid-js"
import OverlayList from "./OverlayList"
type SplitFilePanelList = {
panel: () => JSX.Element
overlay: () => JSX.Element
}
interface SplitFilePanelProps {
header: JSX.Element
list: SplitFilePanelList
viewer: JSX.Element
listOpen: boolean
onToggleList: () => void
splitWidth: number
onResizeMouseDown: (event: MouseEvent) => void
onResizeTouchStart: (event: TouchEvent) => void
isPhoneLayout: boolean
overlayAriaLabel: string
}
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
return (
<div class="files-tab-container">
<div class="files-tab-header">
<div class="files-tab-header-row">
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
{props.listOpen ? "Hide files" : "Show files"}
</button>
{props.header}
</div>
</div>
<div class="files-tab-body">
<Show
when={!props.isPhoneLayout && props.listOpen}
fallback={props.viewer}
>
<div class="files-split" style={{ "--files-pane-width": `${props.splitWidth}px` }}>
<div class="file-list-panel">
<div class="file-list-scroll">{props.list.panel()}</div>
</div>
<div
class="file-split-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize file list"
onMouseDown={props.onResizeMouseDown}
onTouchStart={props.onResizeTouchStart}
/>
{props.viewer}
</div>
</Show>
<Show when={props.isPhoneLayout}>
<Show when={props.listOpen}>
<OverlayList ariaLabel={props.overlayAriaLabel}>{props.list.overlay()}</OverlayList>
</Show>
</Show>
</div>
</div>
)
}
export default SplitFilePanel

View File

@@ -0,0 +1,203 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode } from "../types"
interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string
instanceId: string
activeSessionId: Accessor<string | null>
activeSessionDiffs: Accessor<any[] | undefined>
selectedFile: Accessor<string | null>
onSelectFile: (file: string, closeList: boolean) => void
diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode>
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
listOpen: Accessor<boolean>
onToggleList: () => void
splitWidth: Accessor<number>
onResizeMouseDown: (event: MouseEvent) => void
onResizeTouchStart: (event: TouchEvent) => void
isPhoneLayout: Accessor<boolean>
}
const ChangesTab: Component<ChangesTabProps> = (props) => {
const renderContent = (): JSX.Element => {
const sessionId = props.activeSessionId()
const hasSession = Boolean(sessionId && sessionId !== "info")
const diffs = hasSession ? props.activeSessionDiffs() : null
const sorted = Array.isArray(diffs) ? [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || ""))) : []
const totals = sorted.reduce(
(acc, item) => {
acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
return acc
},
{ additions: 0, deletions: 0 },
)
const mostChanged = sorted.length
? sorted.reduce((best, item) => {
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 del = typeof (item as any)?.deletions === "number" ? (item as any).deletions : 0
const score = add + del
if (score > bestScore) return item
if (score < bestScore) return best
return String(item.file || "").localeCompare(String((best as any)?.file || "")) < 0 ? item : best
}, sorted[0])
: null
// Auto-select the most-changed file if none selected.
const currentSelected = props.selectedFile()
const selectedFileData = sorted.find((f) => f.file === currentSelected) || mostChanged
const scopeKey = `${props.instanceId}:${hasSession ? sessionId : "no-session"}`
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")
}
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
</div>
}
>
{(file) => (
<MonacoDiffViewer
scopeKey={scopeKey}
path={String(file().file || "")}
before={String((file() as any).before || "")}
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
/>
)}
</Show>
</div>
</div>
)
const renderEmptyList = () => (
<div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
)
const renderListPanel = () => (
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => {
props.onSelectFile(item.file, props.isPhoneLayout())
}}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.file}>
<span class="file-path-text">{item.file}</span>
</div>
<div class="file-list-item-stats">
<span class="file-list-item-additions">+{item.additions}</span>
<span class="file-list-item-deletions">-{item.deletions}</span>
</div>
</div>
</div>
)}
</For>
</Show>
)
const renderListOverlay = () => (
<Show when={sorted.length > 0} fallback={renderEmptyList()}>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${selectedFileData?.file === item.file ? "file-list-item-active" : ""}`}
onClick={() => {
props.onSelectFile(item.file, true)
}}
title={item.file}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.file}>
<span class="file-path-text">{item.file}</span>
</div>
<div class="file-list-item-stats">
<span class="file-list-item-additions">+{item.additions}</span>
<span class="file-list-item-deletions">-{item.deletions}</span>
</div>
</div>
</div>
)}
</For>
</Show>
)
const headerPath = () => (selectedFileData?.file ? selectedFileData.file : props.t("instanceShell.rightPanel.tabs.changes"))
return (
<SplitFilePanel
header={
<>
<span class="files-tab-selected-path" title={headerPath()}>
<span class="file-path-text">{headerPath()}</span>
</span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span>
</span>
<span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span>
</span>
</div>
</>
}
list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()}
listOpen={props.listOpen()}
onToggleList={props.onToggleList}
splitWidth={props.splitWidth()}
onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Changes"
/>
)
}
return <>{renderContent()}</>
}
export default ChangesTab

View File

@@ -0,0 +1,191 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid"
import { MonacoFileViewer } from "../../../../file-viewer/monaco-file-viewer"
import SplitFilePanel from "../components/SplitFilePanel"
interface FilesTabProps {
t: (key: string, vars?: Record<string, any>) => string
browserPath: Accessor<string>
browserEntries: Accessor<FileNode[] | null>
browserLoading: Accessor<boolean>
browserError: Accessor<string | null>
browserSelectedPath: Accessor<string | null>
browserSelectedContent: Accessor<string | null>
browserSelectedLoading: Accessor<boolean>
browserSelectedError: Accessor<string | null>
parentPath: Accessor<string | null>
scopeKey: Accessor<string>
onLoadEntries: (path: string) => void
onOpenFile: (path: string) => void
onRefresh: () => void
listOpen: Accessor<boolean>
onToggleList: () => void
splitWidth: Accessor<number>
onResizeMouseDown: (event: MouseEvent) => void
onResizeTouchStart: (event: TouchEvent) => void
isPhoneLayout: Accessor<boolean>
}
const FilesTab: Component<FilesTabProps> = (props) => {
const renderContent = (): JSX.Element => {
const entriesValue = props.browserEntries()
const entries = entriesValue || []
const sorted = [...entries].sort((a, b) => {
const aDir = a.type === "directory" ? 0 : 1
const bDir = b.type === "directory" ? 0 : 1
if (aDir !== bDir) return aDir - bDir
return String(a.name || "").localeCompare(String(b.name || ""))
})
const parent = props.parentPath()
const headerDisplayedPath = () => props.browserSelectedPath() || props.browserPath()
const emptyViewerMessage = () => {
if (props.browserLoading() && entriesValue === null) return "Loading files..."
return "Select a file to preview"
}
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={props.browserSelectedLoading()}
fallback={
<Show
when={props.browserSelectedError()}
fallback={
<Show
when={
props.browserSelectedPath() && props.browserSelectedContent() !== null
? { path: props.browserSelectedPath() as string, content: props.browserSelectedContent() as string }
: null
}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
</div>
}
>
{(payload) => (
<MonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
)}
</Show>
}
>
{(err) => (
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{err()}</span>
</div>
)}
</Show>
}
>
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span>
</div>
</Show>
</div>
</div>
)
const renderList = () => (
<>
<Show when={parent}>
{(p) => (
<div class="file-list-item" onClick={() => props.onLoadEntries(p())}>
<div class="file-list-item-content">
<div class="file-list-item-path" title={p()}>
<span class="file-path-text">..</span>
</div>
</div>
</div>
)}
</Show>
<Show when={props.browserLoading() && entriesValue === null}>
<div class="p-3 text-xs text-secondary">Loading files...</div>
</Show>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${props.browserSelectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
if (item.type === "directory") {
props.onLoadEntries(item.path)
return
}
props.onOpenFile(item.path)
}}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.name}</span>
</div>
<div class="file-list-item-stats">
<span class="text-[10px] text-secondary">{item.type}</span>
</div>
</div>
</div>
)}
</For>
</>
)
return (
<SplitFilePanel
header={
<>
<div class="files-tab-stats">
<span class="files-tab-stat">
<span class="files-tab-selected-path" title={headerDisplayedPath()}>
<span class="file-path-text">{headerDisplayedPath()}</span>
</span>
</span>
<Show when={props.browserLoading()}>
<span>Loading</span>
</Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={props.browserLoading()}
style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()}
>
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
</button>
</>
}
list={{ panel: renderList, overlay: renderList }}
viewer={renderViewer()}
listOpen={props.listOpen()}
onToggleList={props.onToggleList}
splitWidth={props.splitWidth()}
onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Files"
/>
)
}
return <>{renderContent()}</>
}
export default FilesTab

View File

@@ -0,0 +1,258 @@
import { For, Show, type Accessor, type Component, type JSX } from "solid-js"
import type { File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid"
import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode } from "../types"
interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string
activeSessionId: Accessor<string | null>
entries: Accessor<GitFileStatus[] | null>
statusLoading: Accessor<boolean>
statusError: Accessor<string | null>
selectedPath: Accessor<string | null>
selectedLoading: Accessor<boolean>
selectedError: Accessor<string | null>
selectedBefore: Accessor<string | null>
selectedAfter: Accessor<string | null>
mostChangedPath: Accessor<string | null>
scopeKey: Accessor<string>
diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode>
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
onOpenFile: (path: string) => void
onRefresh: () => void
listOpen: Accessor<boolean>
onToggleList: () => void
splitWidth: Accessor<number>
onResizeMouseDown: (event: MouseEvent) => void
onResizeTouchStart: (event: TouchEvent) => void
isPhoneLayout: Accessor<boolean>
}
const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderContent = (): JSX.Element => {
const sessionId = props.activeSessionId()
const hasSession = Boolean(sessionId && sessionId !== "info")
const entries = hasSession ? props.entries() : null
const sorted = Array.isArray(entries)
? [...entries].sort((a, b) => String(a.path || "").localeCompare(String(b.path || "")))
: []
const totals = sorted.reduce(
(acc, item) => {
acc.additions += typeof item.added === "number" ? item.added : 0
acc.deletions += typeof item.removed === "number" ? item.removed : 0
return acc
},
{ additions: 0, deletions: 0 },
)
const nonDeleted = 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 selectedPath = props.selectedPath()
const fallbackPath = props.mostChangedPath()
const selectedEntry =
sorted.find((item) => item.path === selectedPath) ||
(fallbackPath ? sorted.find((item) => item.path === fallbackPath) : null)
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={props.selectedLoading()}
fallback={
<Show
when={props.selectedError()}
fallback={
<Show
when={
selectedEntry &&
props.selectedBefore() !== null &&
props.selectedAfter() !== null &&
selectedEntry.status !== "deleted"
? {
path: selectedEntry.path,
before: props.selectedBefore() as string,
after: props.selectedAfter() as string,
}
: null
}
fallback={
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{emptyViewerMessage()}</span>
</div>
}
>
{(file) => (
<MonacoDiffViewer
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()}
/>
)}
</Show>
}
>
{(err) => (
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">{err()}</span>
</div>
)}
</Show>
}
>
<div class="file-viewer-empty">
<span class="file-viewer-empty-text">Loading</span>
</div>
</Show>
</div>
</div>
)
const renderEmptyList = () => <div class="p-3 text-xs text-secondary">{emptyViewerMessage()}</div>
const renderListPanel = () => (
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => {
props.onOpenFile(item.path)
}}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span>
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
<span class="file-list-item-additions">+{item.added}</span>
<span class="file-list-item-deletions">-{item.removed}</span>
</>
</Show>
</div>
</div>
</div>
)}
</For>
</Show>
)
const renderListOverlay = () => (
<Show when={nonDeleted.length > 0} fallback={renderEmptyList()}>
<For each={sorted}>
{(item) => (
<div
class={`file-list-item ${props.selectedPath() === item.path ? "file-list-item-active" : ""}`}
onClick={() => props.onOpenFile(item.path)}
title={item.path}
>
<div class="file-list-item-content">
<div class="file-list-item-path" title={item.path}>
<span class="file-path-text">{item.path}</span>
</div>
<div class="file-list-item-stats">
<Show when={item.status === "deleted"}>
<span class="text-[10px] text-secondary">deleted</span>
</Show>
<Show when={item.status !== "deleted"}>
<>
<span class="file-list-item-additions">+{item.added}</span>
<span class="file-list-item-deletions">-{item.removed}</span>
</>
</Show>
</div>
</div>
</div>
)}
</For>
</Show>
)
return (
<SplitFilePanel
header={
<>
<span class="files-tab-selected-path" title={selectedEntry?.path || "Git Changes"}>
<span class="file-path-text">{selectedEntry?.path || "Git Changes"}</span>
</span>
<div class="files-tab-stats" style={{ flex: "0 0 auto" }}>
<span class="files-tab-stat files-tab-stat-additions">
<span class="files-tab-stat-value">+{totals.additions}</span>
</span>
<span class="files-tab-stat files-tab-stat-deletions">
<span class="files-tab-stat-value">-{totals.deletions}</span>
</span>
<Show when={props.statusError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={!hasSession || props.statusLoading() || entries === null}
style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()}
>
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
</button>
</>
}
list={{ panel: renderListPanel, overlay: renderListOverlay }}
viewer={renderViewer()}
listOpen={props.listOpen()}
onToggleList={props.onToggleList}
splitWidth={props.splitWidth()}
onResizeMouseDown={props.onResizeMouseDown}
onResizeTouchStart={props.onResizeTouchStart}
isPhoneLayout={props.isPhoneLayout()}
overlayAriaLabel="Git Changes"
/>
)
}
return <>{renderContent()}</>
}
export default GitChangesTab

View File

@@ -0,0 +1,294 @@
import { For, Show, type Accessor, type Component } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import { Accordion } from "@kobalte/core"
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import type { Instance } from "../../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
import type { Session } from "../../../../../types/session"
import ContextUsagePanel from "../../../../session/context-usage-panel"
import { TodoListView } from "../../../../tool-call/renderers/todo"
import InstanceServiceStatus from "../../../../instance-service-status"
interface StatusTabProps {
t: (key: string, vars?: Record<string, any>) => string
instanceId: string
instance: Instance
activeSessionId: Accessor<string | null>
activeSession: Accessor<Session | null>
activeSessionDiffs: Accessor<any[] | undefined>
latestTodoState: Accessor<ToolState | null>
backgroundProcessList: Accessor<BackgroundProcess[]>
onOpenBackgroundOutput: (process: BackgroundProcess) => void
onStopBackgroundProcess: (processId: string) => Promise<void> | void
onTerminateBackgroundProcess: (processId: string) => Promise<void> | void
expandedItems: Accessor<string[]>
onExpandedItemsChange: (values: string[]) => void
onOpenChangesTab: (file?: string) => void
}
const StatusTab: Component<StatusTabProps> = (props) => {
const isSectionExpanded = (id: string) => props.expandedItems().includes(id)
const renderStatusSessionChanges = () => {
const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.sessionChanges.noSessionSelected")}</span>
</div>
)
}
const diffs = props.activeSessionDiffs()
if (diffs === undefined) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.sessionChanges.loading")}</span>
</div>
)
}
if (!Array.isArray(diffs) || diffs.length === 0) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.sessionChanges.empty")}</span>
</div>
)
}
const sorted = [...diffs].sort((a, b) => String(a.file || "").localeCompare(String(b.file || "")))
const totals = sorted.reduce(
(acc, item) => {
acc.additions += typeof item.additions === "number" ? item.additions : 0
acc.deletions += typeof item.deletions === "number" ? item.deletions : 0
return acc
},
{ additions: 0, deletions: 0 },
)
return (
<div class="flex flex-col gap-3 min-h-0">
<div class="flex items-center justify-between gap-2 text-[11px] text-secondary">
<span>{props.t("instanceShell.sessionChanges.filesChanged", { count: sorted.length })}</span>
<span class="flex items-center gap-2">
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${totals.additions}`}</span>
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${totals.deletions}`}</span>
</span>
</div>
<div class="rounded-md border border-base bg-surface-secondary p-2 max-h-[40vh] overflow-y-auto">
<div class="flex flex-col">
<For each={sorted}>
{(item) => (
<button
type="button"
class="border-b border-base last:border-b-0 text-left hover:bg-surface-muted rounded-sm"
onClick={() => props.onOpenChangesTab(item.file)}
title={props.t("instanceShell.sessionChanges.actions.show")}
>
<div class="flex items-center justify-between gap-3">
<div
class="text-xs font-mono text-primary min-w-0 flex-1 overflow-hidden whitespace-nowrap"
title={item.file}
style="text-overflow: ellipsis; direction: rtl; text-align: left; unicode-bidi: plaintext;"
>
{item.file}
</div>
<div class="flex items-center gap-2 text-[11px] flex-shrink-0">
<span style={{ color: "var(--session-status-idle-fg)" }}>{`+${item.additions}`}</span>
<span style={{ color: "var(--session-status-working-fg)" }}>{`-${item.deletions}`}</span>
</div>
</div>
</button>
)}
</For>
</div>
</div>
</div>
)
}
const renderPlanSectionContent = () => {
const sessionId = props.activeSessionId()
if (!sessionId || sessionId === "info") {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.plan.noSessionSelected")}</span>
</div>
)
}
const todoState = props.latestTodoState()
if (!todoState) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.plan.empty")}</span>
</div>
)
}
return <TodoListView state={todoState} emptyLabel={props.t("instanceShell.plan.empty")} showStatusLabel={false} />
}
const renderBackgroundProcesses = () => {
const processes = props.backgroundProcessList()
if (processes.length === 0) {
return (
<div class="right-panel-empty right-panel-empty--left">
<span class="text-xs">{props.t("instanceShell.backgroundProcesses.empty")}</span>
</div>
)
}
return (
<div class="flex flex-col gap-2">
<For each={processes}>
{(process) => (
<div class="status-process-card">
<div class="status-process-header">
<span class="status-process-title">{process.title}</span>
<div class="status-process-meta">
<span>{props.t("instanceShell.backgroundProcesses.status", { status: process.status })}</span>
<Show when={typeof process.outputSizeBytes === "number"}>
<span>
{props.t("instanceShell.backgroundProcesses.output", {
sizeKb: Math.round((process.outputSizeBytes ?? 0) / 1024),
})}
</span>
</Show>
</div>
</div>
<div class="status-process-actions">
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => props.onOpenBackgroundOutput(process)}
aria-label={props.t("instanceShell.backgroundProcesses.actions.output")}
title={props.t("instanceShell.backgroundProcesses.actions.output")}
>
<TerminalSquare class="h-4 w-4" />
</button>
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
disabled={process.status !== "running"}
onClick={() => props.onStopBackgroundProcess(process.id)}
aria-label={props.t("instanceShell.backgroundProcesses.actions.stop")}
title={props.t("instanceShell.backgroundProcesses.actions.stop")}
>
<XOctagon class="h-4 w-4" />
</button>
<button
type="button"
class="button-tertiary w-full p-1 inline-flex items-center justify-center"
onClick={() => props.onTerminateBackgroundProcess(process.id)}
aria-label={props.t("instanceShell.backgroundProcesses.actions.terminate")}
title={props.t("instanceShell.backgroundProcesses.actions.terminate")}
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
)}
</For>
</div>
)
}
const statusSections = [
{
id: "session-changes",
labelKey: "instanceShell.rightPanel.sections.sessionChanges",
render: renderStatusSessionChanges,
},
{
id: "plan",
labelKey: "instanceShell.rightPanel.sections.plan",
render: renderPlanSectionContent,
},
{
id: "background-processes",
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
render: renderBackgroundProcesses,
},
{
id: "mcp",
labelKey: "instanceShell.rightPanel.sections.mcp",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["mcp"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
{
id: "lsp",
labelKey: "instanceShell.rightPanel.sections.lsp",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["lsp"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
{
id: "plugins",
labelKey: "instanceShell.rightPanel.sections.plugins",
render: () => (
<InstanceServiceStatus
initialInstance={props.instance}
sections={["plugins"]}
showSectionHeadings={false}
class="space-y-2"
/>
),
},
]
return (
<div class="status-tab-container">
<Show when={props.activeSession()}>
{(activeSession) => (
<ContextUsagePanel instanceId={props.instanceId} sessionId={activeSession().id} class="status-tab-context-panel" />
)}
</Show>
<Accordion.Root
class="right-panel-accordion"
collapsible
multiple
value={props.expandedItems()}
onChange={props.onExpandedItemsChange}
>
<For each={statusSections}>
{(section) => (
<Accordion.Item value={section.id} class="right-panel-accordion-item">
<Accordion.Header>
<Accordion.Trigger class="right-panel-accordion-trigger">
<span>{props.t(section.labelKey)}</span>
<ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="right-panel-accordion-content">{section.render()}</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion.Root>
</div>
)
}
export default StatusTab

View File

@@ -0,0 +1,5 @@
export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
export type DiffViewMode = "split" | "unified"
export type DiffContextMode = "expanded" | "collapsed"

View File

@@ -0,0 +1,92 @@
export const DEFAULT_SESSION_SIDEBAR_WIDTH = 340
export const MIN_SESSION_SIDEBAR_WIDTH = 220
export const MAX_SESSION_SIDEBAR_WIDTH = 400
export const RIGHT_DRAWER_WIDTH = 260
export const MIN_RIGHT_DRAWER_WIDTH = 200
export const MAX_RIGHT_DRAWER_WIDTH = 1200
export const LEFT_DRAWER_STORAGE_KEY = "opencode-session-sidebar-width-v8"
export const RIGHT_DRAWER_STORAGE_KEY = "opencode-session-right-drawer-width-v1"
export const LEFT_PIN_STORAGE_KEY = "opencode-session-left-drawer-pinned-v1"
export const RIGHT_PIN_STORAGE_KEY = "opencode-session-right-drawer-pinned-v1"
export const RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v2"
export const LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY = "opencode-session-right-panel-tab-v1"
export const RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-changes-split-width-v1"
export const RIGHT_PANEL_FILES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-files-split-width-v1"
export const RIGHT_PANEL_GIT_CHANGES_SPLIT_WIDTH_KEY = "opencode-session-right-panel-git-changes-split-width-v1"
export const RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-changes-list-open-nonphone-v1"
export const RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-changes-list-open-phone-v1"
export const RIGHT_PANEL_FILES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-files-list-open-nonphone-v1"
export const RIGHT_PANEL_FILES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-files-list-open-phone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-right-panel-git-changes-list-open-nonphone-v1"
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const clampWidth = (value: number) =>
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))
export const clampRightWidth = (value: number) => {
const windowMax = typeof window !== "undefined" ? Math.floor(window.innerWidth * 0.7) : MAX_RIGHT_DRAWER_WIDTH
const max = Math.max(MIN_RIGHT_DRAWER_WIDTH, windowMax)
return Math.min(max, Math.max(MIN_RIGHT_DRAWER_WIDTH, value))
}
const getPinStorageKey = (side: "left" | "right") => (side === "left" ? LEFT_PIN_STORAGE_KEY : RIGHT_PIN_STORAGE_KEY)
export function readStoredPinState(side: "left" | "right", defaultValue: boolean) {
if (typeof window === "undefined") return defaultValue
const stored = window.localStorage.getItem(getPinStorageKey(side))
if (stored === "true") return true
if (stored === "false") return false
return defaultValue
}
export function persistPinState(side: "left" | "right", value: boolean) {
if (typeof window === "undefined") return
window.localStorage.setItem(getPinStorageKey(side), value ? "true" : "false")
}
export function readStoredRightPanelTab(
defaultValue: "changes" | "git-changes" | "files" | "status",
): "changes" | "git-changes" | "files" | "status" {
if (typeof window === "undefined") return defaultValue
const stored = window.localStorage.getItem(RIGHT_PANEL_TAB_STORAGE_KEY)
if (stored === "status") return "status"
if (stored === "changes") return "changes"
if (stored === "git-changes") return "git-changes"
if (stored === "files") return "files"
// Migrate from v1 (where the stored values were the internal tab ids).
const legacy = window.localStorage.getItem(LEGACY_RIGHT_PANEL_TAB_STORAGE_KEY)
if (legacy === "status") return "status"
if (legacy === "browser") return "files"
if (legacy === "files") return "changes"
return defaultValue
}
export function readStoredPanelWidth(key: string, fallback: number) {
if (typeof window === "undefined") return fallback
const stored = window.localStorage.getItem(key)
if (!stored) return fallback
const parsed = Number.parseInt(stored, 10)
return Number.isFinite(parsed) ? parsed : fallback
}
export function readStoredBool(key: string): boolean | null {
if (typeof window === "undefined") return null
const stored = window.localStorage.getItem(key)
if (stored === "true") return true
if (stored === "false") return false
return null
}
export function readStoredEnum<T extends string>(key: string, allowed: readonly T[]): T | null {
if (typeof window === "undefined") return null
const stored = window.localStorage.getItem(key)
if (!stored) return null
return (allowed as readonly string[]).includes(stored) ? (stored as T) : null
}

View File

@@ -0,0 +1,3 @@
export type LayoutMode = "desktop" | "tablet" | "phone"
export type DrawerViewState = "pinned" | "floating-open" | "floating-closed"

View File

@@ -0,0 +1,260 @@
import {
batch,
createComponent,
createEffect,
createMemo,
createSignal,
onCleanup,
onMount,
type Accessor,
type JSX,
type Setter,
} from "solid-js"
import MenuIcon from "@suid/icons-material/Menu"
import type { TranslateParams } from "../../../lib/i18n"
import type { DrawerViewState, LayoutMode } from "./types"
import { persistPinState, readStoredPinState } from "./storage"
export interface UseDrawerChromeOptions {
t: (key: string, params?: TranslateParams) => string
layoutMode: Accessor<LayoutMode>
leftPinningSupported: Accessor<boolean>
rightPinningSupported: Accessor<boolean>
leftDrawerContentEl: Accessor<HTMLElement | null>
rightDrawerContentEl: Accessor<HTMLElement | null>
leftToggleButtonEl: Accessor<HTMLElement | null>
rightToggleButtonEl: Accessor<HTMLElement | null>
measureDrawerHost?: () => void
}
export interface DrawerChromeApi {
leftPinned: Accessor<boolean>
leftOpen: Accessor<boolean>
rightPinned: Accessor<boolean>
rightOpen: Accessor<boolean>
setLeftOpen: Setter<boolean>
setRightOpen: Setter<boolean>
leftDrawerState: Accessor<DrawerViewState>
rightDrawerState: Accessor<DrawerViewState>
pinLeft: () => void
unpinLeft: () => void
pinRight: () => void
unpinRight: () => void
closeLeft: () => void
closeRight: () => void
leftAppBarButtonLabel: Accessor<string>
rightAppBarButtonLabel: Accessor<string>
leftAppBarButtonIcon: Accessor<JSX.Element>
rightAppBarButtonIcon: Accessor<JSX.Element>
handleLeftAppBarButtonClick: () => void
handleRightAppBarButtonClick: () => void
}
export function useDrawerChrome(options: UseDrawerChromeOptions): DrawerChromeApi {
const [leftPinned, setLeftPinned] = createSignal(true)
const [leftOpen, setLeftOpen] = createSignal(true)
const [rightPinned, setRightPinned] = createSignal(true)
const [rightOpen, setRightOpen] = createSignal(true)
const measureDrawerHost = () => options.measureDrawerHost?.()
const focusTarget = (element: HTMLElement | null) => {
if (!element) return
requestAnimationFrame(() => {
element.focus()
})
}
const blurIfInside = (element: HTMLElement | null) => {
if (typeof document === "undefined" || !element) return
const active = document.activeElement as HTMLElement | null
if (active && element.contains(active)) {
active.blur()
}
}
const persistPinIfSupported = (side: "left" | "right", value: boolean) => {
if (side === "left" && !options.leftPinningSupported()) return
if (side === "right" && !options.rightPinningSupported()) return
persistPinState(side, value)
}
createEffect(() => {
switch (options.layoutMode()) {
case "desktop": {
const leftSaved = readStoredPinState("left", true)
const rightSaved = readStoredPinState("right", true)
setLeftPinned(leftSaved)
setLeftOpen(leftSaved)
setRightPinned(rightSaved)
setRightOpen(rightSaved)
break
}
case "tablet": {
setLeftPinned(true)
setLeftOpen(true)
setRightPinned(false)
setRightOpen(false)
break
}
default:
setLeftPinned(false)
setLeftOpen(false)
setRightPinned(false)
setRightOpen(false)
break
}
})
const leftDrawerState = createMemo<DrawerViewState>(() => {
if (leftPinned()) return "pinned"
return leftOpen() ? "floating-open" : "floating-closed"
})
const rightDrawerState = createMemo<DrawerViewState>(() => {
if (rightPinned()) return "pinned"
return rightOpen() ? "floating-open" : "floating-closed"
})
const leftAppBarButtonLabel = () => {
const state = leftDrawerState()
if (state === "pinned") return options.t("instanceShell.leftDrawer.toggle.pinned")
return options.t("instanceShell.leftDrawer.toggle.open")
}
const rightAppBarButtonLabel = () => {
const state = rightDrawerState()
if (state === "pinned") return options.t("instanceShell.rightDrawer.toggle.pinned")
return options.t("instanceShell.rightDrawer.toggle.open")
}
const leftAppBarButtonIcon = () => {
return createComponent(MenuIcon, { fontSize: "small" })
}
const rightAppBarButtonIcon = () => {
return createComponent(MenuIcon, { fontSize: "small", sx: { transform: "scaleX(-1)" } })
}
const pinLeft = () => {
blurIfInside(options.leftDrawerContentEl())
batch(() => {
setLeftPinned(true)
setLeftOpen(true)
})
persistPinIfSupported("left", true)
measureDrawerHost()
}
const unpinLeft = () => {
blurIfInside(options.leftDrawerContentEl())
batch(() => {
setLeftPinned(false)
setLeftOpen(true)
})
persistPinIfSupported("left", false)
measureDrawerHost()
}
const pinRight = () => {
blurIfInside(options.rightDrawerContentEl())
batch(() => {
setRightPinned(true)
setRightOpen(true)
})
persistPinIfSupported("right", true)
measureDrawerHost()
}
const unpinRight = () => {
blurIfInside(options.rightDrawerContentEl())
batch(() => {
setRightPinned(false)
setRightOpen(true)
})
persistPinIfSupported("right", false)
measureDrawerHost()
}
const handleLeftAppBarButtonClick = () => {
const state = leftDrawerState()
if (state !== "floating-closed") return
setLeftOpen(true)
measureDrawerHost()
}
const handleRightAppBarButtonClick = () => {
const state = rightDrawerState()
if (state !== "floating-closed") return
setRightOpen(true)
measureDrawerHost()
}
const closeLeft = () => {
if (leftDrawerState() === "pinned") return
blurIfInside(options.leftDrawerContentEl())
setLeftOpen(false)
focusTarget(options.leftToggleButtonEl())
}
const closeRight = () => {
if (rightDrawerState() === "pinned") return
blurIfInside(options.rightDrawerContentEl())
setRightOpen(false)
focusTarget(options.rightToggleButtonEl())
}
const closeFloatingDrawersIfAny = () => {
let handled = false
if (!leftPinned() && leftOpen()) {
setLeftOpen(false)
blurIfInside(options.leftDrawerContentEl())
focusTarget(options.leftToggleButtonEl())
handled = true
}
if (!rightPinned() && rightOpen()) {
setRightOpen(false)
blurIfInside(options.rightDrawerContentEl())
focusTarget(options.rightToggleButtonEl())
handled = true
}
return handled
}
onMount(() => {
if (typeof window === "undefined") return
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
if (!closeFloatingDrawersIfAny()) return
event.preventDefault()
event.stopPropagation()
}
window.addEventListener("keydown", handleEscape, true)
onCleanup(() => window.removeEventListener("keydown", handleEscape, true))
})
return {
leftPinned,
leftOpen,
rightPinned,
rightOpen,
setLeftOpen,
setRightOpen,
leftDrawerState,
rightDrawerState,
pinLeft,
unpinLeft,
pinRight,
unpinRight,
closeLeft,
closeRight,
leftAppBarButtonLabel,
rightAppBarButtonLabel,
leftAppBarButtonIcon,
rightAppBarButtonIcon,
handleLeftAppBarButtonClick,
handleRightAppBarButtonClick,
}
}

View File

@@ -0,0 +1,65 @@
import { createEffect, createSignal, type Accessor } from "solid-js"
type DrawerHostMeasure = {
setDrawerHost: (element: HTMLElement) => void
drawerContainer: () => HTMLElement | undefined
measureDrawerHost: () => void
floatingTopPx: () => string
floatingHeight: () => string
}
export function useDrawerHostMeasure(tabBarOffset: Accessor<number>): DrawerHostMeasure {
const [drawerHost, setDrawerHost] = createSignal<HTMLElement | null>(null)
const [floatingDrawerTop, setFloatingDrawerTop] = createSignal(0)
const [floatingDrawerHeight, setFloatingDrawerHeight] = createSignal(0)
const storeDrawerHost = (element: HTMLElement) => {
setDrawerHost(element)
}
const measureDrawerHost = () => {
if (typeof window === "undefined") return
const host = drawerHost()
if (!host) return
const rect = host.getBoundingClientRect()
setFloatingDrawerTop(rect.top)
setFloatingDrawerHeight(Math.max(0, rect.height))
}
createEffect(() => {
tabBarOffset()
if (typeof window === "undefined") return
requestAnimationFrame(() => measureDrawerHost())
})
const drawerContainer = () => {
const host = drawerHost()
if (host) return host
if (typeof document !== "undefined") {
return document.body
}
return undefined
}
const fallbackDrawerTop = () => tabBarOffset()
const floatingTop = () => {
const measured = floatingDrawerTop()
if (measured > 0) return measured
return fallbackDrawerTop()
}
const floatingTopPx = () => `${floatingTop()}px`
const floatingHeight = () => {
const measured = floatingDrawerHeight()
if (measured > 0) return `${measured}px`
return `calc(100% - ${floatingTop()}px)`
}
return {
setDrawerHost: storeDrawerHost,
drawerContainer,
measureDrawerHost,
floatingTopPx,
floatingHeight,
}
}

View File

@@ -0,0 +1,113 @@
import { createSignal, onCleanup, type Accessor, type Setter } from "solid-js"
import { useGlobalPointerDrag } from "./useGlobalPointerDrag"
type DrawerResizeSide = "left" | "right"
type DrawerResizeOptions = {
sessionSidebarWidth: Accessor<number>
rightDrawerWidth: Accessor<number>
setSessionSidebarWidth: Setter<number>
setRightDrawerWidth: Setter<number>
clampLeft: (width: number) => number
clampRight: (width: number) => number
measureDrawerHost: () => void
}
type DrawerResizeApi = {
handleDrawerResizeMouseDown: (side: DrawerResizeSide) => (event: MouseEvent) => void
handleDrawerResizeTouchStart: (side: DrawerResizeSide) => (event: TouchEvent) => void
}
export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
const [activeResizeSide, setActiveResizeSide] = createSignal<DrawerResizeSide | null>(null)
const [resizeStartX, setResizeStartX] = createSignal(0)
const [resizeStartWidth, setResizeStartWidth] = createSignal(0)
const scheduleDrawerMeasure = () => {
if (typeof window === "undefined") {
options.measureDrawerHost()
return
}
requestAnimationFrame(() => options.measureDrawerHost())
}
const applyDrawerWidth = (side: DrawerResizeSide, width: number) => {
if (side === "left") {
options.setSessionSidebarWidth(width)
} else {
options.setRightDrawerWidth(width)
}
scheduleDrawerMeasure()
}
const handleDrawerPointerMove = (clientX: number) => {
const side = activeResizeSide()
if (!side) return
const startWidth = resizeStartWidth()
const clamp = side === "left" ? options.clampLeft : options.clampRight
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
const nextWidth = clamp(startWidth + delta)
applyDrawerWidth(side, nextWidth)
}
function drawerMouseMove(event: MouseEvent) {
event.preventDefault()
handleDrawerPointerMove(event.clientX)
}
function drawerMouseUp() {
stopDrawerResize()
}
function drawerTouchMove(event: TouchEvent) {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
handleDrawerPointerMove(touch.clientX)
}
function drawerTouchEnd() {
stopDrawerResize()
}
const drawerPointerDrag = useGlobalPointerDrag({
onMouseMove: drawerMouseMove,
onMouseUp: drawerMouseUp,
onTouchMove: drawerTouchMove,
onTouchEnd: drawerTouchEnd,
})
function stopDrawerResize() {
setActiveResizeSide(null)
drawerPointerDrag.stop()
}
const startDrawerResize = (side: DrawerResizeSide, clientX: number) => {
setActiveResizeSide(side)
setResizeStartX(clientX)
setResizeStartWidth(side === "left" ? options.sessionSidebarWidth() : options.rightDrawerWidth())
drawerPointerDrag.start()
}
const handleDrawerResizeMouseDown = (side: DrawerResizeSide) => (event: MouseEvent) => {
event.preventDefault()
startDrawerResize(side, event.clientX)
}
const handleDrawerResizeTouchStart = (side: DrawerResizeSide) => (event: TouchEvent) => {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
startDrawerResize(side, touch.clientX)
}
onCleanup(() => {
stopDrawerResize()
})
return {
handleDrawerResizeMouseDown,
handleDrawerResizeTouchStart,
}
}

View File

@@ -0,0 +1,29 @@
type GlobalPointerDragHandlers = {
onMouseMove: (event: MouseEvent) => void
onMouseUp: (event: MouseEvent) => void
onTouchMove: (event: TouchEvent) => void
onTouchEnd: (event: TouchEvent) => void
}
type GlobalPointerDrag = {
start: () => void
stop: () => void
}
export function useGlobalPointerDrag(handlers: GlobalPointerDragHandlers): GlobalPointerDrag {
const start = () => {
document.addEventListener("mousemove", handlers.onMouseMove)
document.addEventListener("mouseup", handlers.onMouseUp)
document.addEventListener("touchmove", handlers.onTouchMove, { passive: false })
document.addEventListener("touchend", handlers.onTouchEnd)
}
const stop = () => {
document.removeEventListener("mousemove", handlers.onMouseMove)
document.removeEventListener("mouseup", handlers.onMouseUp)
document.removeEventListener("touchmove", handlers.onTouchMove)
document.removeEventListener("touchend", handlers.onTouchEnd)
}
return { start, stop }
}

View File

@@ -0,0 +1,173 @@
import { batch, createMemo, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import type { Session } from "../../../types/session"
import {
activeParentSessionId,
activeSessionId as activeSessionMap,
getSessionFamily,
getSessionInfo,
getSessionThreads,
sessions,
setActiveParentSession,
setActiveSession,
} from "../../../stores/sessions"
import { messageStoreBus } from "../../../stores/message-v2/bus"
import { getBackgroundProcesses } from "../../../stores/background-processes"
import type { LatestTodoSnapshot, SessionUsageState } from "../../../stores/message-v2/types"
type InstanceSessionContextOptions = {
instanceId: Accessor<string>
}
type InstanceSessionContextState = {
// Session collections and selections
allInstanceSessions: Accessor<Map<string, Session>>
sessionThreads: Accessor<ReturnType<typeof getSessionThreads>>
activeSessions: Accessor<Map<string, SessionFamilyMember>>
activeSessionIdForInstance: Accessor<string | null>
parentSessionIdForInstance: Accessor<string | null>
activeSessionForInstance: Accessor<SessionFamilyMember | null>
activeSessionDiffs: Accessor<SessionFamilyMember["diff"] | undefined>
// Usage / info summaries
activeSessionUsage: Accessor<SessionUsageState | null>
activeSessionInfoDetails: Accessor<ReturnType<typeof getSessionInfo> | null>
tokenStats: Accessor<{ used: number; avail: number | null }>
// Todo state
latestTodoSnapshot: Accessor<LatestTodoSnapshot | null>
latestTodoState: Accessor<ToolState | null>
// Background processes
backgroundProcessList: Accessor<ReturnType<typeof getBackgroundProcesses>>
// Controller
handleSessionSelect: (sessionId: string) => void
}
type SessionFamilyMember = ReturnType<typeof getSessionFamily>[number]
export function useInstanceSessionContext(options: InstanceSessionContextOptions): InstanceSessionContextState {
const messageStore = createMemo(() => messageStoreBus.getOrCreate(options.instanceId()))
const allInstanceSessions = createMemo<Map<string, Session>>(() => {
return sessions().get(options.instanceId()) ?? new Map()
})
const sessionThreads = createMemo(() => getSessionThreads(options.instanceId()))
const activeSessions = createMemo(() => {
const parentId = activeParentSessionId().get(options.instanceId())
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
const sessionFamily = getSessionFamily(options.instanceId(), parentId)
return new Map(sessionFamily.map((s) => [s.id, s]))
})
const activeSessionIdForInstance = createMemo(() => {
return activeSessionMap().get(options.instanceId()) || null
})
const parentSessionIdForInstance = createMemo(() => {
return activeParentSessionId().get(options.instanceId()) || null
})
const activeSessionForInstance = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
return activeSessions().get(sessionId) ?? null
})
const activeSessionDiffs = createMemo(() => {
const session = activeSessionForInstance()
return session?.diff
})
const activeSessionUsage = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId) return null
const store = messageStore()
return store?.getSessionUsage(sessionId) ?? null
})
const activeSessionInfoDetails = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId) return null
return getSessionInfo(options.instanceId(), sessionId) ?? null
})
const tokenStats = createMemo(() => {
const usage = activeSessionUsage()
const info = activeSessionInfoDetails()
return {
used: usage?.actualUsageTokens ?? info?.actualUsageTokens ?? 0,
avail: info?.contextAvailableTokens ?? null,
}
})
const latestTodoSnapshot = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
const store = messageStore()
if (!store) return null
const snapshot = store.state.latestTodos[sessionId]
return snapshot ?? null
})
const latestTodoState = createMemo<ToolState | null>(() => {
const snapshot = latestTodoSnapshot()
if (!snapshot) return null
const store = messageStore()
if (!store) return null
const message = store.getMessage(snapshot.messageId)
if (!message) return null
const partRecord = message.parts?.[snapshot.partId]
const part = partRecord?.data as { type?: string; tool?: string; state?: ToolState }
if (!part || part.type !== "tool" || part.tool !== "todowrite") return null
const state = part.state
if (!state || state.status !== "completed") return null
return state
})
const backgroundProcessList = createMemo(() => getBackgroundProcesses(options.instanceId()))
const handleSessionSelect = (sessionId: string) => {
const instanceId = options.instanceId()
if (sessionId === "info") {
setActiveSession(instanceId, sessionId)
return
}
const session = allInstanceSessions().get(sessionId)
if (!session) return
if (session.parentId === null) {
setActiveParentSession(instanceId, sessionId)
return
}
const parentId = session.parentId
if (!parentId) return
batch(() => {
setActiveParentSession(instanceId, parentId)
setActiveSession(instanceId, sessionId)
})
}
return {
allInstanceSessions,
sessionThreads,
activeSessions,
activeSessionIdForInstance,
parentSessionIdForInstance,
activeSessionForInstance,
activeSessionDiffs,
activeSessionUsage,
activeSessionInfoDetails,
tokenStats,
latestTodoSnapshot,
latestTodoState,
backgroundProcessList,
handleSessionSelect,
}
}

View File

@@ -0,0 +1,99 @@
import { createEffect, createSignal, type Accessor } from "solid-js"
import { messageStoreBus } from "../../../stores/message-v2/bus"
import { clearSessionRenderCache } from "../../message-block"
import { getLogger } from "../../../lib/logger"
const log = getLogger("session")
const SESSION_CACHE_LIMIT = 5
type SessionCacheOptions = {
instanceId: Accessor<string>
instanceSessions: Accessor<Map<string, unknown>>
activeSessionId: Accessor<string | null>
}
type SessionCacheState = {
cachedSessionIds: Accessor<string[]>
}
export function useSessionCache(options: SessionCacheOptions): SessionCacheState {
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
const evictSession = (sessionId: string) => {
if (!sessionId) return
const instanceId = options.instanceId()
log.info("Evicting cached session", { instanceId, sessionId })
const store = messageStoreBus.getInstance(instanceId)
store?.clearSession(sessionId)
clearSessionRenderCache(instanceId, sessionId)
}
const scheduleEvictions = (ids: string[]) => {
if (!ids.length) return
setPendingEvictions((current) => {
const existing = new Set(current)
const next = [...current]
ids.forEach((id) => {
if (!existing.has(id)) {
next.push(id)
existing.add(id)
}
})
return next
})
}
createEffect(() => {
const pending = pendingEvictions()
if (!pending.length) return
const cached = new Set(cachedSessionIds())
const remaining: string[] = []
pending.forEach((id) => {
if (cached.has(id)) {
remaining.push(id)
} else {
evictSession(id)
}
})
if (remaining.length !== pending.length) {
setPendingEvictions(remaining)
}
})
createEffect(() => {
const instanceSessions = options.instanceSessions()
const activeId = options.activeSessionId()
setCachedSessionIds((current) => {
const next = current.filter((id) => id !== "info" && instanceSessions.has(id))
const touch = (id: string | null) => {
if (!id || id === "info") return
if (!instanceSessions.has(id)) return
const index = next.indexOf(id)
if (index !== -1) {
next.splice(index, 1)
}
next.unshift(id)
}
touch(activeId)
const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next
const trimmedSet = new Set(trimmed)
const removed = current.filter((id) => !trimmedSet.has(id))
if (removed.length) {
scheduleEvictions(removed)
}
return trimmed
})
})
return {
cachedSessionIds,
}
}

View File

@@ -0,0 +1,109 @@
import { createEffect, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import {
SESSION_SIDEBAR_EVENT,
type SessionSidebarRequestAction,
type SessionSidebarRequestDetail,
} from "../../../lib/session-sidebar-events"
interface PendingSidebarAction {
action: SessionSidebarRequestAction
id: number
}
interface UseSessionSidebarRequestsOptions {
instanceId: Accessor<string>
sidebarContentEl: Accessor<HTMLElement | null>
leftPinned: Accessor<boolean>
leftOpen: Accessor<boolean>
setLeftOpen: (next: boolean) => void
measureDrawerHost: () => void
}
export function useSessionSidebarRequests(options: UseSessionSidebarRequestsOptions) {
let sidebarActionId = 0
const [pendingSidebarAction, setPendingSidebarAction] = createSignal<PendingSidebarAction | null>(null)
const triggerKeyboardEvent = (target: HTMLElement, options: { key: string; code: string; keyCode: number }) => {
target.dispatchEvent(
new KeyboardEvent("keydown", {
key: options.key,
code: options.code,
keyCode: options.keyCode,
which: options.keyCode,
bubbles: true,
cancelable: true,
}),
)
}
const focusAgentSelectorControl = () => {
const agentTrigger = options.sidebarContentEl()?.querySelector("[data-agent-selector]") as HTMLElement | null
if (!agentTrigger) return false
agentTrigger.focus()
setTimeout(() => triggerKeyboardEvent(agentTrigger, { key: "Enter", code: "Enter", keyCode: 13 }), 10)
return true
}
const focusModelSelectorControl = () => {
const input = options.sidebarContentEl()?.querySelector<HTMLInputElement>("[data-model-selector]")
if (!input) return false
input.focus()
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
return true
}
const focusVariantSelectorControl = () => {
const input = options.sidebarContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
if (!input) return false
input.focus()
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
return true
}
createEffect(() => {
const pending = pendingSidebarAction()
if (!pending) return
const action = pending.action
const contentReady = Boolean(options.sidebarContentEl())
if (!contentReady) {
return
}
if (action === "show-session-list") {
setPendingSidebarAction(null)
return
}
const handled =
action === "focus-agent-selector"
? focusAgentSelectorControl()
: action === "focus-model-selector"
? focusModelSelectorControl()
: focusVariantSelectorControl()
if (handled) {
setPendingSidebarAction(null)
}
})
const handleSidebarRequest = (action: SessionSidebarRequestAction) => {
setPendingSidebarAction({ action, id: sidebarActionId++ })
if (!options.leftPinned() && !options.leftOpen()) {
options.setLeftOpen(true)
options.measureDrawerHost()
}
}
onMount(() => {
if (typeof window === "undefined") return
const handler = (event: Event) => {
const detail = (event as CustomEvent<SessionSidebarRequestDetail>).detail
if (!detail || detail.instanceId !== options.instanceId()) return
handleSidebarRequest(detail.action)
}
window.addEventListener(SESSION_SIDEBAR_EVENT, handler)
onCleanup(() => window.removeEventListener(SESSION_SIDEBAR_EVENT, handler))
})
return {
handleSidebarRequest,
pendingSidebarAction,
}
}

View File

@@ -1,5 +1,5 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown"
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities, setMarkdownTheme } from "../lib/markdown"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger"
@@ -72,6 +72,9 @@ export function Markdown(props: MarkdownProps) {
createEffect(async () => {
const { part, text, themeKey, highlightEnabled, version } = resolved()
// Ensure the markdown highlighter theme matches the active UI theme.
setMarkdownTheme(themeKey === "dark")
latestRequestedText = text
const cacheMatches = (cache: RenderCache | undefined) => {
@@ -171,6 +174,8 @@ export function Markdown(props: MarkdownProps) {
const { part, text, themeKey, version } = resolved()
setMarkdownTheme(themeKey === "dark")
if (latestRequestedText !== text) {
return
}

View File

@@ -1,5 +1,5 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { FoldVertical } from "lucide-solid"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -11,6 +11,8 @@ import { messageStoreBus } from "../stores/message-v2/bus"
import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions"
import { useI18n } from "../lib/i18n"
const TOOL_ICON = "🔧"
@@ -172,21 +174,256 @@ messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
interface ContentDisplayItem {
type: "content"
key: string
record: MessageRecord
parts: ClientPart[]
messageInfo?: MessageInfo
isQueued: boolean
showAgentMeta?: boolean
messageId: string
startPartId: string
}
interface ToolDisplayItem {
type: "tool"
key: string
toolPart: ToolCallPart
messageInfo?: MessageInfo
messageId: string
messageVersion: number
partVersion: number
partId: string
}
interface MessageContentItemProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageId: string
startPartId: string
messageIndex: number
lastAssistantIndex: () => number
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
onContentRendered?: () => void
}
function MessageContentItem(props: MessageContentItemProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const isQueued = createMemo(() => {
const current = record()
if (!current) return false
if (current.role !== "user") return false
const lastAssistant = props.lastAssistantIndex()
return lastAssistant === -1 || props.messageIndex > lastAssistant
})
const parts = createMemo<ClientPart[]>(() => {
const current = record()
if (!current) return []
const ids = current.partIds
const startIndex = ids.indexOf(props.startPartId)
if (startIndex === -1) return []
const resolved: ClientPart[] = []
for (let idx = startIndex; idx < ids.length; idx++) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
if (
part.type === "tool" ||
part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
break
}
resolved.push(part)
}
return resolved
})
const showAgentMeta = createMemo(() => {
const current = record()
if (!current) return false
if (current.role !== "assistant") return false
const currentParts = parts()
if (!currentParts.some((part) => partHasRenderableText(part))) {
return false
}
const ids = current.partIds
const startIndex = ids.indexOf(props.startPartId)
if (startIndex === -1) return false
// Only show agent meta on the first content segment that contains renderable content.
for (let idx = 0; idx < startIndex; idx++) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
if (
part.type === "tool" ||
part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
continue
}
if (partHasRenderableText(part)) {
return false
}
}
return true
})
return (
<Show when={record()}>
{(resolvedRecord) => (
<MessageItem
record={resolvedRecord()}
messageInfo={messageInfo()}
parts={parts()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={isQueued()}
showAgentMeta={showAgentMeta()}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
)}
</Show>
)
}
interface ToolCallItemProps {
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageId: string
partId: string
onContentRendered?: () => void
}
function ToolCallItem(props: ToolCallItemProps) {
const { t } = useI18n()
const [deleting, setDeleting] = createSignal(false)
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const partEntry = createMemo(() => record()?.parts?.[props.partId])
const toolPart = createMemo(() => {
const part = partEntry()?.data as ClientPart | undefined
if (!part || part.type !== "tool") return undefined
return part as ToolCallPart
})
const toolState = createMemo(() => toolPart()?.state as ToolState | undefined)
const toolName = createMemo(() => toolPart()?.tool || "")
const messageVersion = createMemo(() => record()?.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 state = toolState()
if (!state) return ""
if (!(isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))) {
return ""
}
return extractTaskSessionId(state)
})
const taskLocation = createMemo(() => {
const id = taskSessionId()
if (!id) return null
return findTaskSessionLocation(id, props.instanceId)
})
const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
const location = taskLocation()
if (!location) return
navigateToTaskSession(location)
}
const handleDeleteToolPart = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (deleteDisabled()) return
setDeleting(true)
try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) {
showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), {
title: t("messageBlock.tool.deletePart.failed.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeleting(false)
}
}
return (
<Show when={toolPart()}>
{(resolvedToolPart) => (
<>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<span class="tool-call-icon">{TOOL_ICON}</span>
<span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
</div>
<div class="flex items-center gap-2">
<Show when={taskSessionId()}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation()}
onClick={handleGoToTaskSession}
title={t("messageBlock.tool.goToSession.label")}
aria-label={t("messageBlock.tool.goToSession.label")}
>
<ExternalLink class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<button
class="tool-call-header-button"
type="button"
disabled={deleteDisabled()}
onClick={handleDeleteToolPart}
title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</div>
<ToolCall
toolCall={resolvedToolPart()}
toolCallId={props.partId}
messageId={props.messageId}
messageVersion={messageVersion()}
partVersion={partVersion()}
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onContentRendered}
/>
</>
)}
</Show>
)
}
interface StepDisplayItem {
@@ -204,6 +441,8 @@ type ReasoningDisplayItem = {
messageInfo?: MessageInfo
showAgentMeta?: boolean
defaultExpanded: boolean
messageId: string
partId: string
}
type CompactionDisplayItem = {
@@ -212,6 +451,8 @@ type CompactionDisplayItem = {
part: ClientPart
messageInfo?: MessageInfo
accentColor?: string
messageId: string
partId: string
}
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
@@ -272,7 +513,6 @@ export default function MessageBlock(props: MessageBlockProps) {
const items: MessageBlockItem[] = []
const blockContentKeys: string[] = []
const blockToolKeys: string[] = []
let segmentIndex = 0
let pendingParts: ClientPart[] = []
let agentMetaAttached = current.role !== "assistant"
const defaultAccentColor = current.role === "user" ? USER_BORDER_COLOR : ASSISTANT_BORDER_COLOR
@@ -280,34 +520,28 @@ export default function MessageBlock(props: MessageBlockProps) {
const flushContent = () => {
if (pendingParts.length === 0) return
const segmentKey = `${current.id}:segment:${segmentIndex}`
segmentIndex += 1
const shouldShowAgentMeta =
current.role === "assistant" &&
!agentMetaAttached &&
pendingParts.some((part) => partHasRenderableText(part))
const startPartId = typeof (pendingParts[0] as any)?.id === "string" ? ((pendingParts[0] as any).id as string) : ""
if (!startPartId) {
pendingParts = []
return
}
if (!agentMetaAttached && pendingParts.some((part) => partHasRenderableText(part))) {
agentMetaAttached = true
}
const segmentKey = `${current.id}:content:${startPartId}`
let cached = sessionCache.messageItems.get(segmentKey)
if (!cached) {
cached = {
type: "content",
key: segmentKey,
record: current,
parts: pendingParts.slice(),
messageInfo: info,
isQueued,
showAgentMeta: shouldShowAgentMeta,
messageId: current.id,
startPartId,
}
sessionCache.messageItems.set(segmentKey, cached)
} else {
cached.record = current
cached.parts = pendingParts.slice()
cached.messageInfo = info
cached.isQueued = isQueued
cached.showAgentMeta = shouldShowAgentMeta
}
if (shouldShowAgentMeta) {
agentMetaAttached = true
}
items.push(cached)
blockContentKeys.push(segmentKey)
lastAccentColor = defaultAccentColor
@@ -317,28 +551,26 @@ export default function MessageBlock(props: MessageBlockProps) {
orderedParts.forEach((part, partIndex) => {
if (part.type === "tool") {
flushContent()
const partVersion = typeof (part as any).revision === "number" ? (part as any).revision : 0
const messageVersion = current.revision
const key = `${current.id}:${part.id ?? partIndex}`
const partId = part.id
if (!partId) {
// Tool parts are required to have ids; if one slips through, skip rendering
// to avoid unstable keys and accidental remount cascades.
return
}
const key = `${current.id}:${partId}`
let toolItem = sessionCache.toolItems.get(key)
if (!toolItem) {
toolItem = {
type: "tool",
key,
toolPart: part as ToolCallPart,
messageInfo: info,
messageId: current.id,
messageVersion,
partVersion,
partId,
}
sessionCache.toolItems.set(key, toolItem)
} else {
toolItem.key = key
toolItem.toolPart = part as ToolCallPart
toolItem.messageInfo = info
toolItem.messageId = current.id
toolItem.messageVersion = messageVersion
toolItem.partVersion = partVersion
toolItem.partId = partId
}
items.push(toolItem)
blockToolKeys.push(key)
@@ -348,7 +580,8 @@ export default function MessageBlock(props: MessageBlockProps) {
if (part.type === "compaction") {
flushContent()
const key = `${current.id}:${part.id ?? partIndex}:compaction`
const partId = part.id ?? ""
const key = `${current.id}:${partId || partIndex}:compaction`
const isAuto = Boolean((part as any)?.auto)
items.push({
type: "compaction",
@@ -356,6 +589,8 @@ export default function MessageBlock(props: MessageBlockProps) {
part,
messageInfo: info,
accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR,
messageId: current.id,
partId,
})
lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR
return
@@ -380,7 +615,8 @@ export default function MessageBlock(props: MessageBlockProps) {
if (part.type === "reasoning") {
flushContent()
if (props.showThinking() && reasoningHasRenderableContent(part)) {
const key = `${current.id}:${part.id ?? partIndex}:reasoning`
const partId = part.id ?? ""
const key = `${current.id}:${partId || partIndex}:reasoning`
const showAgentMeta = current.role === "assistant" && !agentMetaAttached
if (showAgentMeta) {
agentMetaAttached = true
@@ -392,6 +628,8 @@ export default function MessageBlock(props: MessageBlockProps) {
messageInfo: info,
showAgentMeta,
defaultExpanded: props.thinkingDefaultExpanded(),
messageId: current.id,
partId,
})
lastAccentColor = ASSISTANT_BORDER_COLOR
}
@@ -427,21 +665,21 @@ export default function MessageBlock(props: MessageBlockProps) {
})
return (
<Show when={block()} keyed>
<Show when={block()}>
{(resolvedBlock) => (
<div class="message-stream-block" data-message-id={resolvedBlock.record.id}>
<For each={resolvedBlock.items}>
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}>
<For each={resolvedBlock().items}>
{(item) => (
<Switch>
<Match when={item.type === "content"}>
<MessageItem
record={(item as ContentDisplayItem).record}
messageInfo={(item as ContentDisplayItem).messageInfo}
parts={(item as ContentDisplayItem).parts}
<MessageContentItem
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={(item as ContentDisplayItem).isQueued}
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
store={props.store}
messageId={(item as ContentDisplayItem).messageId}
startPartId={(item as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
@@ -450,46 +688,14 @@ export default function MessageBlock(props: MessageBlockProps) {
<Match when={item.type === "tool"}>
{(() => {
const toolItem = item as ToolDisplayItem
const toolState = toolItem.toolPart.state as ToolState | undefined
const hasToolState =
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId, props.instanceId) : null
const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!taskLocation) return
navigateToTaskSession(taskLocation)
}
return (
<div class="tool-call-message" data-key={toolItem.key}>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<span class="tool-call-icon">{TOOL_ICON}</span>
<span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolItem.toolPart.tool || t("messageBlock.tool.unknown")}</span>
</div>
<Show when={taskSessionId}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation}
onClick={handleGoToTaskSession}
title={!taskLocation ? t("messageBlock.tool.goToSession.unavailableTitle") : t("messageBlock.tool.goToSession.title")}
>
{t("messageBlock.tool.goToSession.label")}
</button>
</Show>
</div>
<ToolCall
toolCall={toolItem.toolPart}
toolCallId={toolItem.toolPart.id}
messageId={toolItem.messageId}
messageVersion={toolItem.messageVersion}
partVersion={toolItem.partVersion}
<ToolCallItem
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageId={toolItem.messageId}
partId={toolItem.partId}
onContentRendered={props.onContentRendered}
/>
</div>
@@ -497,7 +703,12 @@ export default function MessageBlock(props: MessageBlockProps) {
})()}
</Match>
<Match when={item.type === "step-start"}>
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
<StepCard
kind="start"
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
showAgentMeta
/>
</Match>
<Match when={item.type === "step-finish"}>
<StepCard
@@ -509,7 +720,15 @@ export default function MessageBlock(props: MessageBlockProps) {
/>
</Match>
<Match when={item.type === "compaction"}>
<CompactionCard part={(item as CompactionDisplayItem).part} messageInfo={(item as CompactionDisplayItem).messageInfo} borderColor={(item as CompactionDisplayItem).accentColor} />
<CompactionCard
part={(item as CompactionDisplayItem).part}
messageInfo={(item as CompactionDisplayItem).messageInfo}
borderColor={(item as CompactionDisplayItem).accentColor}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item as CompactionDisplayItem).messageId}
partId={(item as CompactionDisplayItem).partId}
/>
</Match>
<Match when={item.type === "reasoning"}>
<ReasoningCard
@@ -517,6 +736,8 @@ export default function MessageBlock(props: MessageBlockProps) {
messageInfo={(item as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={(item as ReasoningDisplayItem).messageId}
partId={(item as ReasoningDisplayItem).partId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
/>
@@ -539,8 +760,19 @@ interface StepCardProps {
borderColor?: string
}
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
interface CompactionCardProps {
part: ClientPart
messageInfo?: MessageInfo
borderColor?: string
instanceId: string
sessionId: string
messageId: string
partId: string
}
function CompactionCard(props: CompactionCardProps) {
const { t } = useI18n()
const [deleting, setDeleting] = createSignal(false)
const isAuto = () => Boolean((props.part as any)?.auto)
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
@@ -548,13 +780,43 @@ function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; bo
const containerClass = () =>
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
const canDelete = () => Boolean(props.partId) && !deleting()
const handleDelete = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canDelete()) return
setDeleting(true)
try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeleting(false)
}
}
return (
<div
class={containerClass()}
class={`${containerClass()} relative`}
style={{ "border-left": `4px solid ${borderColor()}` }}
role="status"
aria-label={t("messageBlock.compaction.ariaLabel")}
>
<button
type="button"
class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2"
disabled={!canDelete()}
onClick={handleDelete}
title={t("messagePart.actions.deleteTitle")}
>
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
</button>
<div class="message-compaction-row">
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
<span class="message-compaction-label">{label()}</span>
@@ -609,6 +871,7 @@ function StepCard(props: StepCardProps) {
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [
{ label: t("messageBlock.usage.input"), value: usage.input, formatter: formatTokenTotal },
@@ -674,6 +937,8 @@ interface ReasoningCardProps {
messageInfo?: MessageInfo
instanceId: string
sessionId: string
messageId: string
partId: string
showAgentMeta?: boolean
defaultExpanded?: boolean
}
@@ -681,6 +946,7 @@ interface ReasoningCardProps {
function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deleting, setDeleting] = createSignal(false)
createEffect(() => {
setExpanded(Boolean(props.defaultExpanded))
@@ -744,39 +1010,92 @@ function ReasoningCard(props: ReasoningCardProps) {
const toggle = () => setExpanded((prev) => !prev)
const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
const hasDeleteTarget = () => Boolean(props.partId)
const canDelete = () => hasDeleteTarget() && !deleting()
const handleDelete = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canDelete()) return
setDeleting(true)
try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId)
} catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeleting(false)
}
}
return (
<div class="message-reasoning-card">
<button
type="button"
class="message-reasoning-toggle"
onClick={toggle}
aria-expanded={expanded()}
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
>
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</Show>
</span>
<span class="message-reasoning-meta">
<span class="message-reasoning-indicator">
{expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")}
<div class="message-reasoning-header">
<button
type="button"
class="message-reasoning-toggle"
onClick={toggle}
aria-expanded={expanded()}
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
>
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</Show>
</span>
</button>
<div class="message-reasoning-actions">
<button
type="button"
class="message-action-button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggle()
}}
aria-label={viewHideLabel()}
title={viewHideLabel()}
>
<Show when={expanded()} fallback={<ChevronsUpDown class="w-3.5 h-3.5" aria-hidden="true" />}>
<ChevronsDownUp class="w-3.5 h-3.5" aria-hidden="true" />
</Show>
</button>
<Show when={hasDeleteTarget()}>
<button
type="button"
class="message-action-button"
onClick={handleDelete}
disabled={!canDelete()}
aria-label={t("messagePart.actions.deleteTitle")}
title={t("messagePart.actions.deleteTitle")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<span class="message-reasoning-time">{timestamp()}</span>
</span>
</button>
</div>
</div>
<Show when={expanded()}>
<div class="message-reasoning-expanded">

View File

@@ -1,10 +1,13 @@
import { For, Show, createSignal } from "solid-js"
import { Copy, Split, Trash2, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions"
interface MessageItemProps {
record: MessageRecord
@@ -22,6 +25,7 @@ interface MessageItemProps {
export default function MessageItem(props: MessageItemProps) {
const { t } = useI18n()
const [copied, setCopied] = createSignal(false)
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set())
const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
@@ -137,8 +141,17 @@ export default function MessageItem(props: MessageItemProps) {
}
const isGenerating = () => {
if (hasContent()) {
return false
}
// Prefer the local record status for streaming placeholders.
if (!isUser() && props.record.status === "streaming") {
return true
}
const info = props.messageInfo
return !hasContent() && info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0
return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
}
const handleRevert = () => {
@@ -147,6 +160,8 @@ export default function MessageItem(props: MessageItemProps) {
}
}
const copyLabel = () => (copied() ? t("messageItem.actions.copied") : t("messageItem.actions.copy"))
const getRawContent = () => {
return props.parts
.filter(part => part.type === "text")
@@ -163,7 +178,51 @@ export default function MessageItem(props: MessageItemProps) {
setTimeout(() => setCopied(false), 2000)
}
if (!isUser() && !hasContent()) {
const deletableTextPartId = () => {
const part = props.parts.find((candidate) => {
if (!candidate || candidate.type !== "text") return false
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 {
await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId)
} catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setPartDeleting(partId, false)
}
}
if (!isUser() && !hasContent() && !isGenerating()) {
return null
}
@@ -209,62 +268,84 @@ export default function MessageItem(props: MessageItemProps) {
return (
<div class={containerClass()}>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="message-speaker">
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
</div>
<div class="message-item-actions">
<Show when={isUser()}>
<div class="message-action-group">
<Show when={props.onRevert}>
<button
class="message-action-button"
onClick={handleRevert}
title={t("messageItem.actions.revertTitle")}
aria-label={t("messageItem.actions.revertTitle")}
>
{t("messageItem.actions.revert")}
</button>
</Show>
<Show when={props.onFork}>
<button
class="message-action-button"
onClick={() => props.onFork?.(props.record.id)}
title={t("messageItem.actions.forkTitle")}
aria-label={t("messageItem.actions.forkTitle")}
>
{t("messageItem.actions.fork")}
</button>
</Show>
<button
class="message-action-button"
onClick={handleCopy}
title={t("messageItem.actions.copyTitle")}
aria-label={t("messageItem.actions.copyTitle")}
>
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
{t("messageItem.actions.copied")}
<div class="message-item-header-row message-item-header-row--top">
<div class="message-speaker">
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
</div>
<div class="message-item-actions">
<Show when={isUser()}>
<div class="message-action-group">
<Show when={props.onRevert}>
<button
class="message-action-button"
onClick={handleRevert}
title={t("messageItem.actions.revert")}
aria-label={t("messageItem.actions.revert")}
>
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</button>
</div>
</Show>
<Show when={!isUser()}>
<button
class="message-action-button"
onClick={handleCopy}
title={t("messageItem.actions.copyTitle")}
aria-label={t("messageItem.actions.copyTitle")}
>
<Show when={copied()} fallback={t("messageItem.actions.copy")}>
{t("messageItem.actions.copied")}
</Show>
</button>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
<Show when={props.onFork}>
<button
class="message-action-button"
onClick={() => props.onFork?.(props.record.id)}
title={t("messageItem.actions.fork")}
aria-label={t("messageItem.actions.fork")}
>
<Split class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<button
class="message-action-button"
onClick={handleCopy}
title={copyLabel()}
aria-label={copyLabel()}
>
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</Show>
<Show when={!isUser()}>
<div class="message-action-group">
<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={deletableTextPartId()}>
{(partId) => (
<button
class="message-action-button"
onClick={() => void handleDeletePart(partId())}
disabled={isDeletingPart(partId())}
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")}
>
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button>
)}
</Show>
</div>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
</div>
<Show when={agentMeta()}>
{(meta) => (
<div class="message-item-header-row message-item-header-row--bottom">
<span class="message-agent-meta">{meta()}</span>
</div>
)}
</Show>
</header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
@@ -328,6 +409,19 @@ export default function MessageItem(props: MessageItemProps) {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<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} />

View File

@@ -3,7 +3,7 @@ import Kbd from "./kbd"
import { useI18n } from "../lib/i18n"
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-primary/70"
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
interface MessageListHeaderProps {
usedTokens: number

View File

@@ -15,7 +15,7 @@ interface MessagePartProps {
sessionId: string
onRendered?: () => void
}
export default function MessagePart(props: MessagePartProps) {
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
@@ -25,6 +25,14 @@ interface MessagePartProps {
const isAssistantMessage = () => props.messageType === "assistant"
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
const shouldHideTextPart = () => {
const part = props.part
if (!part || part.type !== "text") return false
// Keep optimistic user prompts visible; hide synthetic assistant text.
return Boolean((part as any).synthetic) && props.messageType !== "user"
}
const plainTextContent = () => {
const part = props.part
@@ -94,23 +102,23 @@ interface MessagePartProps {
return (
<Switch>
<Match when={partType() === "text"}>
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div class={textContainerClass()}>
<Show
when={isAssistantMessage()}
fallback={<span>{plainTextContent()}</span>}
>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
<Show
when={isAssistantMessage()}
fallback={<span class="text-primary">{plainTextContent()}</span>}
>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
</div>
</Show>
</Match>

View File

@@ -7,6 +7,8 @@ import { getSessionInfo } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import { useI18n } from "../lib/i18n"
import { copyToClipboard } from "../lib/clipboard"
import { showToastNotification } from "../lib/notifications"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
const SCROLL_SCOPE = "session"
@@ -94,6 +96,9 @@ export default function MessageSection(props: MessageSectionProps) {
const seenTimelineMessageIds = new Set<string>()
const seenTimelineSegmentKeys = new Set<string>()
const timelinePartCountsByMessageId = new Map<string, number>()
let pendingTimelineMessagePartUpdates = new Set<string>()
let pendingTimelinePartUpdateFrame: number | null = null
function makeTimelineKey(segment: TimelineSegment) {
return `${segment.messageId}:${segment.id}:${segment.type}`
@@ -102,6 +107,7 @@ export default function MessageSection(props: MessageSectionProps) {
function seedTimeline() {
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
const ids = untrack(messageIds)
const resolvedStore = untrack(store)
const segments: TimelineSegment[] = []
@@ -109,6 +115,7 @@ export default function MessageSection(props: MessageSectionProps) {
const record = resolvedStore.getMessage(messageId)
if (!record) return
seenTimelineMessageIds.add(messageId)
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
const built = buildTimelineSegments(props.instanceId, record, t)
built.forEach((segment) => {
const key = makeTimelineKey(segment)
@@ -123,6 +130,7 @@ export default function MessageSection(props: MessageSectionProps) {
function appendTimelineForMessage(messageId: string) {
const record = untrack(() => store().getMessage(messageId))
if (!record) return
timelinePartCountsByMessageId.set(messageId, record.partIds.length)
const built = buildTimelineSegments(props.instanceId, record, t)
if (built.length === 0) return
const newSegments: TimelineSegment[] = []
@@ -375,7 +383,9 @@ export default function MessageSection(props: MessageSectionProps) {
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
const shellRect = shell.getBoundingClientRect()
const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8)
const maxLeft = Math.max(shell.clientWidth - 180, 8)
// Keep the popover within the stream shell. The quote popover currently
// renders 3 actions; keep enough horizontal room for the pill.
const maxLeft = Math.max(shell.clientWidth - 260, 8)
const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft)
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
}
@@ -394,6 +404,24 @@ export default function MessageSection(props: MessageSectionProps) {
selection?.removeAllRanges()
}
}
async function handleCopySelectionRequest() {
const info = quoteSelection()
if (!info) return
const success = await copyToClipboard(info.text)
showToastNotification({
message: success ? t("messageSection.quote.copied") : t("messageSection.quote.copyFailed"),
variant: success ? "success" : "error",
duration: success ? 2000 : 6000,
})
clearQuoteSelection()
if (typeof window !== "undefined") {
const selection = window.getSelection()
selection?.removeAllRanges()
}
}
function handleContentRendered() {
if (props.loading) {
@@ -468,8 +496,6 @@ export default function MessageSection(props: MessageSectionProps) {
})
let previousTimelineIds: string[] = []
let previousLastTimelineMessageId: string | null = null
let previousLastTimelinePartCount = 0
createEffect(() => {
const loading = Boolean(props.loading)
@@ -477,11 +503,15 @@ export default function MessageSection(props: MessageSectionProps) {
if (loading) {
previousTimelineIds = []
previousLastTimelineMessageId = null
previousLastTimelinePartCount = 0
setTimelineSegments([])
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
timelinePartCountsByMessageId.clear()
pendingTimelineMessagePartUpdates.clear()
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
return
}
@@ -523,6 +553,14 @@ export default function MessageSection(props: MessageSectionProps) {
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
// Keep part count tracking in sync with id replacement.
const existingPartCount = timelinePartCountsByMessageId.get(oldId)
if (existingPartCount !== undefined) {
timelinePartCountsByMessageId.delete(oldId)
timelinePartCountsByMessageId.set(newId, existingPartCount)
}
previousTimelineIds = ids.slice()
return
}
@@ -546,30 +584,95 @@ export default function MessageSection(props: MessageSectionProps) {
previousTimelineIds = ids.slice()
})
function clearPendingTimelinePartUpdateFrame() {
if (pendingTimelinePartUpdateFrame !== null) {
cancelAnimationFrame(pendingTimelinePartUpdateFrame)
pendingTimelinePartUpdateFrame = null
}
}
function scheduleTimelinePartUpdateFlush() {
if (pendingTimelinePartUpdateFrame !== null) return
pendingTimelinePartUpdateFrame = requestAnimationFrame(() => {
pendingTimelinePartUpdateFrame = null
if (pendingTimelineMessagePartUpdates.size === 0) return
const changedIds = Array.from(pendingTimelineMessagePartUpdates)
pendingTimelineMessagePartUpdates = new Set<string>()
const ids = messageIds()
const resolvedStore = store()
setTimelineSegments((prev) => {
let next = prev
for (const changedId of changedIds) {
// Remove old segments for this message.
next = next.filter((segment) => segment.messageId !== changedId)
const record = resolvedStore.getMessage(changedId)
const rebuilt = record ? buildTimelineSegments(props.instanceId, record, t) : []
// Insert rebuilt segments in the correct place based on session message order.
if (rebuilt.length > 0) {
let insertAt = next.length
const changedIndex = ids.indexOf(changedId)
if (changedIndex >= 0) {
for (let i = changedIndex + 1; i < ids.length; i++) {
const followingId = ids[i]
const existingIndex = next.findIndex((segment) => segment.messageId === followingId)
if (existingIndex >= 0) {
insertAt = existingIndex
break
}
}
}
next = [...next.slice(0, insertAt), ...rebuilt, ...next.slice(insertAt)]
}
}
// Rebuild the segment key set since we may have removed/replaced segments.
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
})
}
// Keep timeline segments in sync when message parts are added/removed.
// Part deletion does not remove message ids from the session, so we must
// explicitly replace segments for messages whose part count changed.
createEffect(() => {
if (props.loading) return
const ids = messageIds()
if (ids.length === 0) return
const lastId = ids[ids.length - 1]
if (!lastId) return
const record = store().getMessage(lastId)
if (!record) return
const partCount = record.partIds.length
if (lastId === previousLastTimelineMessageId && partCount === previousLastTimelinePartCount) {
return
const resolvedStore = store()
let hasChanges = false
for (const messageId of ids) {
const record = resolvedStore.getMessage(messageId)
const partCount = record?.partIds.length ?? 0
const previousCount = timelinePartCountsByMessageId.get(messageId)
if (previousCount === undefined) {
timelinePartCountsByMessageId.set(messageId, partCount)
continue
}
if (previousCount !== partCount) {
timelinePartCountsByMessageId.set(messageId, partCount)
pendingTimelineMessagePartUpdates.add(messageId)
hasChanges = true
}
}
previousLastTimelineMessageId = lastId
previousLastTimelinePartCount = partCount
const built = buildTimelineSegments(props.instanceId, record, t)
const newSegments: TimelineSegment[] = []
built.forEach((segment) => {
const key = makeTimelineKey(segment)
if (seenTimelineSegmentKeys.has(key)) return
seenTimelineSegmentKeys.add(key)
newSegments.push(segment)
})
if (newSegments.length > 0) {
setTimelineSegments((prev) => [...prev, ...newSegments])
// Drop tracking for ids that are no longer present.
for (const trackedId of Array.from(timelinePartCountsByMessageId.keys())) {
if (!ids.includes(trackedId)) {
timelinePartCountsByMessageId.delete(trackedId)
}
}
if (hasChanges) {
scheduleTimelinePartUpdateFlush()
}
})
@@ -736,6 +839,7 @@ export default function MessageSection(props: MessageSectionProps) {
cancelAnimationFrame(pendingAnchorScroll)
}
clearScrollToBottomFrames()
clearPendingTimelinePartUpdateFrame()
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
}
@@ -835,6 +939,9 @@ export default function MessageSection(props: MessageSectionProps) {
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
{t("messageSection.quote.addAsCode")}
</button>
<button type="button" class="message-quote-button" onClick={() => void handleCopySelectionRequest()}>
{t("messageSection.quote.copy")}
</button>
</div>
</div>
)}

View File

@@ -276,6 +276,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
let hoverTimer: number | null = null
let closeTimer: number | null = null
const showTools = () => props.showToolSegments ?? true
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
@@ -292,10 +293,30 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
hoverTimer = null
}
}
const clearCloseTimer = () => {
if (closeTimer !== null && typeof window !== "undefined") {
window.clearTimeout(closeTimer)
closeTimer = null
}
}
const scheduleClose = () => {
if (typeof window === "undefined") return
clearHoverTimer()
clearCloseTimer()
// Small delay so the pointer can travel from the segment to the tooltip.
closeTimer = window.setTimeout(() => {
closeTimer = null
setHoveredSegment(null)
setHoverAnchorRect(null)
}, 160)
}
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
if (typeof window === "undefined") return
clearHoverTimer()
clearCloseTimer()
const target = event.currentTarget as HTMLButtonElement
hoverTimer = window.setTimeout(() => {
const rect = target.getBoundingClientRect()
@@ -305,9 +326,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}
const handleMouseLeave = () => {
clearHoverTimer()
setHoveredSegment(null)
setHoverAnchorRect(null)
scheduleClose()
}
createEffect(() => {
@@ -326,7 +345,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
setTooltipCoords({ top: clampedTop, left: clampedLeft })
})
onCleanup(() => clearHoverTimer())
onCleanup(() => {
clearHoverTimer()
clearCloseTimer()
})
createEffect(() => {
const activeId = props.activeMessageId
@@ -432,6 +454,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
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}

View File

@@ -6,7 +6,6 @@ import type { Model } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
import Kbd from "./kbd"
const log = getLogger("session")
@@ -295,15 +294,12 @@ export default function ModelSelector(props: ModelSelectorProps) {
<span class="selector-trigger-primary selector-trigger-primary--align-left">
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
</span>
{currentModelValue() && (
{currentModelValue() && (
<span class="selector-trigger-secondary">
{currentModelValue()!.providerId}/{currentModelValue()!.id}
</span>
)}
</div>
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
<Kbd shortcut="cmd+shift+m" />
</span>
<Combobox.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Combobox.Icon>

View File

@@ -0,0 +1,232 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect, createResource } from "solid-js"
import { showToastNotification } from "../lib/notifications"
import {
getOsNotificationCapability,
requestOsNotificationPermission,
type OsNotificationPermission,
} from "../lib/os-notifications"
import { useConfig } from "../stores/preferences"
interface NotificationsSettingsModalProps {
open: boolean
onClose: () => void
}
function formatPermissionLabel(permission: OsNotificationPermission): string {
switch (permission) {
case "granted":
return "Granted"
case "denied":
return "Denied"
case "default":
return "Not granted"
case "unsupported":
return "Unsupported"
default:
return String(permission)
}
}
const NotificationsSettingsModal: Component<NotificationsSettingsModalProps> = (props) => {
const { preferences, updatePreferences } = useConfig()
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
createEffect(() => {
if (props.open) {
void refetch()
}
})
const handleEnableToggle = async (enabled: boolean) => {
if (!enabled) {
updatePreferences({ osNotificationsEnabled: false })
return
}
const cap = capability()
if (cap && !cap.supported) {
showToastNotification({
title: "Notifications",
message: cap.info ?? "OS notifications are not supported in this environment.",
variant: "warning",
})
updatePreferences({ osNotificationsEnabled: false })
return
}
const permission = await requestOsNotificationPermission()
if (permission !== "granted") {
showToastNotification({
title: "Notifications",
message:
permission === "denied"
? "Notification permission denied. Enable notifications in your system/browser settings."
: "Notification permission not granted.",
variant: "warning",
})
updatePreferences({ osNotificationsEnabled: false })
return
}
updatePreferences({ osNotificationsEnabled: true })
void refetch()
}
const handleRequestPermission = async () => {
const cap = capability()
if (cap && !cap.supported) {
showToastNotification({
title: "Notifications",
message: cap.info ?? "Notifications are not supported in this environment.",
variant: "warning",
})
return
}
const permission = await requestOsNotificationPermission()
if (permission === "granted") {
showToastNotification({
title: "Notifications",
message: "Permission granted. You can now enable notifications.",
variant: "success",
duration: 6000,
})
void refetch()
return
}
showToastNotification({
title: "Notifications",
message:
permission === "denied"
? "Permission denied. You may need to enable notifications in your system/browser settings."
: "Permission not granted.",
variant: "warning",
})
void refetch()
}
const supported = () => capability()?.supported ?? false
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported")
const infoMessage = () => capability()?.info
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-xl max-h-[90vh] flex flex-col overflow-hidden">
<header class="px-6 py-4 border-b" style={{ "border-color": "var(--border-base)" }}>
<Dialog.Title class="text-xl font-semibold text-primary">Notifications</Dialog.Title>
</header>
<div class="flex-1 overflow-y-auto p-6 space-y-6">
<div class="panel">
<div class="panel-header">
<h3 class="panel-title">Session Status Notifications</h3>
</div>
<div class="panel-body space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-semibold text-primary">Enable</div>
<div class="text-xs text-secondary">Permission: {permissionLabel()}</div>
</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().osNotificationsEnabled)}
disabled={!supported() && capability.state === "ready"}
onChange={(e) => void handleEnableToggle(e.currentTarget.checked)}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
<div class="flex items-center justify-between gap-4">
<div class="text-sm text-primary">Request permission</div>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
onClick={() => void handleRequestPermission()}
>
Request
</button>
</div>
</Show>
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-semibold text-primary">Notify when app is focused</div>
</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
disabled={!preferences().osNotificationsEnabled}
onChange={(e) => updatePreferences({ osNotificationsAllowWhenVisible: e.currentTarget.checked })}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
<Show when={Boolean(infoMessage())}>
<div class="text-xs text-secondary">{infoMessage()}</div>
</Show>
<Show when={!supported() && capability.state === "ready"}>
<div class="text-xs text-secondary">
Notifications are not supported in this environment. The bell icon stays disabled.
</div>
</Show>
<div class="border-t pt-4" style={{ "border-color": "var(--border-base)" }}>
<div class="text-sm font-semibold text-primary mb-2">Notify me when</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-4">
<div class="text-sm text-primary">Session needs input</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().notifyOnNeedsInput)}
disabled={!preferences().osNotificationsEnabled}
onChange={(e) => updatePreferences({ notifyOnNeedsInput: e.currentTarget.checked })}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
<div class="flex items-center justify-between gap-4">
<div class="text-sm text-primary">Session becomes idle</div>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(preferences().notifyOnIdle)}
disabled={!preferences().osNotificationsEnabled}
onChange={(e) => updatePreferences({ notifyOnIdle: e.currentTarget.checked })}
/>
<span class="text-sm">Enabled</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t flex justify-end" style={{ "border-color": "var(--border-base)" }}>
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
Close
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default NotificationsSettingsModal

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
import { For, Show, type Component } from "solid-js"
import { Expand } from "lucide-solid"
import type { Attachment } from "../../types/attachment"
import { useI18n } from "../../lib/i18n"
interface PromptAttachmentsBarProps {
attachments: Attachment[]
onRemoveAttachment: (attachmentId: string) => void
onExpandTextAttachment: (attachmentId: string) => void
}
const PromptAttachmentsBar: Component<PromptAttachmentsBarProps> = (props) => {
const { t } = useI18n()
return (
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
<For each={props.attachments}>
{(attachment) => {
const isText = attachment.source.type === "text"
return (
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
<span class="font-mono">{attachment.display}</span>
<Show when={isText}>
<button
type="button"
class="attachment-expand"
onClick={() => props.onExpandTextAttachment(attachment.id)}
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
title={t("sessionView.attachments.insertPastedTextTitle")}
>
<Expand class="h-3 w-3" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="attachment-remove"
onClick={() => props.onRemoveAttachment(attachment.id)}
aria-label={t("sessionView.attachments.removeAriaLabel")}
>
×
</button>
</div>
)
}}
</For>
</div>
)
}
export default PromptAttachmentsBar

View File

@@ -0,0 +1,72 @@
import type { Attachment } from "../../types/attachment"
export function formatPastedPlaceholder(value: string | number) {
return `[pasted #${value}]`
}
export function formatImagePlaceholder(value: string | number) {
return `[Image #${value}]`
}
export function createPastedPlaceholderRegex() {
return /\[pasted #(\d+)\]/g
}
export function createImagePlaceholderRegex() {
return /\[Image #(\d+)\]/g
}
export function createMentionRegex() {
return /@(\S+)/g
}
export const pastedDisplayCounterRegex = /pasted #(\d+)/
export const imageDisplayCounterRegex = /Image #(\d+)/
export const bracketedImageDisplayCounterRegex = /\[Image #(\d+)\]/
export function parseCounter(value: string) {
const parsed = Number.parseInt(value, 10)
return Number.isNaN(parsed) ? null : parsed
}
export function findHighestAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
let highestPaste = 0
let highestImage = 0
for (const match of currentPrompt.matchAll(createPastedPlaceholderRegex())) {
const parsed = parseCounter(match[1])
if (parsed !== null) {
highestPaste = Math.max(highestPaste, parsed)
}
}
for (const attachment of sessionAttachments) {
if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
const parsed = parseCounter(placeholderMatch[1])
if (parsed !== null) {
highestPaste = Math.max(highestPaste, parsed)
}
}
}
if (attachment.source.type === "file" && attachment.mediaType.startsWith("image/")) {
const imageMatch = attachment.display.match(imageDisplayCounterRegex)
if (imageMatch) {
const parsed = parseCounter(imageMatch[1])
if (parsed !== null) {
highestImage = Math.max(highestImage, parsed)
}
}
}
}
for (const match of currentPrompt.matchAll(createImagePlaceholderRegex())) {
const parsed = parseCounter(match[1])
if (parsed !== null) {
highestImage = Math.max(highestImage, parsed)
}
}
return { highestPaste, highestImage }
}

View File

@@ -0,0 +1,26 @@
import type { Attachment } from "../../types/attachment"
export type PromptMode = "normal" | "shell"
export type ExpandState = "normal" | "expanded"
export type PickerMode = "mention" | "command"
export type PromptInsertMode = "quote" | "code"
export interface PromptInputApi {
insertSelection(text: string, mode: PromptInsertMode): void
expandTextAttachment(attachmentId: string): void
setPromptText(text: string, opts?: { focus?: boolean }): void
focus(): void
}
export interface PromptInputProps {
instanceId: string
instanceFolder: string
sessionId: string
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
onRunShell?: (command: string) => Promise<void>
disabled?: boolean
escapeInDebounce?: boolean
isSessionBusy?: boolean
onAbortSession?: () => Promise<void>
registerPromptInputApi?: (api: PromptInputApi) => void | (() => void)
}

View File

@@ -0,0 +1,296 @@
import { createSignal, type Accessor } from "solid-js"
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
import { createFileAttachment, createTextAttachment } from "../../types/attachment"
import type { Attachment } from "../../types/attachment"
import {
bracketedImageDisplayCounterRegex,
findHighestAttachmentCounters,
formatImagePlaceholder,
formatPastedPlaceholder,
pastedDisplayCounterRegex,
} from "./attachmentPlaceholders"
type PromptAttachmentsOptions = {
instanceId: Accessor<string>
sessionId: Accessor<string>
instanceFolder: Accessor<string>
prompt: Accessor<string>
setPrompt: (value: string) => void
getTextarea: () => HTMLTextAreaElement | null
}
type PromptAttachments = {
attachments: Accessor<Attachment[]>
pasteCount: Accessor<number>
imageCount: Accessor<number>
syncAttachmentCounters: (promptText: string, sessionAttachments: Attachment[]) => void
handlePaste: (e: ClipboardEvent) => Promise<void>
isDragging: Accessor<boolean>
handleDragOver: (e: DragEvent) => void
handleDragLeave: (e: DragEvent) => void
handleDrop: (e: DragEvent) => void
handleRemoveAttachment: (attachmentId: string) => void
handleExpandTextAttachment: (attachment: Attachment) => void
}
export function usePromptAttachments(options: PromptAttachmentsOptions): PromptAttachments {
const attachments = () => getAttachments(options.instanceId(), options.sessionId())
const [isDragging, setIsDragging] = createSignal(false)
const [pasteCount, setPasteCount] = createSignal(0)
const [imageCount, setImageCount] = createSignal(0)
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
const { highestPaste, highestImage } = findHighestAttachmentCounters(currentPrompt, sessionAttachments)
setPasteCount(highestPaste)
setImageCount(highestImage)
}
function handleRemoveAttachment(attachmentId: string) {
const currentAttachments = attachments()
const attachment = currentAttachments.find((a) => a.id === attachmentId)
removeAttachment(options.instanceId(), options.sessionId(), attachmentId)
if (attachment) {
const currentPrompt = options.prompt()
let newPrompt = currentPrompt
if (attachment.source.type === "file") {
if (attachment.mediaType.startsWith("image/")) {
const imageMatch = attachment.display.match(bracketedImageDisplayCounterRegex)
if (imageMatch) {
const placeholder = formatImagePlaceholder(imageMatch[1])
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
}
} else {
const filename = attachment.filename
newPrompt = currentPrompt.replace(`@${filename}`, "").replace(/\s+/g, " ").trim()
}
} else if (attachment.source.type === "agent") {
const agentName = attachment.filename
newPrompt = currentPrompt.replace(`@${agentName}`, "").replace(/\s+/g, " ").trim()
} else if (attachment.source.type === "text") {
const placeholderMatch = attachment.display.match(pastedDisplayCounterRegex)
if (placeholderMatch) {
const placeholder = formatPastedPlaceholder(placeholderMatch[1])
newPrompt = currentPrompt.replace(placeholder, "").replace(/\s+/g, " ").trim()
}
}
options.setPrompt(newPrompt)
}
}
function handleExpandTextAttachment(attachment: Attachment) {
if (attachment.source.type !== "text") return
const textarea = options.getTextarea()
const value = attachment.source.value
const match = attachment.display.match(pastedDisplayCounterRegex)
const placeholder = match ? formatPastedPlaceholder(match[1]) : null
const currentText = options.prompt()
let nextText = currentText
let selectionTarget: number | null = null
if (placeholder) {
const placeholderIndex = currentText.indexOf(placeholder)
if (placeholderIndex !== -1) {
nextText =
currentText.substring(0, placeholderIndex) +
value +
currentText.substring(placeholderIndex + placeholder.length)
selectionTarget = placeholderIndex + value.length
}
}
if (nextText === currentText) {
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
nextText = currentText.substring(0, start) + value + currentText.substring(end)
selectionTarget = start + value.length
} else {
nextText = currentText + value
}
}
options.setPrompt(nextText)
removeAttachment(options.instanceId(), options.sessionId(), attachment.id)
if (textarea) {
setTimeout(() => {
textarea.focus()
if (selectionTarget !== null) {
textarea.setSelectionRange(selectionTarget, selectionTarget)
}
}, 0)
}
}
async function handlePaste(e: ClipboardEvent) {
const items = e.clipboardData?.items
if (!items) return
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.startsWith("image/")) {
e.preventDefault()
const blob = item.getAsFile()
if (!blob) continue
const count = imageCount() + 1
setImageCount(count)
const reader = new FileReader()
reader.onload = () => {
const base64Data = (reader.result as string).split(",")[1]
const display = formatImagePlaceholder(count)
const filename = `image-${count}.png`
const attachment = createFileAttachment(
filename,
filename,
"image/png",
new TextEncoder().encode(base64Data),
options.instanceFolder(),
)
attachment.url = `data:image/png;base64,${base64Data}`
attachment.display = display
addAttachment(options.instanceId(), options.sessionId(), attachment)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const placeholder = formatImagePlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
}
}
reader.readAsDataURL(blob)
return
}
}
const pastedText = e.clipboardData?.getData("text/plain")
if (!pastedText) return
const lineCount = pastedText.split("\n").length
const charCount = pastedText.length
const isLongPaste = charCount > 150 || lineCount > 3
if (isLongPaste) {
e.preventDefault()
const count = pasteCount() + 1
setPasteCount(count)
const summary = lineCount > 1 ? `${lineCount} lines` : `${charCount} chars`
const display = `pasted #${count} (${summary})`
const filename = `paste-${count}.txt`
const attachment = createTextAttachment(pastedText, display, filename)
addAttachment(options.instanceId(), options.sessionId(), attachment)
const textarea = options.getTextarea()
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
const currentText = options.prompt()
const placeholder = formatPastedPlaceholder(count)
const newText = currentText.substring(0, start) + placeholder + currentText.substring(end)
options.setPrompt(newText)
setTimeout(() => {
const newCursorPos = start + placeholder.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
}
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
}
function handleDragLeave(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
}
function handleDrop(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const files = e.dataTransfer?.files
if (!files || files.length === 0) return
for (let i = 0; i < files.length; i++) {
const file = files[i]
const path = (file as File & { path?: string }).path || file.name
const filename = file.name
const mime = file.type || "text/plain"
const createAndStoreAttachment = (previewUrl?: string) => {
const attachment = createFileAttachment(path, filename, mime, undefined, options.instanceFolder())
if (previewUrl && (mime.startsWith("image/") || mime.startsWith("text/"))) {
attachment.url = previewUrl
}
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
if (mime.startsWith("image/") && typeof FileReader !== "undefined") {
const reader = new FileReader()
reader.onload = () => {
const result = typeof reader.result === "string" ? reader.result : undefined
createAndStoreAttachment(result)
}
reader.readAsDataURL(file)
} else if (mime.startsWith("text/") && typeof FileReader !== "undefined") {
const reader = new FileReader()
reader.onload = () => {
const dataUrl = typeof reader.result === "string" ? reader.result : undefined
createAndStoreAttachment(dataUrl)
}
reader.readAsDataURL(file)
} else {
createAndStoreAttachment()
}
}
options.getTextarea()?.focus()
}
return {
attachments,
pasteCount,
imageCount,
syncAttachmentCounters,
handlePaste,
isDragging,
handleDragOver,
handleDragLeave,
handleDrop,
handleRemoveAttachment,
handleExpandTextAttachment,
}
}

View File

@@ -0,0 +1,272 @@
import type { Accessor } from "solid-js"
import type { Attachment } from "../../types/attachment"
import type { PromptMode } from "./types"
import {
createImagePlaceholderRegex,
createMentionRegex,
createPastedPlaceholderRegex,
} from "./attachmentPlaceholders"
export type UsePromptKeyDownOptions = {
getTextarea: () => HTMLTextAreaElement | null
prompt: Accessor<string>
setPrompt: (v: string) => void
mode: Accessor<PromptMode>
setMode: (m: PromptMode) => void
isPickerOpen: Accessor<boolean>
closePicker: () => void
ignoredAtPositions: Accessor<Set<number>>
setIgnoredAtPositions: (next: Set<number> | ((s: Set<number>) => Set<number>)) => void
getAttachments: Accessor<Attachment[]>
removeAttachment: (attachmentId: string) => void
submitOnEnter: Accessor<boolean>
onSend: () => void
selectPreviousHistory: (force?: boolean) => boolean
selectNextHistory: (force?: boolean) => boolean
}
export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
const insertNewlineAtCursor = () => {
const textarea = options.getTextarea()
const current = options.prompt()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const nextValue = current.substring(0, start) + "\n" + current.substring(end)
const nextCursor = start + 1
options.setPrompt(nextValue)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (!nextTextarea) return
nextTextarea.focus()
nextTextarea.setSelectionRange(nextCursor, nextCursor)
}, 0)
}
return function handleKeyDown(e: KeyboardEvent) {
const textarea = options.getTextarea()
if (!textarea) return
const currentText = options.prompt()
const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
const isShellMode = options.mode() === "shell"
if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !textarea.disabled) {
e.preventDefault()
options.setMode("shell")
return
}
if (options.isPickerOpen() && e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
options.closePicker()
return
}
if (isShellMode) {
if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
options.setMode("normal")
return
}
if (e.key === "Backspace" && cursorAtBufferStart && currentText.length === 0) {
e.preventDefault()
options.setMode("normal")
return
}
}
if (e.key === "Backspace" || e.key === "Delete") {
const cursorPos = textarea.selectionStart
const text = currentText
const pastePlaceholderRegex = createPastedPlaceholderRegex()
let pasteMatch
while ((pasteMatch = pastePlaceholderRegex.exec(text)) !== null) {
const placeholderStart = pasteMatch.index
const placeholderEnd = pasteMatch.index + pasteMatch[0].length
const pasteNumber = pasteMatch[1]
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
const isSelected =
textarea.selectionStart <= placeholderStart &&
textarea.selectionEnd >= placeholderEnd &&
textarea.selectionStart !== textarea.selectionEnd
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
e.preventDefault()
const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find(
(a) => a.source.type === "text" && a.display.includes(`pasted #${pasteNumber}`),
)
if (attachment) {
options.removeAttachment(attachment.id)
}
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
options.setPrompt(newText)
setTimeout(() => {
textarea.setSelectionRange(placeholderStart, placeholderStart)
}, 0)
return
}
}
const imagePlaceholderRegex = createImagePlaceholderRegex()
let imageMatch
while ((imageMatch = imagePlaceholderRegex.exec(text)) !== null) {
const placeholderStart = imageMatch.index
const placeholderEnd = imageMatch.index + imageMatch[0].length
const imageNumber = imageMatch[1]
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === placeholderEnd
const isDeletingFromStart = e.key === "Delete" && cursorPos === placeholderStart
const isSelected =
textarea.selectionStart <= placeholderStart &&
textarea.selectionEnd >= placeholderEnd &&
textarea.selectionStart !== textarea.selectionEnd
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
e.preventDefault()
const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find(
(a) => a.source.type === "file" && a.mediaType.startsWith("image/") && a.display.includes(`Image #${imageNumber}`),
)
if (attachment) {
options.removeAttachment(attachment.id)
}
const newText = text.substring(0, placeholderStart) + text.substring(placeholderEnd)
options.setPrompt(newText)
setTimeout(() => {
textarea.setSelectionRange(placeholderStart, placeholderStart)
}, 0)
return
}
}
const mentionRegex = createMentionRegex()
let mentionMatch
while ((mentionMatch = mentionRegex.exec(text)) !== null) {
const mentionStart = mentionMatch.index
const mentionEnd = mentionMatch.index + mentionMatch[0].length
const name = mentionMatch[1]
const isDeletingFromEnd = e.key === "Backspace" && cursorPos === mentionEnd
const isDeletingFromStart = e.key === "Delete" && cursorPos === mentionStart
const isSelected =
textarea.selectionStart <= mentionStart &&
textarea.selectionEnd >= mentionEnd &&
textarea.selectionStart !== textarea.selectionEnd
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find(
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
)
if (attachment) {
e.preventDefault()
options.removeAttachment(attachment.id)
options.setIgnoredAtPositions((prev) => {
const next = new Set(prev)
next.delete(mentionStart)
return next
})
const newText = text.substring(0, mentionStart) + text.substring(mentionEnd)
options.setPrompt(newText)
setTimeout(() => {
textarea.setSelectionRange(mentionStart, mentionStart)
}, 0)
return
}
}
}
}
if (e.key === "Enter") {
const isModified = e.metaKey || e.ctrlKey
// If the picker is open, Enter should select from it.
if (!isModified && options.isPickerOpen()) {
return
}
if (options.submitOnEnter()) {
// Swapped mode: Enter submits, Cmd/Ctrl+Enter inserts a newline.
if (isModified) {
e.preventDefault()
e.stopPropagation()
insertNewlineAtCursor()
return
}
// Shift+Enter always inserts a newline.
if (e.shiftKey) {
// If the picker is open, avoid selecting an item on Enter.
if (options.isPickerOpen()) {
e.stopPropagation()
}
return
}
e.preventDefault()
options.onSend()
return
}
// Default: Cmd/Ctrl+Enter submits.
if (isModified) {
e.preventDefault()
if (options.isPickerOpen()) {
options.closePicker()
}
options.onSend()
return
}
}
if (e.key === "ArrowUp") {
const handled = options.selectPreviousHistory()
if (handled) {
e.preventDefault()
return
}
}
if (e.key === "ArrowDown") {
const handled = options.selectNextHistory()
if (handled) {
e.preventDefault()
return
}
}
}
}

View File

@@ -0,0 +1,272 @@
import { createSignal, type Accessor, type Setter } from "solid-js"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { Agent } from "../../types/session"
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
import { addAttachment, getAttachments } from "../../stores/attachments"
import type { PickerMode } from "./types"
type PickerItem =
| { type: "agent"; agent: Agent }
| { type: "file"; file: { path: string; relativePath?: string; isGitFile: boolean; isDirectory?: boolean } }
| { type: "command"; command: SDKCommand }
type PromptPickerOptions = {
instanceId: Accessor<string>
sessionId: Accessor<string>
instanceFolder: Accessor<string>
prompt: Accessor<string>
setPrompt: (value: string) => void
getTextarea: () => HTMLTextAreaElement | null
instanceAgents: Accessor<Agent[]>
commands: Accessor<SDKCommand[]>
}
type PromptPickerController = {
showPicker: Accessor<boolean>
pickerMode: Accessor<PickerMode>
searchQuery: Accessor<string>
atPosition: Accessor<number | null>
ignoredAtPositions: Accessor<Set<number>>
setShowPicker: Setter<boolean>
setPickerMode: Setter<PickerMode>
setSearchQuery: Setter<string>
setAtPosition: Setter<number | null>
setIgnoredAtPositions: Setter<Set<number>>
handleInput: (e: Event) => void
handlePickerSelect: (item: PickerItem) => void
handlePickerClose: () => void
}
export function usePromptPicker(options: PromptPickerOptions): PromptPickerController {
const [showPicker, setShowPicker] = createSignal(false)
const [pickerMode, setPickerMode] = createSignal<PickerMode>("mention")
const [searchQuery, setSearchQuery] = createSignal("")
const [atPosition, setAtPosition] = createSignal<number | null>(null)
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set<number>())
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement
const value = target.value
options.setPrompt(value)
const cursorPos = target.selectionStart
// Slash command picker (only when editing the command token: "/<query>")
if (value.startsWith("/") && cursorPos >= 1) {
const firstWhitespaceIndex = value.slice(1).search(/\s/)
const tokenEnd = firstWhitespaceIndex === -1 ? value.length : firstWhitespaceIndex + 1
if (cursorPos <= tokenEnd) {
setPickerMode("command")
setAtPosition(0)
setSearchQuery(value.substring(1, cursorPos))
setShowPicker(true)
return
}
}
const textBeforeCursor = value.substring(0, cursorPos)
const lastAtIndex = textBeforeCursor.lastIndexOf("@")
const previousAtPosition = atPosition()
if (lastAtIndex === -1) {
setIgnoredAtPositions(new Set<number>())
} else if (previousAtPosition !== null && lastAtIndex !== previousAtPosition) {
setIgnoredAtPositions((prev) => {
const next = new Set(prev)
next.delete(previousAtPosition)
return next
})
}
if (lastAtIndex !== -1) {
const textAfterAt = value.substring(lastAtIndex + 1, cursorPos)
const hasSpace = textAfterAt.includes(" ") || textAfterAt.includes("\n")
if (!hasSpace && cursorPos === lastAtIndex + textAfterAt.length + 1) {
if (!ignoredAtPositions().has(lastAtIndex)) {
setPickerMode("mention")
setAtPosition(lastAtIndex)
setSearchQuery(textAfterAt)
setShowPicker(true)
}
return
}
}
setShowPicker(false)
setAtPosition(null)
}
function handlePickerSelect(item: PickerItem) {
const textarea = options.getTextarea()
if (item.type === "command") {
const name = item.command.name
const currentPrompt = options.prompt()
const afterSlash = currentPrompt.slice(1)
const firstWhitespaceIndex = afterSlash.search(/\s/)
const tokenEnd = firstWhitespaceIndex === -1 ? currentPrompt.length : firstWhitespaceIndex + 1
const before = ""
const after = currentPrompt.substring(tokenEnd)
const newPrompt = before + `/${name} ` + after
options.setPrompt(newPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = `/${name} `.length
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
nextTextarea.focus()
}
}, 0)
} else if (item.type === "agent") {
const agentName = item.agent.name
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "agent" && att.source.name === agentName,
)
if (!alreadyAttached) {
const attachment = createAgentAttachment(agentName)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
if (pos !== null) {
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
const attachmentText = `@${agentName}`
const newPrompt = before + attachmentText + " " + after
options.setPrompt(newPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + attachmentText.length + 1
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
} else if (item.type === "file") {
const displayPath = item.file.path
const relativePath = item.file.relativePath ?? displayPath
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
if (isFolder) {
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
const folderMention =
relativePath === "." || relativePath === ""
? "/"
: relativePath.replace(/\/+$/, "") + "/"
if (pos !== null) {
const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos)
const newPrompt = before + folderMention + after
options.setPrompt(newPrompt)
setSearchQuery(folderMention)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + 1 + folderMention.length
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
return
}
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
const pathSegments = normalizedPath.split("/")
const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
return candidate === "." ? "/" : candidate
})()
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPath,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPath,
filename,
"text/plain",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
if (pos !== null) {
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
const attachmentText = `@${normalizedPath}`
const newPrompt = before + attachmentText + " " + after
options.setPrompt(newPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + attachmentText.length + 1
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
}
setShowPicker(false)
setAtPosition(null)
setSearchQuery("")
textarea?.focus()
}
function handlePickerClose() {
const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
}
setShowPicker(false)
setAtPosition(null)
setSearchQuery("")
setTimeout(() => options.getTextarea()?.focus(), 0)
}
return {
showPicker,
pickerMode,
searchQuery,
atPosition,
ignoredAtPositions,
setShowPicker,
setPickerMode,
setSearchQuery,
setAtPosition,
setIgnoredAtPositions,
handleInput,
handlePickerSelect,
handlePickerClose,
}
}

View File

@@ -0,0 +1,203 @@
import { createEffect, createSignal, on, onCleanup, onMount, type Accessor } from "solid-js"
import { addToHistory, getHistory } from "../../stores/message-history"
import { clearSessionDraftPrompt, getSessionDraftPrompt, setSessionDraftPrompt } from "../../stores/sessions"
import { getLogger } from "../../lib/logger"
const log = getLogger("actions")
type GetTextarea = () => HTMLTextAreaElement | undefined | null
type PromptStateOptions = {
instanceId: Accessor<string>
sessionId: Accessor<string>
instanceFolder: Accessor<string>
onSessionDraftLoaded?: (draft: string) => void
}
type HistorySelectOptions = {
force?: boolean
isPickerOpen: boolean
getTextarea: GetTextarea
}
type PromptState = {
prompt: Accessor<string>
setPrompt: (value: string) => void
clearPrompt: () => void
draftLoadedNonce: Accessor<number>
history: Accessor<string[]>
historyIndex: Accessor<number>
historyDraft: Accessor<string | null>
resetHistoryNavigation: () => void
clearHistoryDraft: () => void
recordHistoryEntry: (entry: string) => Promise<void>
selectPreviousHistory: (options: HistorySelectOptions) => boolean
selectNextHistory: (options: HistorySelectOptions) => boolean
}
const HISTORY_LIMIT = 100
export function usePromptState(options: PromptStateOptions): PromptState {
const [prompt, setPromptInternal] = createSignal("")
const [history, setHistory] = createSignal<string[]>([])
const [historyIndex, setHistoryIndex] = createSignal(-1)
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
const [draftLoadedNonce, setDraftLoadedNonce] = createSignal(0)
const setPrompt = (value: string) => {
setPromptInternal(value)
// Persist drafts only when the user is at the "fresh" position (not browsing history).
// This keeps the bottom-of-history draft stable even if the user edits recalled history entries.
if (historyIndex() === -1) {
setSessionDraftPrompt(options.instanceId(), options.sessionId(), value)
}
}
const clearPrompt = () => {
clearSessionDraftPrompt(options.instanceId(), options.sessionId())
setPromptInternal("")
}
const resetHistoryNavigation = () => {
setHistoryIndex(-1)
setHistoryDraft(null)
}
const clearHistoryDraft = () => {
setHistoryDraft(null)
}
createEffect(
on(
() => `${options.instanceId()}:${options.sessionId()}`,
() => {
const instanceId = options.instanceId()
const sessionId = options.sessionId()
onCleanup(() => {
// Persist the previous session's draft when switching sessions.
setSessionDraftPrompt(instanceId, sessionId, prompt())
})
const storedPrompt = getSessionDraftPrompt(instanceId, sessionId)
setPromptInternal(storedPrompt)
setSessionDraftPrompt(instanceId, sessionId, storedPrompt)
resetHistoryNavigation()
setDraftLoadedNonce((prev) => prev + 1)
options.onSessionDraftLoaded?.(storedPrompt)
},
),
)
onMount(() => {
void (async () => {
const loaded = await getHistory(options.instanceFolder())
setHistory(loaded)
})()
})
const recordHistoryEntry = async (entry: string) => {
try {
await addToHistory(options.instanceFolder(), entry)
setHistory((prev) => {
const next = [entry, ...prev]
if (next.length > HISTORY_LIMIT) {
next.length = HISTORY_LIMIT
}
return next
})
setHistoryIndex(-1)
} catch (historyError) {
log.error("Failed to update prompt history:", historyError)
}
}
const canUseHistory = (selectOptions: HistorySelectOptions) => {
if (selectOptions.force) return true
if (selectOptions.isPickerOpen) return false
const textarea = selectOptions.getTextarea()
if (!textarea) return false
// Only require the cursor to be at the buffer start when *entering* history navigation.
// Once we're already navigating history (historyIndex >= 0), allow ArrowUp/ArrowDown
// regardless of cursor position (we focus the end of the entry).
if (historyIndex() !== -1) return true
return textarea.selectionStart === 0 && textarea.selectionEnd === 0
}
const focusTextareaEnd = (getTextarea: GetTextarea) => {
const textarea = getTextarea()
if (!textarea) return
setTimeout(() => {
const next = getTextarea()
if (!next) return
const pos = next.value.length
next.setSelectionRange(pos, pos)
next.focus()
}, 0)
}
const selectPreviousHistory = (selectOptions: HistorySelectOptions) => {
const entries = history()
if (entries.length === 0) return false
if (!canUseHistory(selectOptions)) return false
if (historyIndex() === -1) {
setHistoryDraft(prompt())
}
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, entries.length - 1)
setHistoryIndex(newIndex)
setPrompt(entries[newIndex])
focusTextareaEnd(selectOptions.getTextarea)
return true
}
const selectNextHistory = (selectOptions: HistorySelectOptions) => {
const entries = history()
if (entries.length === 0) return false
if (!canUseHistory(selectOptions)) return false
if (historyIndex() === -1) return false
const newIndex = historyIndex() - 1
if (newIndex >= 0) {
setHistoryIndex(newIndex)
setPrompt(entries[newIndex])
} else {
setHistoryIndex(-1)
const draft = historyDraft() ?? getSessionDraftPrompt(options.instanceId(), options.sessionId())
setPrompt(draft ?? "")
setHistoryDraft(null)
}
focusTextareaEnd(selectOptions.getTextarea)
return true
}
return {
prompt,
setPrompt,
clearPrompt,
draftLoadedNonce,
history,
historyIndex,
historyDraft,
resetHistoryNavigation,
clearHistoryDraft,
recordHistoryEntry,
selectPreviousHistory,
selectNextHistory,
}
}

View File

@@ -37,10 +37,11 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo(() => {
const list = addresses()
if (allowExternalConnections()) {
return list.filter((address) => address.scope !== "loopback")
if (!allowExternalConnections()) {
return []
}
return list.filter((address) => address.scope === "loopback")
// Local URL is displayed separately; list only remote-friendly addresses.
return list.filter((address) => address.scope !== "loopback")
})
const refreshMeta = async () => {
@@ -311,34 +312,27 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
<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>}>
<div class="remote-address-list">
<For each={displayAddresses()}>
{(address) => {
const expandedState = () => expandedUrl() === address.url
const qr = () => qrCodes()[address.url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
<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">{address.url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} {scopeLabel()} {address.ip}
</p>
<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(address.url)}>
<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(address.url)}
onClick={() => void toggleExpanded(value())}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
@@ -352,7 +346,60 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url: address.url })}
alt={t("remoteAccess.address.qrAlt", { url: value() })}
class="remote-qr-img"
/>
)}
</Show>
</div>
</Show>
</div>
)
}}
</Show>
<For each={displayAddresses()}>
{(address) => {
const url = address.remoteUrl
const expandedState = () => expandedUrl() === url
const qr = () => qrCodes()[url]
const scopeLabel = () =>
address.scope === "external"
? t("remoteAccess.address.scope.network")
: address.scope === "loopback"
? t("remoteAccess.address.scope.loopback")
: t("remoteAccess.address.scope.internal")
return (
<div class="remote-address">
<div class="remote-address-main">
<div>
<p class="remote-address-url">{url}</p>
<p class="remote-address-meta">
{address.family.toUpperCase()} {scopeLabel()} {address.ip}
</p>
</div>
<div class="remote-actions">
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
<ExternalLink class="remote-icon" />
{t("remoteAccess.address.open")}
</button>
<button
class="remote-pill"
type="button"
onClick={() => void toggleExpanded(url)}
aria-expanded={expandedState()}
>
<Link2 class="remote-icon" />
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
</button>
</div>
</div>
<Show when={expandedState()}>
<div class="remote-qr">
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
{(dataUrl) => (
<img
src={dataUrl()}
alt={t("remoteAccess.address.qrAlt", { url })}
class="remote-qr-img"
/>
)}

View File

@@ -2,12 +2,13 @@ import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCl
import type { SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state"
import { getSessionStatus } from "../stores/session-status"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown } from "lucide-solid"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split } from "lucide-solid"
import KeyboardHint from "./keyboard-hint"
import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry } from "../lib/keyboard-registry"
import { showToastNotification } from "../lib/notifications"
import { useI18n } from "../lib/i18n"
import { showConfirmDialog } from "../stores/alerts"
import {
deleteSession,
ensureSessionParentExpanded,
@@ -19,6 +20,7 @@ import {
setActiveSessionFromList,
toggleSessionParentExpanded,
} from "../stores/sessions"
import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session")
@@ -35,6 +37,7 @@ interface SessionListProps {
showFooter?: boolean
headerContent?: JSX.Element
footerContent?: JSX.Element
enableFilterBar?: boolean
}
function formatSessionStatus(status: SessionStatus): string {
@@ -46,6 +49,70 @@ const SessionList: Component<SessionListProps> = (props) => {
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
const [isRenaming, setIsRenaming] = createSignal(false)
const [filterQuery, setFilterQuery] = createSignal("")
const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : ""))
const [selectedSessionIds, setSelectedSessionIds] = createSignal<Set<string>>(new Set())
const normalizeSessionLabel = (sessionId: string) => {
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const title = (session?.title ?? "").trim()
return title || t("sessionList.session.untitled")
}
const sessionMatchesQuery = (sessionId: string, query: string) => {
if (!query) return true
const label = normalizeSessionLabel(sessionId).toLowerCase()
if (label.includes(query)) return true
return sessionId.toLowerCase().includes(query)
}
const filteredThreads = createMemo<SessionThread[]>(() => {
const query = normalizedQuery()
if (!query) return props.threads
const next: SessionThread[] = []
for (const thread of props.threads) {
const parentMatches = sessionMatchesQuery(thread.parent.id, query)
const matchingChildren = thread.children.filter((child) => sessionMatchesQuery(child.id, query))
if (!parentMatches && matchingChildren.length === 0) continue
next.push({
parent: thread.parent,
children: matchingChildren,
latestUpdated: thread.latestUpdated,
})
}
return next
})
const allMatchingSessionIds = createMemo<string[]>(() => {
const ids: string[] = []
for (const thread of filteredThreads()) {
ids.push(thread.parent.id)
for (const child of thread.children) ids.push(child.id)
}
return ids
})
const selectedCount = createMemo(() => selectedSessionIds().size)
const isAllSelected = createMemo(() => {
const ids = allMatchingSessionIds()
if (ids.length === 0) return false
const selected = selectedSessionIds()
return ids.every((id) => selected.has(id))
})
const isSelectAllIndeterminate = createMemo(() => {
const ids = allMatchingSessionIds()
const total = ids.length
if (total === 0) return false
const count = selectedCount()
return count > 0 && count < total
})
const isSessionDeleting = (sessionId: string) => {
const deleting = loading().deletingSession.get(props.instanceId)
return deleting ? deleting.has(sessionId) : false
@@ -54,9 +121,10 @@ const SessionList: Component<SessionListProps> = (props) => {
const selectSession = (sessionId: string) => {
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const parentId = session?.parentId ?? session?.id
if (parentId) {
ensureSessionParentExpanded(props.instanceId, parentId)
// If the user selects a child session, make sure its parent thread is expanded.
// For parent sessions we don't force expansion; user can collapse/expand freely.
if (session?.parentId) {
ensureSessionParentExpanded(props.instanceId, session.parentId)
}
props.onSelect(sessionId)
@@ -82,6 +150,17 @@ const SessionList: Component<SessionListProps> = (props) => {
event.stopPropagation()
if (isSessionDeleting(sessionId)) return
const confirmed = await showConfirmDialog(
t("sessionList.delete.confirmMessage", { label: normalizeSessionLabel(sessionId) }),
{
title: t("sessionList.delete.title"),
variant: "warning",
confirmLabel: t("sessionList.delete.confirmLabel"),
cancelLabel: t("sessionList.delete.cancelLabel"),
},
)
if (!confirmed) return
const shouldSelectFallback = props.activeSessionId === sessionId
let fallbackSessionId: string | undefined
@@ -152,6 +231,115 @@ const SessionList: Component<SessionListProps> = (props) => {
setIsRenaming(false)
}
}
const setSelectedMany = (sessionIds: string[], checked: boolean) => {
if (sessionIds.length === 0) return
setSelectedSessionIds((prev) => {
const next = new Set(prev)
sessionIds.forEach((id) => {
if (checked) next.add(id)
else next.delete(id)
})
return next
})
}
const getSelectableThreadIds = (parentId: string): string[] => {
const query = normalizedQuery()
const source = query ? filteredThreads() : props.threads
const thread = source.find((t) => t.parent.id === parentId)
if (!thread) return [parentId]
return [thread.parent.id, ...thread.children.map((c) => c.id)]
}
const getAllSessionIdsInOrder = (threads: SessionThread[]): string[] => {
const ids: string[] = []
threads.forEach((thread) => {
ids.push(thread.parent.id)
thread.children.forEach((child) => ids.push(child.id))
})
return ids
}
const handleToggleSelectAll = (checked: boolean) => {
const ids = allMatchingSessionIds()
setSelectedMany(ids, checked)
}
const toggleSelectAll = () => {
if (isAllSelected()) {
handleToggleSelectAll(false)
return
}
handleToggleSelectAll(true)
}
const handleBulkDelete = async () => {
const selected = Array.from(selectedSessionIds())
if (selected.length === 0) return
const confirmed = await showConfirmDialog(
t("sessionList.bulkDelete.confirmMessage", { count: selected.length }),
{
title: t("sessionList.bulkDelete.title"),
variant: "warning",
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
},
)
if (!confirmed) return
const deletedSet = new Set(selected)
const currentActiveId = props.activeSessionId
let fallbackSessionId: string | undefined
if (currentActiveId && deletedSet.has(currentActiveId)) {
const ordered = getAllSessionIdsInOrder(props.threads)
const currentIndex = ordered.indexOf(currentActiveId)
for (let i = Math.max(0, currentIndex); i < ordered.length; i++) {
const candidate = ordered[i]
if (candidate && !deletedSet.has(candidate)) {
fallbackSessionId = candidate
break
}
}
if (!fallbackSessionId) {
for (let i = currentIndex - 1; i >= 0; i--) {
const candidate = ordered[i]
if (candidate && !deletedSet.has(candidate)) {
fallbackSessionId = candidate
break
}
}
}
}
let failed = 0
for (const sessionId of selected) {
try {
// eslint-disable-next-line no-await-in-loop
await deleteSession(props.instanceId, sessionId)
} catch (error) {
failed += 1
log.error(`Failed to delete session ${sessionId}:`, error)
}
}
setSelectedSessionIds(new Set<string>())
if (fallbackSessionId) {
setActiveSessionFromList(props.instanceId, fallbackSessionId)
}
if (failed > 0) {
showToastNotification({
message: t("sessionList.bulkDelete.error", { count: failed }),
variant: "error",
})
}
}
const SessionRow: Component<{
@@ -166,6 +354,19 @@ const SessionList: Component<SessionListProps> = (props) => {
if (!session()) {
return <></>
}
const worktreeSlug = createMemo(() => {
if (rowProps.isChild) return "root"
return getWorktreeSlugForParentSession(props.instanceId, rowProps.sessionId)
})
const showWorktreeBadge = createMemo(() => {
if (rowProps.isChild) return false
if (getGitRepoStatus(props.instanceId) === false) return false
const slug = worktreeSlug()
return Boolean(slug) && slug !== "root"
})
const isActive = () => props.activeSessionId === rowProps.sessionId
const title = () => session()?.title || t("sessionList.session.untitled")
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
@@ -190,9 +391,31 @@ const SessionList: Component<SessionListProps> = (props) => {
? t("sessionList.status.needsInput")
: statusLabel()
return (
<div class="session-list-item group">
const isSelected = () => selectedSessionIds().has(rowProps.sessionId)
const parentGroupState = createMemo(() => {
if (rowProps.isChild) {
return { checked: isSelected(), indeterminate: false, ids: [rowProps.sessionId] }
}
const ids = getSelectableThreadIds(rowProps.sessionId)
const selected = selectedSessionIds()
const selectedInGroup = ids.reduce((count, id) => (selected.has(id) ? count + 1 : count), 0)
return {
checked: selectedInGroup > 0 && selectedInGroup === ids.length,
indeterminate: selectedInGroup > 0 && selectedInGroup < ids.length,
ids,
}
})
let rowCheckboxEl: HTMLInputElement | null = null
createEffect(() => {
if (!rowCheckboxEl) return
rowCheckboxEl.indeterminate = parentGroupState().indeterminate
})
return (
<div class="session-list-item group">
<button
class={`session-item-base ${rowProps.isChild ? `session-item-child${rowProps.isLastChild ? " session-item-child-last" : ""} session-item-border-assistant session-item-kind-assistant` : "session-item-border-user session-item-kind-user"} ${isActive() ? "session-item-active" : "session-item-inactive"}`}
data-session-id={rowProps.sessionId}
@@ -204,11 +427,23 @@ const SessionList: Component<SessionListProps> = (props) => {
>
<div class="session-item-row session-item-header">
<div class="session-item-title-row">
{rowProps.isChild ? (
<Bot class="w-4 h-4 flex-shrink-0" />
) : (
<User class="w-4 h-4 flex-shrink-0" />
)}
<Show when={props.enableFilterBar}>
<input
ref={(el) => {
rowCheckboxEl = el
}}
type="checkbox"
checked={parentGroupState().checked}
onClick={(event) => event.stopPropagation()}
onChange={(event) => {
event.stopPropagation()
setSelectedMany(parentGroupState().ids, event.currentTarget.checked)
}}
aria-label={t("sessionList.selection.checkboxAriaLabel")}
/>
</Show>
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
<span class="session-item-title session-item-title--clamp">{title()}</span>
</div>
</div>
@@ -216,9 +451,7 @@ const SessionList: Component<SessionListProps> = (props) => {
<div class="flex items-center gap-2 min-w-0">
<Show
when={rowProps.hasChildren && !rowProps.isChild}
fallback={
rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />
}
fallback={rowProps.isChild ? null : <span class="session-item-expander session-item-expander--spacer" aria-hidden="true" />}
>
<span
class={`session-item-expander opacity-80 hover:opacity-100 ${isActive() ? "hover:bg-white/20" : "hover:bg-surface-hover"}`}
@@ -228,20 +461,24 @@ const SessionList: Component<SessionListProps> = (props) => {
}}
role="button"
tabIndex={0}
aria-label={rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")}
aria-label={
rowProps.expanded ? t("sessionList.expand.collapseAriaLabel") : t("sessionList.expand.expandAriaLabel")
}
title={rowProps.expanded ? t("sessionList.expand.collapseTitle") : t("sessionList.expand.expandTitle")}
>
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span>
</Show>
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
{needsInput() ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
)}
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{statusText()}
</span>
<Show when={showWorktreeBadge()}>
<span class="status-indicator session-status-list worktree-indicator" title={`Worktree: ${worktreeSlug()}`}>
<Split class="w-3.5 h-3.5" aria-hidden="true" />
<span class="worktree-indicator-label">{worktreeSlug()}</span>
</span>
</Show>
</div>
<div class="session-item-actions">
<span
@@ -309,6 +546,13 @@ const SessionList: Component<SessionListProps> = (props) => {
})
createEffect(() => {
// Keep the active child session visible by ensuring its parent is expanded.
// Don't force-expanding when the active session itself is a parent lets users collapse it.
const activeId = props.activeSessionId
if (!activeId || activeId === "info") return
const activeSession = sessionStateSessions().get(props.instanceId)?.get(activeId)
if (!activeSession) return
if (!activeSession.parentId) return
const parentId = activeParentId()
if (!parentId) return
ensureSessionParentExpanded(props.instanceId, parentId)
@@ -365,6 +609,63 @@ const SessionList: Component<SessionListProps> = (props) => {
<div
class="session-list-container bg-surface-secondary border-r border-base flex flex-col w-full"
>
<Show when={props.enableFilterBar}>
<div class="p-3 border-b border-base">
<div class="flex items-center gap-2">
<div class="relative flex-1 min-w-0">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted" aria-hidden="true">
<Search class="w-4 h-4" />
</span>
<input
type="text"
class="form-input pl-9"
value={filterQuery()}
onInput={(e) => setFilterQuery(e.currentTarget.value)}
placeholder={t("sessionList.filter.placeholder")}
aria-label={t("sessionList.filter.ariaLabel")}
/>
</div>
<button
type="button"
class="button-tertiary p-2 inline-flex items-center justify-center"
onClick={toggleSelectAll}
disabled={allMatchingSessionIds().length === 0}
aria-label={t("sessionList.selection.selectAllAriaLabel")}
title={t("sessionList.selection.selectAllLabel")}
>
<Show
when={isSelectAllIndeterminate()}
fallback={isAllSelected() ? <CheckSquare class="w-4 h-4" /> : <Square class="w-4 h-4" />}
>
<MinusSquare class="w-4 h-4" />
</Show>
</button>
</div>
<Show when={selectedCount() > 0}>
<div class="mt-2 flex items-center justify-end gap-2">
<button
type="button"
class="button-tertiary"
onClick={handleBulkDelete}
aria-label={t("sessionList.bulkDelete.ariaLabel", { count: selectedCount() })}
>
{t("sessionList.bulkDelete.button", { count: selectedCount() })}
</button>
<button
type="button"
class="button-tertiary"
onClick={() => setSelectedSessionIds(new Set<string>())}
aria-label={t("sessionList.selection.clearAriaLabel")}
>
{t("sessionList.selection.clearLabel")}
</button>
</div>
</Show>
</div>
</Show>
<Show when={props.showHeader !== false}>
<div class="session-list-header p-3 border-b border-base">
{props.headerContent ?? (
@@ -378,33 +679,33 @@ const SessionList: Component<SessionListProps> = (props) => {
</div>
</Show>
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
<div class="session-list flex-1 overflow-y-auto" ref={(el) => listEl[1](el)}>
<Show when={props.threads.length > 0}>
<div class="session-section">
<For each={props.threads}>
<Show when={filteredThreads().length > 0}>
<div class="session-section">
<For each={filteredThreads()}>
{(thread) => {
const expanded = () => isSessionParentExpanded(props.instanceId, thread.parent.id)
return (
<>
<SessionRow
sessionId={thread.parent.id}
hasChildren={thread.children.length > 0}
expanded={expanded()}
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
/>
{(thread) => {
const expanded = () => (normalizedQuery() ? true : isSessionParentExpanded(props.instanceId, thread.parent.id))
return (
<>
<SessionRow
sessionId={thread.parent.id}
hasChildren={thread.children.length > 0}
expanded={expanded()}
onToggleExpand={() => toggleSessionParentExpanded(props.instanceId, thread.parent.id)}
/>
<Show when={expanded() && thread.children.length > 0}>
<For each={thread.children}>
{(child, index) => (
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
)}
</For>
</Show>
</>
)
}}
<Show when={expanded() && thread.children.length > 0}>
<For each={thread.children}>
{(child, index) => (
<SessionRow sessionId={child.id} isChild isLastChild={index() === thread.children.length - 1} />
)}
</For>
</Show>
</>
)
}}
</For>
</div>
</Show>

View File

@@ -6,11 +6,11 @@ import { useI18n } from "../../lib/i18n"
interface ContextUsagePanelProps {
instanceId: string
sessionId: string
class?: string
}
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
const chipLabelClass = "uppercase text-[10px] tracking-wide text-muted"
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const { t } = useI18n()
@@ -31,26 +31,16 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
const inputTokens = createMemo(() => info().inputTokens ?? 0)
const outputTokens = createMemo(() => info().outputTokens ?? 0)
const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
const availableTokens = createMemo(() => info().contextAvailableTokens)
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
const costValue = createMemo(() => {
const value = info().isSubscriptionModel ? 0 : info().cost
return value > 0 ? value : 0
})
const formatTokenValue = (value: number | null | undefined) => {
if (value === null || value === undefined) return t("contextUsagePanel.unavailable")
return formatTokenTotal(value)
}
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
return (
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div class={headingClass}>{t("contextUsagePanel.headings.tokens")}</div>
<div class={`session-context-panel px-4 py-2 ${props.class ?? ""}`}>
<div class="flex flex-wrap items-center gap-2 text-xs text-primary">
<div class={chipClass}>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.input")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
@@ -64,18 +54,6 @@ const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
<span class="font-semibold text-primary">{costDisplay()}</span>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
<div class={headingClass}>{t("contextUsagePanel.headings.context")}</div>
<div class={chipClass}>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.used")}</span>
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
</div>
<div class={chipClass}>
<span class={chipLabelClass}>{t("contextUsagePanel.labels.available")}</span>
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
</div>
</div>
</div>
)
}

View File

@@ -1,20 +1,20 @@
import { Show, For, createMemo, createEffect, type Component } from "solid-js"
import { Expand } from "lucide-solid"
import { Show, createMemo, createEffect, on, type Component } from "solid-js"
import type { Session } from "../../types/session"
import type { Attachment } from "../../types/attachment"
import type { ClientPart } from "../../types/message"
import MessageSection from "../message-section"
import { messageStoreBus } from "../../stores/message-v2/bus"
import PromptInput from "../prompt-input"
import type { Attachment as PromptAttachment } from "../../types/attachment"
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, 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 { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api"
import { useI18n } from "../../lib/i18n"
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
const log = getLogger("session")
@@ -53,52 +53,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
function handleExpandTextAttachment(attachment: PromptAttachment) {
if (attachment.source.type !== "text") return
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null
const value = attachment.source.value
const match = attachment.display.match(/pasted #(\d+)/)
const placeholder = match ? `[pasted #${match[1]}]` : null
const currentText = textarea?.value ?? ""
let nextText = currentText
let selectionTarget: number | null = null
if (placeholder) {
const placeholderIndex = currentText.indexOf(placeholder)
if (placeholderIndex !== -1) {
nextText =
currentText.substring(0, placeholderIndex) +
value +
currentText.substring(placeholderIndex + placeholder.length)
selectionTarget = placeholderIndex + value.length
}
}
if (nextText === currentText) {
if (textarea) {
const start = textarea.selectionStart
const end = textarea.selectionEnd
nextText = currentText.substring(0, start) + value + currentText.substring(end)
selectionTarget = start + value.length
} else {
nextText = currentText + value
}
}
if (textarea) {
textarea.value = nextText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
if (selectionTarget !== null) {
textarea.setSelectionRange(selectionTarget, selectionTarget)
}
}
removeAttachment(props.instanceId, props.sessionId, attachment.id)
}
let promptInputApi: PromptInputApi | null = null
let pendingPromptText: string | null = null
let pendingSelectionInsert: { text: string; mode: PromptInsertMode } | null = null
let scrollToBottomHandle: (() => void) | undefined
let rootRef: HTMLDivElement | undefined
@@ -112,8 +69,49 @@ export const SessionView: Component<SessionViewProps> = (props) => {
if (!props.isActive) return
scheduleScrollToBottom()
})
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
createEffect(
on(
() => props.isActive,
(isActive) => {
if (!isActive) return
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
if (typeof document === "undefined") return
const activeEl = document.activeElement as HTMLElement | null
const activeIsInput =
activeEl?.tagName === "INPUT" ||
activeEl?.tagName === "TEXTAREA" ||
activeEl?.tagName === "SELECT" ||
Boolean(activeEl?.isContentEditable)
if (activeIsInput) return
const modalOpen = Boolean(document.querySelector('[role="dialog"][aria-modal="true"]'))
if (modalOpen) return
// Defer until the session pane is visible and the textarea is mounted.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (promptInputApi) {
promptInputApi.focus()
return
}
const textarea = rootRef?.querySelector<HTMLTextAreaElement>(".prompt-input")
if (!textarea) return
if (textarea.disabled) return
try {
textarea.focus({ preventScroll: true } as any)
} catch {
textarea.focus()
}
})
})
},
),
)
createEffect(() => {
const currentSession = session()
if (currentSession) {
@@ -121,18 +119,31 @@ export const SessionView: Component<SessionViewProps> = (props) => {
}
})
function registerQuoteHandler(handler: (text: string, mode: "quote" | "code") => void) {
quoteHandler = handler
function registerPromptInputApi(api: PromptInputApi) {
promptInputApi = api
if (pendingPromptText) {
api.setPromptText(pendingPromptText, { focus: true })
pendingPromptText = null
}
if (pendingSelectionInsert) {
api.insertSelection(pendingSelectionInsert.text, pendingSelectionInsert.mode)
pendingSelectionInsert = null
}
return () => {
if (quoteHandler === handler) {
quoteHandler = null
if (promptInputApi === api) {
promptInputApi = null
}
}
}
function handleQuoteSelection(text: string, mode: "quote" | "code") {
if (quoteHandler) {
quoteHandler(text, mode)
function handleQuoteSelection(text: string, mode: PromptInsertMode) {
if (promptInputApi) {
promptInputApi.insertSelection(text, mode)
} else {
pendingSelectionInsert = { text, mode }
}
}
@@ -193,14 +204,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
)
const restoredText = getUserMessageText(messageId)
if (restoredText) {
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
if (textarea) {
textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
}
if (restoredText) {
if (promptInputApi) {
promptInputApi.setPromptText(restoredText, { focus: true })
} else {
pendingPromptText = restoredText
}
}
} catch (error) {
log.error("Failed to revert message", error)
showAlertDialog(t("sessionView.alerts.revertFailed.message"), {
@@ -217,10 +227,15 @@ export const SessionView: Component<SessionViewProps> = (props) => {
}
const restoredText = getUserMessageText(messageId)
const parentTitle = (session()?.title ?? "").trim() || t("sessionList.session.untitled")
try {
const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId })
renameSession(props.instanceId, forkedSession.id, `Fork: ${parentTitle}`).catch((error) => {
log.error("Failed to rename forked session", error)
})
const parentToActivate = forkedSession.parentId ?? forkedSession.id
setActiveParentSession(props.instanceId, parentToActivate)
if (forkedSession.parentId) {
@@ -229,14 +244,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
await loadMessages(props.instanceId, forkedSession.id).catch((error) => log.error("Failed to load forked session messages", error))
if (restoredText) {
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | undefined
if (textarea) {
textarea.value = restoredText
textarea.dispatchEvent(new Event("input", { bubbles: true }))
textarea.focus()
}
}
if (restoredText) {
if (promptInputApi) {
promptInputApi.setPromptText(restoredText, { focus: true })
} else {
pendingPromptText = restoredText
}
}
} catch (error) {
log.error("Failed to fork session", error)
showAlertDialog(t("sessionView.alerts.forkFailed.message"), {
@@ -285,39 +299,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
/>
<Show when={attachments().length > 0}>
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
<For each={attachments()}>
{(attachment) => {
const isText = attachment.source.type === "text"
return (
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
<span class="font-mono">{attachment.display}</span>
<Show when={isText}>
<button
type="button"
class="attachment-expand"
onClick={() => handleExpandTextAttachment(attachment)}
aria-label={t("sessionView.attachments.expandPastedTextAriaLabel")}
title={t("sessionView.attachments.insertPastedTextTitle")}
>
<Expand class="h-3 w-3" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="attachment-remove"
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
aria-label={t("sessionView.attachments.removeAriaLabel")}
>
×
</button>
</div>
)
}}
</For>
</div>
</Show>
<Show when={attachments().length > 0}>
<PromptAttachmentsBar
attachments={attachments()}
onRemoveAttachment={(attachmentId) => removeAttachment(props.instanceId, props.sessionId, attachmentId)}
onExpandTextAttachment={(attachmentId) => promptInputApi?.expandTextAttachment(attachmentId)}
/>
</Show>
<PromptInput
instanceId={props.instanceId}
@@ -329,11 +317,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
isSessionBusy={sessionBusy()}
disabled={sessionNeedsInput()}
onAbortSession={handleAbortSession}
registerQuoteHandler={registerQuoteHandler}
/>
</div>
)
}}
registerPromptInputApi={registerPromptInputApi}
/>
</div>
)
}}
</Show>
)
}

View File

@@ -0,0 +1,39 @@
import { createMemo, type Component } from "solid-js"
import { Laptop, Moon, Sun } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { useTheme } from "../lib/theme"
interface ThemeModeToggleProps {
class?: string
}
export const ThemeModeToggle: Component<ThemeModeToggleProps> = (props) => {
const { t } = useI18n()
const { themeMode, cycleThemeMode } = useTheme()
const modeLabel = () => {
const mode = themeMode()
if (mode === "system") return t("theme.mode.system")
if (mode === "light") return t("theme.mode.light")
return t("theme.mode.dark")
}
const icon = createMemo(() => {
const mode = themeMode()
if (mode === "system") return <Laptop class="w-4 h-4" />
if (mode === "light") return <Sun class="w-4 h-4" />
return <Moon class="w-4 h-4" />
})
return (
<button
type="button"
class={props.class ?? "new-tab-button"}
onClick={cycleThemeMode}
aria-label={t("theme.toggle.ariaLabel", { mode: modeLabel() })}
title={t("theme.toggle.title", { mode: modeLabel() })}
>
{icon()}
</button>
)
}

View File

@@ -5,7 +5,6 @@ import { ChevronDown } from "lucide-solid"
import { getLogger } from "../lib/logger"
import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences"
import { useI18n } from "../lib/i18n"
import Kbd from "./kbd"
const log = getLogger("session")
@@ -93,9 +92,6 @@ export default function ThinkingSelector(props: ThinkingSelectorProps) {
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
<span class="selector-trigger-primary selector-trigger-primary--align-left">{triggerPrimary()}</span>
</div>
<span class="selector-trigger-hint" aria-hidden="true">
<Kbd shortcut="cmd+shift+t" />
</span>
<Combobox.Icon class="selector-trigger-icon">
<ChevronDown class="w-3 h-3" />
</Combobox.Icon>

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