Compare commits

..

343 Commits

Author SHA1 Message Date
Shantur Rathore
c825ff066e bump version to 0.5.1 2026-01-07 08:56:19 +00:00
Shantur Rathore
f7ded37ea3 Fix macOS tauri cli package name 2026-01-07 06:35:59 +00:00
Shantur Rathore
847faf1214 Fix Tauri builds and Windows opencode-config loop 2026-01-07 06:25:19 +00:00
Shantur Rathore
b1691add1c Stabilize Tauri CLI install in CI 2026-01-07 06:10:30 +00:00
Shantur Rathore
3b9a44779a Exclude opencode-config from npm workspaces 2026-01-06 23:20:45 +00:00
Shantur Rathore
62fd88cd3f Install @tauri-apps/cli alongside platform bindings 2026-01-06 23:10:45 +00:00
Shantur Rathore
ce2273fe57 Sync package-lock for opencode-config workspace 2026-01-06 23:03:54 +00:00
Shantur Rathore
0eee325777 Stabilize Windows opencode-config install and pin tauri bindings 2026-01-06 22:57:38 +00:00
Shantur Rathore
f7c9db44ad Fix Windows builds by tracking opencode-config package.json 2026-01-06 22:43:46 +00:00
Shantur Rathore
1fcf89b945 Isolate opencode-config install from workspace 2026-01-06 20:58:01 +00:00
Shantur Rathore
f5682ea246 Fix dev CI build tool resolution and Windows npm spawning 2026-01-06 20:45:40 +00:00
Shantur Rathore
fa308696b4 Allow callers to control workflow permissions 2026-01-06 20:32:29 +00:00
Shantur Rathore
ac8dfcc607 Improve opencode-config install on Windows 2026-01-06 20:30:40 +00:00
Shantur Rathore
ac04d5daf7 Run build-only CI on dev pushes 2026-01-06 20:30:37 +00:00
Shantur Rathore
7fe8fee295 Fix Tauri CLI native dependency installs 2026-01-06 20:30:33 +00:00
Shantur Rathore
5954b332d5 Update dependencies 2026-01-06 19:38:08 +00:00
Shantur Rathore
eb89dfaf89 Fix iOS input auto-zoom (fixes #49, thanks @xpcmdshell) 2026-01-06 18:51:39 +00:00
Shantur Rathore
25bf313338 Show compaction indicator in message stream and timeline 2026-01-06 18:48:00 +00:00
Shantur Rathore
315abf21e6 Fix session status hydration and compaction transitions 2026-01-06 18:03:42 +00:00
Shantur Rathore
f24e360d78 Optimize session status updates
Reduce per-token store churn by updating status on transitions, caching instance-level indicators, and avoiding O(n) session-map cloning.
2026-01-06 09:58:55 +00:00
Shantur Rathore
1a6f1fdbae Bump to v0.5.0 2026-01-05 22:39:02 +00:00
Shantur Rathore
e09ce0780e Reconcile permissions after message hydration
After loadMessages hydrates tool parts, reattach pending permissions to the correct tool-call part ids so ToolCall permission UI renders reliably.
2026-01-05 20:39:51 +00:00
Shantur Rathore
95fdad7523 Use shield icon for permission status
Replace permission dots with a shield indicator and adjust permission colors to stand out from working/compacting.
2026-01-05 20:18:07 +00:00
Shantur Rathore
06416a9eb3 Add instance tab session status indicator
Aggregate session states per instance so tabs reflect permission, compaction, and working activity.
2026-01-05 20:09:13 +00:00
Shantur Rathore
2db62b1d17 Make UI global cache version-aware
Store one cached value per cacheId and overwrite when version changes to prevent unbounded growth from per-version keys.
2026-01-05 19:45:33 +00:00
Shantur Rathore
1377bc6b91 Migrate UI to v2 SDK client
Use v2 OpencodeClient with normalized request handling and rehydrate pending permissions via GET /permission on instance hydration.
2026-01-04 22:02:30 +00:00
Shantur Rathore
fcb5998474 Update UI permissions for SDK 1.0.166
Handle permission.asked events and requestID replies while keeping legacy compatibility.
2026-01-04 22:02:30 +00:00
Shantur Rathore
c2df32ec8b Stream ANSI tool output rendering 2026-01-04 22:02:30 +00:00
Shantur Rathore
f01149ee9e Stream ANSI tool output rendering 2026-01-04 22:02:29 +00:00
Shantur Rathore
eebfcb5628 Unify ANSI rendering with sequence parser 2026-01-04 22:02:29 +00:00
Shantur Rathore
4571a1dcf9 Render ANSI background output 2026-01-04 22:02:29 +00:00
Shantur Rathore
a041e1c6c3 Track session status via SSE updates 2026-01-04 22:02:29 +00:00
Shantur Rathore
abb8a9df19 Merge pull request #51 from bizzkoot/fix/copy-button-web
fix: copy button functionality in web browsers
2026-01-04 13:52:41 +00:00
bizzkoot
3c450c076a fix: copy button functionality in web browsers
- Add clipboard utility with fallback for non-secure contexts
- Implement modern Clipboard API with document.execCommand fallback
- Update copy buttons in code blocks, markdown, messages, and session list
- Add proper error handling and user feedback for copy operations

Fixes issue where copy buttons did not work in web browsers served over HTTP or without Clipboard API support
2026-01-04 20:00:22 +08:00
Shantur Rathore
4b05e698f8 Require tool part ids for tool-call rendering and caching
Rebind permissions from callID to part id when parts arrive.
2026-01-02 16:21:24 +00:00
Shantur Rathore
a9524b3e30 Load complete background process output and fix dialog layout 2025-12-30 22:03:04 +00:00
Shantur Rathore
154c5208b4 Show timeline icons at all widths 2025-12-29 16:19:11 +00:00
Shantur Rathore
71479a59a7 Add ANSI rendering for bash tool output 2025-12-26 10:47:53 +00:00
Shantur Rathore
3606d9aa50 Enforce workspace-only paths for background processes 2025-12-25 23:15:43 +00:00
Shantur Rathore
3e4d51c9f2 Surface runtime output in launch errors 2025-12-25 20:44:21 +00:00
Shantur Rathore
2603b1d260 Handle revert removals locally and retarget prompt input 2025-12-25 15:12:44 +00:00
Shantur Rathore
94aa469e90 Stop workspace port warning timer after allocation 2025-12-24 20:29:11 +00:00
Shantur Rathore
dab1e0fa7a Bundle opencode-config dependencies 2025-12-24 20:25:19 +00:00
Shantur Rathore
a14247f049 Sync package-lock 2025-12-24 19:10:32 +00:00
Shantur Rathore
695a890e0a Normalize plugin file URLs 2025-12-24 13:37:39 +00:00
Shantur Rathore
402d72d038 Remove session idle plugin wiring 2025-12-24 13:34:46 +00:00
Shantur Rathore
d32ec73c63 Resolve bundled opencode config from resources 2025-12-24 13:30:00 +00:00
Shantur Rathore
d0eac1e610 Use bundled opencode config at runtime 2025-12-24 12:01:03 +00:00
Shantur Rathore
e947691aae Consolidate plugins under CodeNomad entry 2025-12-24 01:07:56 +00:00
Shantur Rathore
575f987b8f Add background process manager and UI panel 2025-12-24 00:59:41 +00:00
Shantur Rathore
28b66ed0af Add CodeNomad plugin bridge for opencode 2025-12-23 23:06:33 +00:00
Shantur Rathore
4060c4f60b Show configured plugins in status panels 2025-12-23 18:24:09 +00:00
Shantur Rathore
8334e27294 Show error if opencode fails to launch 2025-12-17 22:59:05 +00:00
Shantur Rathore
722b523f92 Add packages/opencode-config and use it 2025-12-17 22:58:41 +00:00
Shantur Rathore
06be455358 bump version to 0.4.0 2025-12-15 16:32:29 +00:00
Alex Crouch
450f5bf0b4 change copy to act only on individual assitant/user blocks 2025-12-15 16:10:19 +00:00
Alex Crouch
997d4f4129 feat(ui): add copy button to message items
Add a Copy button that allows users to copy raw message contents
(text and reasoning parts) to clipboard. The button appears on all
messages - alongside Revert/Fork for user messages, and standalone
for assistant messages.
2025-12-15 16:10:19 +00:00
Shantur Rathore
ff5c698131 Refactor instance metadata handling 2025-12-15 16:08:28 +00:00
Shantur Rathore
14497f2082 Limit instance info scroll area 2025-12-15 10:37:08 +00:00
Shantur Rathore
f3e1966b5d Rename control panel to status panel 2025-12-15 10:09:51 +00:00
Shantur Rathore
78592f229e Fix long plan item layout 2025-12-15 10:09:18 +00:00
Shantur Rathore
c8161669ac Add shared instance metadata context 2025-12-15 00:42:16 +00:00
Shantur Rathore
8ec57da275 Tweak control panel plan styling 2025-12-14 23:36:38 +00:00
Shantur Rathore
c00b29145a limit cached session views on tab switch 2025-12-14 17:07:17 +00:00
Shantur Rathore
7d2a349e95 Lower AppBar z-index for timeline tooltips 2025-12-14 16:43:43 +00:00
Shantur Rathore
6c326b18ca Close floating drawers on escape key 2025-12-14 16:42:31 +00:00
Shantur Rathore
09229259d1 Ensure agent selector popover overlays drawer 2025-12-14 16:39:28 +00:00
Shantur Rathore
b20bfc34b2 Fix selector shortcut popovers with floating drawer 2025-12-14 16:36:34 +00:00
Shantur Rathore
4e1f08bfcf Trigger selector popups after auto-opening drawer 2025-12-14 16:33:53 +00:00
Shantur Rathore
ef4f8ac45f Route agent/model shortcuts through sidebar events 2025-12-14 16:30:31 +00:00
Shantur Rathore
6a7255d9d2 Auto-open left drawer for selector shortcuts 2025-12-14 16:26:37 +00:00
Shantur Rathore
f37fcaed3d Open left drawer for selector and session shortcuts 2025-12-14 16:22:30 +00:00
Shantur Rathore
d9fd22c29f Raise selector popover layer above drawers 2025-12-14 16:16:55 +00:00
Shantur Rathore
3fcab5b80a Add timeline divider and fix session scroll 2025-12-14 16:13:22 +00:00
Shantur Rathore
4ed2361387 Reduce scroll jitter from virtual items 2025-12-14 15:55:09 +00:00
Shantur Rathore
75b3699649 Show latest todowrite plan in control panel 2025-12-14 15:05:09 +00:00
Shantur Rathore
a6404f25d9 Add control panel accordion for session sidebar 2025-12-14 14:09:07 +00:00
Shantur Rathore
7591e5c1c9 Add MCP toggle control 2025-12-14 13:40:32 +00:00
Shantur Rathore
5e8b3fd5c9 Keep session chrome in info view 2025-12-14 13:24:47 +00:00
Shantur Rathore
20b82496a1 Persist drawer pin preferences 2025-12-14 13:13:43 +00:00
Shantur Rathore
542b59940a Add resizable session drawers 2025-12-14 13:01:29 +00:00
Shantur Rathore
8d5c6b37e9 Prevent welcome resume list overflow 2025-12-14 12:50:00 +00:00
Shantur Rathore
8155fc9956 Ensure welcome and palette layouts wrap on phone 2025-12-14 12:40:00 +00:00
Shantur Rathore
cd4afb5314 Clamp phone shell horizontal overflow 2025-12-14 12:31:26 +00:00
Shantur Rathore
557c2500c7 Clean up legacy instance shell and theme additions 2025-12-14 01:55:50 +00:00
Shantur Rathore
74f8b6c31f Remove instance shell overflow scroll 2025-12-14 01:54:32 +00:00
Shantur Rathore
da517416a5 Hide app bar during folder selection and tighten toolbar 2025-12-14 01:52:34 +00:00
Shantur Rathore
b8f93bf768 Tighten phone app bar and compact palette control 2025-12-14 01:43:04 +00:00
Shantur Rathore
0110052758 Add mobile-friendly instance shell app bar 2025-12-14 01:34:31 +00:00
Shantur Rathore
0e0da1a142 Show diagnostics only for edited file 2025-12-13 22:23:37 +00:00
Shantur Rathore
da3b66a3bd Ensure Tauri CLI locates server in AppImage 2025-12-13 21:54:04 +00:00
Shantur Rathore
088e5f1eea Align prompt input area with action column 2025-12-13 13:20:33 +00:00
Shantur Rathore
0da2e1d7bb Sync tool-call titles and task summaries 2025-12-12 13:51:40 +00:00
Shantur Rathore
90c6835ee7 Defer tool markdown render while running 2025-12-12 12:00:42 +00:00
Shantur Rathore
92bef8bfb8 Memoize markdown renders per part revision 2025-12-12 12:00:31 +00:00
Shantur Rathore
766be00ded Make message list bottom-first with append-only timeline 2025-12-12 12:00:19 +00:00
Shantur Rathore
ce5eaa1841 Use async prompt API and SDK bump 2025-12-09 21:42:29 +00:00
Shantur Rathore
c323667729 Preserve session scroll when returning 2025-12-09 21:29:48 +00:00
Shantur Rathore
67a12d6126 Add session rename dialogs and API wiring 2025-12-09 20:13:35 +00:00
Shantur Rathore
bd0cb04b78 Avoid queued badge in timeline previews 2025-12-09 18:20:38 +00:00
Shantur Rathore
d3706d2985 Improve message timeline tooltip positioning 2025-12-09 17:19:55 +00:00
Shantur Rathore
9769d7a46e Clarify pending tool call message 2025-12-09 17:14:21 +00:00
Shantur Rathore
783fb5c5b2 Defer initial message scroll until list renders 2025-12-09 17:09:30 +00:00
Shantur Rathore
82ff1916b7 Prevent cached session remeasurements and remove logs 2025-12-09 16:27:01 +00:00
Shantur Rathore
8204143810 Improve session cache eviction 2025-12-09 16:27:01 +00:00
Shantur Rathore
e54f80f20e Keep tabs hidden but in memory 2025-12-09 11:12:41 +00:00
Shantur Rathore
54a2917faa Merge pull request #40 from alexispurslane/tauri-native-menu
Implement complete native menu system with keyboard accelerators
2025-12-09 01:30:47 +00:00
Shantur Rathore
b72ead1bea bump to v0.3.0 2025-12-09 01:29:30 +00:00
Shantur Rathore
7996228327 lazy mount message blocks 2025-12-09 01:04:49 +00:00
Shantur Rathore
7aba3c1221 add timeline tool visibility toggle 2025-12-08 18:32:23 +00:00
Alexis Purslane
11dedd4446 Implement complete native menu system with keyboard accelerators
This commit establishes a comprehensive native menu system for the Tauri application, bringing it to parity with the Electron implementation and enabling full keyboard shortcut support.

## Key Features Added

**Native Menu System:**
- Complete menu bar with File, Edit, View, and Window menus
- Platform-specific adaptations (macOS app menu, Windows/Linux quit behavior)
- Full menu event handling system with proper window management

**Keyboard Accelerators:**
- CmdOrCtrl+N for New Instance creation
- Standard Edit menu shortcuts (Undo, Redo, Cut, Copy, Paste, Select All)
- View menu shortcuts (Reload, Force Reload, Toggle DevTools, Fullscreen)
- Built-in zoom hotkeys enabled via zoomHotkeysEnabled

**Enhanced Capabilities:**
- Added core:menu:default permission for menu management
- Added core:webview:allow-set-webview-zoom for zoom functionality
- Proper event communication between Tauri backend and frontend

**Frontend Integration:**
- Tauri event listener for menu:newInstance events
- Proper cleanup and error handling for event subscriptions
- Runtime environment detection for Tauri-specific behavior

## Technical Changes

**Backend (Rust):**
- Replaced empty menu stub with full SubmenuBuilder implementation
- Added comprehensive menu event handling with window operations
- Implemented MenuItem::with_id for accelerator support
- Added platform-specific menu construction logic

**Frontend (TypeScript):**
- Added Tauri event listener integration in main App component
- Proper lifecycle management with onMount/onCleanup
- Runtime environment detection for conditional Tauri behavior

**Configuration:**
- Enabled zoomHotkeysEnabled in tauri.conf.json for built-in zoom support
- Updated capabilities to include necessary menu and webview permissions

This implementation provides a native, platform-consistent user experience with full keyboard shortcut support, matching the functionality users expect from desktop applications.
2025-12-08 11:23:57 -05:00
Shantur Rathore
8fcf757c5c Share release workflows 2025-12-08 14:49:39 +00:00
Shantur Rathore
5cf3c001b5 adjust timeline hover tooltip position 2025-12-08 14:26:42 +00:00
Shantur Rathore
4ae54a1f7b use icons for timeline short labels 2025-12-08 13:46:38 +00:00
Shantur Rathore
81a9c28971 use tool prefixes in timeline 2025-12-08 13:40:42 +00:00
Shantur Rathore
235b9338a7 tweak: use initial for tool short label 2025-12-08 10:56:32 +00:00
Shantur Rathore
642d5e22e6 tweak: show tool name in timeline chip 2025-12-08 10:54:50 +00:00
Shantur Rathore
67ff00d83e feat: add quote mode options 2025-12-08 10:45:12 +00:00
Shantur Rathore
710938eef8 fix: hide assistant timeline entries without text 2025-12-08 10:34:00 +00:00
Shantur Rathore
dc702b1fb2 feat: quote message selections 2025-12-08 10:16:58 +00:00
Shantur Rathore
92d16084db fix: render full message preview for tool calls 2025-12-08 09:52:22 +00:00
Shantur Rathore
9b0e02f66f feat: add timeline hover message preview 2025-12-08 09:45:11 +00:00
Shantur Rathore
a2e5034c20 feat: sync timeline highlight with scroll 2025-12-07 21:59:57 +00:00
Shantur Rathore
e3489b22e6 feat: add timeline sidebar to message view 2025-12-07 21:40:52 +00:00
Shantur Rathore
cd8948770d feat: include bash timeout 2025-12-07 20:23:52 +00:00
Shantur Rathore
d4281f1d9c feat: surface read bounds 2025-12-07 20:15:39 +00:00
Shantur Rathore
49214c60ca Issue template 2025-12-07 18:12:11 +00:00
Shantur Rathore
0a530a257f Add information about codenomad dev version 2025-12-07 18:03:07 +00:00
Shantur Rathore
54f269e955 fix: unblock tauri build 2025-12-07 17:02:03 +00:00
Shantur Rathore
91ab2d5e2c Merge pull request #34 from tybradle/fix/crypto-uuid-fallback
Fix: Add crypto.randomUUID fallback for browser compatibility
2025-12-07 16:22:29 +00:00
Shantur Rathore
72773546f5 Merge remote-tracking branch 'origin/dev' into fork/tybradle-fix-crypto-uuid-fallback 2025-12-07 16:21:37 +00:00
Shantur Rathore
2f58e8a1a9 Show loading indicator before listing sessions 2025-12-07 01:29:36 +00:00
Shantur Rathore
d0cab51eca open external links in native shells 2025-12-07 01:18:26 +00:00
Shantur Rathore
6f04d23b09 limit release toast to folder view 2025-12-07 00:58:15 +00:00
Shantur Rathore
3e72b83393 add release monitor and ui toast 2025-12-07 00:55:10 +00:00
Shantur Rathore
87da8ee9f8 bump version 0.2.8 2025-12-06 23:22:18 +00:00
Shantur Rathore
ec5c5c8c0f add history rocker icons 2025-12-06 23:18:29 +00:00
Shantur Rathore
b9394fb467 add prompt history rocker 2025-12-06 23:13:01 +00:00
Shantur Rathore
de432106e5 add stop control to prompt input 2025-12-06 23:05:38 +00:00
Shantur Rathore
1fbf51b7ae align instance tab accessories to right 2025-12-06 22:38:43 +00:00
Shantur Rathore
864d665049 keep shortcuts in instance tab scroll 2025-12-06 22:37:31 +00:00
Shantur Rathore
c4a9c032a3 make instance tabs strip fully scrollable 2025-12-06 22:36:00 +00:00
Shantur Rathore
3373e23a41 sync hidden sidebar layout with mobile 2025-12-06 22:34:30 +00:00
Shantur Rathore
b0650a283e Revert "fix hidden sidebar toggle button"
This reverts commit f1ad1400a7.
2025-12-06 22:31:52 +00:00
Shantur Rathore
52149f5543 Revert "align sidebar toggle layout on collapse"
This reverts commit 2e5a904034.
2025-12-06 22:31:48 +00:00
Shantur Rathore
2e5a904034 align sidebar toggle layout on collapse 2025-12-06 22:30:37 +00:00
Shantur Rathore
f1ad1400a7 fix hidden sidebar toggle button 2025-12-06 22:29:03 +00:00
Shantur Rathore
bbd28404ff gate instance info overlay on desktop 2025-12-06 22:26:52 +00:00
Shantur Rathore
04f6e362b9 Centralize tool call scroll helpers 2025-12-06 22:22:44 +00:00
Shantur Rathore
0b9cce6f86 Add session delete button 2025-12-06 22:20:15 +00:00
Shantur Rathore
d68cb6b1b8 Add delete controls to resume sessions 2025-12-06 22:16:08 +00:00
Shantur Rathore
e345dc1262 Expose UI logger controls globally 2025-12-06 12:17:33 +00:00
Shantur Rathore
2b27790a81 add tool call auto scroll sentinels 2025-12-05 23:47:34 +00:00
Shantur Rathore
2514fa94b4 stabilize scroll event wiring 2025-12-05 22:05:27 +00:00
Shantur Rathore
522910ff64 refine message stream auto scroll 2025-12-05 21:57:10 +00:00
Shantur Rathore
971abe24d7 feat(ui): add runtime logger and replace console usage 2025-12-05 15:07:49 +00:00
Shantur Rathore
49143bd049 Upgrade sdk and use async prompt 2025-12-05 12:28:44 +00:00
Shantur Rathore
df52ed3035 improve server logging for sse and http 2025-12-05 10:53:57 +00:00
Shantur Rathore
617aac8fd8 Use monitor icon for remote connect 2025-12-04 10:58:33 +00:00
Shantur Rathore
6e82ecc97e Improve remote overlay responsiveness 2025-12-03 22:15:46 +00:00
Shantur Rathore
636a19fc50 Move remote connect button to tab bar edge 2025-12-03 22:12:16 +00:00
Shantur Rathore
97f78bb337 Make remote connect buttons icon-only 2025-12-03 22:10:53 +00:00
Shantur Rathore
0ca39d2fb0 Filter loopback addresses when remote 2025-12-03 22:05:26 +00:00
Shantur Rathore
aad1337111 Use remote-hand-over icon for connect buttons 2025-12-03 22:04:01 +00:00
Shantur Rathore
6d7bc813ed Adjust remote button placement 2025-12-03 22:02:50 +00:00
Shantur Rathore
1a0dd21540 Expose remote connect button on folder view 2025-12-03 22:02:17 +00:00
Shantur Rathore
7cf9c35375 Restrict meta addresses when local-only 2025-12-03 21:59:20 +00:00
Shantur Rathore
f1c32253af Adjust remote handover header copy 2025-12-03 21:57:54 +00:00
Shantur Rathore
4a8d13e2cd Update remote overlay copy 2025-12-03 21:57:27 +00:00
Shantur Rathore
b0fd63ead5 Limit remote overlay to single QR 2025-12-03 21:54:11 +00:00
Shantur Rathore
94cb741c7f Add remote access controls 2025-12-03 21:52:42 +00:00
Shantur Rathore
976430d61c Don't use vite.config.ts 2025-12-03 18:36:15 +00:00
Shantur Rathore
8a8555d591 Optimize task tool summary recompute on version changes 2025-12-03 18:13:56 +00:00
Shantur Rathore
57c1605242 Message addition performance improvements 2025-12-03 17:07:05 +00:00
Shantur Rathore
cfbd0bdffa Show view instance button only on small screen 2025-12-03 16:41:45 +00:00
Shantur Rathore
58efb8bc3e disable diff cache 2025-12-03 16:41:19 +00:00
Shantur Rathore
b35bfe63c0 Increase timeout for CLI startup 2025-12-03 16:37:48 +00:00
Shantur Rathore
d7b5f53d59 launch cli listeners on all interfaces 2025-12-03 00:16:02 +00:00
Shantur Rathore
168b782006 retry default port before auto ephemeral 2025-12-03 00:10:20 +00:00
Shantur Rathore
9e0fbd185d keep instance tab close buttons visible 2025-12-03 00:09:07 +00:00
Shantur Rathore
11be314f63 center mobile usage chips 2025-12-03 00:04:52 +00:00
Shantur Rathore
36ee301ef2 center session header metrics 2025-12-03 00:04:07 +00:00
Shantur Rathore
d6dd06b7d1 fix session sidebar width binding 2025-12-02 23:58:41 +00:00
Shantur Rathore
6a16dd8f98 align permission attachments with SSE stream 2025-12-02 23:53:34 +00:00
Shantur Rathore
78338f33c1 add responsive session sidebar 2025-12-02 23:52:45 +00:00
Shantur Rathore
8c72d279df add mobile overlay for instance info 2025-12-02 23:17:14 +00:00
Shantur Rathore
a9500276c8 add expand control for pasted text attachments 2025-12-02 22:59:36 +00:00
Shantur Rathore
f9ec757c64 refactor message stream layout 2025-12-02 19:23:05 +00:00
Tyler Bradley
f4c9385661 Fix: Add crypto.randomUUID fallback for browser compatibility
The crypto.randomUUID() API requires a secure context (HTTPS or specific
localhost conditions). When running CodeNomad Server on 0.0.0.0:9898 or
accessing via LAN/VPN, some browsers don't provide this API, causing
attachment creation to fail with TypeError.

Added generateUUID() helper that uses crypto.randomUUID() when available,
with a Math.random()-based UUID v4 fallback for compatibility.

Fixes file and agent attachment creation in the @ mention picker when
running in headless server mode.
2025-12-02 16:55:59 +00:00
Shantur Rathore
6ba50cadd2 modularize tool-call rendering and styles 2025-12-02 16:16:49 +00:00
Shantur Rathore
8d5169cb39 Memoize ToolCall task summary rendering 2025-12-02 13:45:35 +00:00
Shantur Rathore
fe8b4a9acd Drop tool-call scroll caching 2025-12-02 13:10:29 +00:00
Shantur Rathore
831e59cd77 Anchor scroll to message stream sentinel 2025-12-02 12:37:49 +00:00
Shantur Rathore
7fde8afcf0 Remove placeholder min-height fallback 2025-12-02 12:07:43 +00:00
Shantur Rathore
d07c2ec4a9 Stop gating virtualization on measurements 2025-12-02 12:02:05 +00:00
Shantur Rathore
4306147990 Precalc viewport window for virtualization 2025-12-02 11:49:42 +00:00
Shantur Rathore
c614da3e3c Batch virtual item visibility updates 2025-12-02 11:45:12 +00:00
Shantur Rathore
73b59d8266 Share intersection observers across virtual items 2025-12-02 11:33:22 +00:00
Shantur Rathore
a2d8ea0dfd Add local virtualization wrapper to message stream 2025-12-02 11:13:12 +00:00
Shantur Rathore
52ee196103 Add macos workaround 2025-12-02 10:23:21 +00:00
Shantur Rathore
1a1aee8f91 Group message action buttons 2025-12-01 23:42:01 +00:00
Shantur Rathore
5384ff8e80 Merge message parts props 2025-12-01 23:20:44 +00:00
Shantur Rathore
6d5836ce1f Always render step usage chips 2025-12-01 23:12:31 +00:00
Shantur Rathore
d3dc170e02 Lazy render tool-call bodies 2025-12-01 23:09:22 +00:00
Shantur Rathore
983c8cc4a3 Streamline tool-call header DOM 2025-12-01 22:48:22 +00:00
Shantur Rathore
757c587b17 Simplify message header markup 2025-12-01 22:41:30 +00:00
Shantur Rathore
5f9cf397b9 Reduce step-finish usage chip DOM 2025-12-01 22:36:03 +00:00
Shantur Rathore
78ab17d148 bump version to 0.2.7 2025-12-01 19:54:12 +00:00
Shantur Rathore
e91923ad99 Improve message stream auto-scroll during streaming 2025-12-01 19:46:16 +00:00
Shantur Rathore
fd23ea54b6 Simplify message cache pruning and diff/markdown initialization 2025-12-01 19:46:16 +00:00
Shantur Rathore
1e7969eaba Localize ToolCall expansion and diagnostics state 2025-12-01 19:46:16 +00:00
Shantur Rathore
77bfe41a8e Reduce message cloning and gate scroll work on load 2025-12-01 19:46:16 +00:00
Shantur Rathore
6d134e4dec Render stream with Index to limit churn on inserts/removes 2025-12-01 19:46:16 +00:00
Shantur Rathore
9423326193 Make MessageBlock rerender via keyed Show 2025-12-01 19:46:16 +00:00
Shantur Rathore
c5011e4ece Make message blocks reactive to session signals 2025-12-01 19:46:16 +00:00
Shantur Rathore
66c270151a Stream change token and scroll checks use ids only 2025-12-01 19:46:16 +00:00
Shantur Rathore
5ce41217e9 Make MessageBlock output reactive via Show 2025-12-01 19:46:16 +00:00
Shantur Rathore
1e4d949d35 Add message stream debug logging 2025-12-01 19:46:16 +00:00
Shantur Rathore
6bb9e8e414 Render message stream per message id 2025-12-01 19:46:16 +00:00
Shantur Rathore
1efc49b67b Prune reverted messages from session store 2025-12-01 19:46:16 +00:00
Shantur Rathore
f0ed98222a Skip reverted messages from display caches 2025-12-01 19:46:16 +00:00
Shantur Rathore
ddd8ce341a Add diff viewer fallback and extract message block 2025-12-01 19:46:16 +00:00
Shantur Rathore
b7721ba3e7 Merge pull request #13 from alexispurslane/blank-session-cleanup
Blank session cleanup
2025-12-01 19:45:09 +00:00
Alexis Purslane
0554018980 update message 2025-12-01 12:37:22 -05:00
Alexis Purslane
ca18942bfd confirmation dialogue 2025-12-01 11:43:04 -05:00
Alexis Purslane
c9c1f69b82 further improvements 2025-12-01 11:35:03 -05:00
Alexis Purslane
aa0c31fa1e improve PR 2025-11-29 21:44:15 -05:00
Alexis Purslane
96b88dbcdc update blank session cleanup code for now session store logic 2025-11-27 20:18:22 -05:00
Alexis Dumas
50676416ed blank session cleanup improvements
- make the blank session cleanup system optionally fetch full message histories for each session to better judge if it's blank
- make a command that does the deep clean, keep the clean that happens on new session creation shallow
2025-11-27 18:18:24 -05:00
Alexis Purslane
f633d75005 Blank session cleanup 2025-11-27 18:18:24 -05:00
Shantur Rathore
4085f6d6b9 Avoid npm ci pruning during prebuild 2025-11-27 20:40:21 +00:00
Shantur Rathore
ae288833e1 Install server deps before build in prebuild 2025-11-27 20:29:08 +00:00
Shantur Rathore
f16e244265 Handle rollup platform binaries in prebuild 2025-11-27 20:25:04 +00:00
Shantur Rathore
b6e43c899b Improve dialog text wrapping and sizing 2025-11-27 20:11:38 +00:00
Shantur Rathore
9fa436b0b8 Ensure rollup linux binary in tauri prebuild 2025-11-27 20:11:02 +00:00
Shantur Rathore
ccd65fbc74 publish-server after build in dev 2025-11-27 19:48:35 +00:00
Shantur Rathore
daa7e3a6d1 Only publish server after successful builds 2025-11-27 19:45:58 +00:00
Shantur Rathore
ff356ac5ea bump Version to 0.2.6 2025-11-27 19:42:55 +00:00
Shantur Rathore
d68b92ff38 Gate npm publish on successful builds 2025-11-27 19:41:02 +00:00
Shantur Rathore
940216d98b Ensure tauri prebuild installs UI workspace deps 2025-11-27 19:40:36 +00:00
Shantur Rathore
69cd3cf545 bumpVersion to 0.2.5 2025-11-27 19:34:30 +00:00
Shantur Rathore
042a45db0d Ensure autoscroll reacts to UI toggles 2025-11-27 19:20:55 +00:00
Shantur Rathore
cc45c16d73 Stabilize message stream autoscroll 2025-11-27 18:48:11 +00:00
Shantur Rathore
91fb351a63 Improve sidebar default width and message autoscroll 2025-11-27 18:24:45 +00:00
Shantur Rathore
d9b149a7cb Reintroduce scroll restore effect for message stream 2025-11-27 17:25:19 +00:00
Shantur Rathore
222a467a19 Improve message stream caching and scroll performance 2025-11-27 16:51:05 +00:00
Shantur Rathore
18513939f7 Tighten message spacing and restyle reasoning blocks 2025-11-27 13:53:52 +00:00
Shantur Rathore
c123714271 Add thinking expansion preference and step finish styling 2025-11-27 13:39:03 +00:00
Shantur Rathore
5c82a2d653 Align assistant metadata display with message content 2025-11-27 13:26:31 +00:00
Shantur Rathore
435881529e match thinking toggle button sizing 2025-11-27 13:10:56 +00:00
Shantur Rathore
700342670c refine thinking accordion layout 2025-11-27 13:05:52 +00:00
Shantur Rathore
2f40f5eedf refine step and stream spacing 2025-11-27 10:41:34 +00:00
Shantur Rathore
54905c5626 tighten message spacing 2025-11-27 10:30:30 +00:00
Shantur Rathore
1bf1a4761d soften assistant and thinking headers 2025-11-27 10:27:29 +00:00
Shantur Rathore
755695a35a refine thinking cards and message layout 2025-11-27 10:24:41 +00:00
Shantur Rathore
6a9a442948 Handle session cleanup and error message status 2025-11-26 16:20:02 +00:00
Shantur Rathore
3db9b0f673 tidy normalized store hydration 2025-11-26 15:59:24 +00:00
Shantur Rathore
4e0e5dcdca Restore tool navigation and balanced scroll controls 2025-11-26 15:28:48 +00:00
Shantur Rathore
fad2809299 Improve message stream caching and virtualization for large sessions 2025-11-26 13:30:20 +00:00
Shantur Rathore
c77bfc2ee7 Avoid deep reconcile in message hydrate 2025-11-26 11:08:54 +00:00
Shantur Rathore
f1fa28dd2c Optimize message hydrate to reduce traversal 2025-11-26 10:59:15 +00:00
Shantur Rathore
91ace25333 Batch hydrate normalized messages for session load 2025-11-26 10:57:39 +00:00
Shantur Rathore
b54db28fb1 avoid deep proxying message info 2025-11-26 10:29:14 +00:00
Shantur Rathore
f13feb3062 Revert "cap session order/history lengths"
This reverts commit 4622bdc7ea.
2025-11-26 10:24:58 +00:00
Shantur Rathore
4622bdc7ea cap session order/history lengths 2025-11-26 10:23:49 +00:00
Shantur Rathore
919127b6d9 fix session closing crash 2025-11-26 10:20:08 +00:00
Shantur Rathore
27cd4515cd finish migration to message-store 2025-11-26 10:13:05 +00:00
Shantur Rathore
93a5c16cab migrate session event/actions to v2 store 2025-11-26 09:57:21 +00:00
Shantur Rathore
16b76385e2 chore: add message store v2 baseline 2025-11-26 09:42:10 +00:00
Shantur Rathore
9313b2bd6c Add showUsageMetrics to Prefs schema 2025-11-25 16:06:14 +00:00
Shantur Rathore
d25cb09714 Align selector shortcuts and widen sidebar 2025-11-25 16:04:19 +00:00
Shantur Rathore
0d0d1271c3 Move assistant usage chips 2025-11-25 13:12:54 +00:00
Shantur Rathore
1fd3b2e75c Add toggle for usage metrics 2025-11-25 12:26:38 +00:00
Shantur Rathore
bf32fcf136 Refine session usage tracking 2025-11-25 12:03:33 +00:00
Shantur Rathore
48eb6b8982 bump version 0.2.4 2025-11-25 08:56:51 +00:00
Shantur Rathore
797fafe854 Normalize host when parsing CLI 2025-11-25 00:52:46 +00:00
Shantur Rathore
b342660ed0 Improve welcome mobile layout 2025-11-25 00:50:21 +00:00
Shantur Rathore
169d5ddeb9 Use npx tauri for workspace builds 2025-11-24 20:16:49 +00:00
Shantur Rathore
38642b60e9 add command palette button 2025-11-24 14:37:15 +00:00
Shantur Rathore
01effb8924 refine prompt overlay layout 2025-11-24 14:16:25 +00:00
Shantur Rathore
b434bfd3e9 Ensure tauri bundle includes server deps 2025-11-24 11:20:27 +00:00
Shantur Rathore
ed769911d6 bump to version v0.2.3 2025-11-23 19:37:41 +00:00
Shantur Rathore
dd6efee900 disable SSE body timeouts and ignore workspace-stopped disconnects 2025-11-23 19:34:14 +00:00
Shantur Rathore
48a16a6702 ignore expected workspace stops when showing disconnect modal 2025-11-23 19:17:53 +00:00
Shantur Rathore
841b9daa1f only show disconnect modal on final status 2025-11-23 19:13:22 +00:00
Shantur Rathore
1741e49568 aggregate instance SSE streams through server bus so UI uses single connection 2025-11-23 19:07:10 +00:00
Shantur Rathore
8577b3d1e6 show loading status only for errors 2025-11-23 14:42:09 +00:00
Shantur Rathore
011533b3c4 improve prompt submission history handling 2025-11-23 14:41:49 +00:00
Shantur Rathore
002efad9ad cap CLI proxy concurrency 2025-11-23 14:40:37 +00:00
Shantur Rathore
3ce5569b82 route CLI logs to host processes only 2025-11-23 13:38:50 +00:00
Shantur Rathore
d7c0c225b9 chore: align monorepo package versions with 0.2.2 2025-11-23 12:05:36 +00:00
Shantur Rathore
f4de0103a8 Resolve CLI binary metadata for UI 2025-11-23 11:59:12 +00:00
Shantur Rathore
0a9b7fafed Align Tauri dev flow with shared renderer 2025-11-23 10:37:45 +00:00
Shantur Rathore
073604c9f5 Force dark theme defaults across shells 2025-11-23 10:00:16 +00:00
Shantur Rathore
4062b43380 Enable native dialogs across shells 2025-11-23 00:36:43 +00:00
Shantur Rathore
00bd9f9c1c Allow proxy streams to stay open 2025-11-22 21:50:04 +00:00
Shantur Rathore
3edb0ac09e Add runtime environment detection 2025-11-22 21:46:53 +00:00
Shantur Rathore
e9f3c4ee52 Unify loader assets across shells 2025-11-22 21:20:29 +00:00
Shantur Rathore
92420d9e02 Move screenshots to correct folder 2025-11-21 21:59:58 +00:00
Shantur Rathore
3688be06ee Bump version to 0.2.1 2025-11-21 21:56:29 +00:00
Shantur Rathore
8b2be441fc Ensure Tauri is bundled 2025-11-21 21:19:38 +00:00
Shantur Rathore
b2493a3a53 Use reusable publish workflow with explicit versions 2025-11-21 21:01:22 +00:00
Shantur Rathore
4eb3dbf492 Route npm publish through reusable workflow 2025-11-21 20:54:59 +00:00
Shantur Rathore
adbe0399b2 Fix tauri conf 2025-11-21 20:53:40 +00:00
Shantur Rathore
e2461661f7 Test npm publish 2025-11-21 20:44:54 +00:00
Shantur Rathore
cc012094b4 Test npm publish 2025-11-21 20:42:11 +00:00
Shantur Rathore
968a6f3cab Test npm publish 2025-11-21 20:40:25 +00:00
Shantur Rathore
f0d8634a83 Test npm publish 2025-11-21 20:36:46 +00:00
Shantur Rathore
9f862d5afc Require all linux tauri bundles and upload raw artifacts 2025-11-21 20:17:41 +00:00
Shantur Rathore
08f3d75015 Package tauri linux multi-target and zip windows app folder 2025-11-21 20:15:23 +00:00
Shantur Rathore
d9adab3022 Bundle linux tauri as appimage 2025-11-21 20:10:54 +00:00
Shantur Rathore
9019f7622e Failure on missing tauri linux bundle 2025-11-21 19:30:46 +00:00
Shantur Rathore
631b5002e7 Use non-native alert and confirm dialogs 2025-11-21 19:28:53 +00:00
Shantur Rathore
b1987008c7 Add id token for npmjs 2025-11-21 19:20:46 +00:00
Shantur Rathore
3338109d51 Try to fix linux-arm 2025-11-21 18:58:07 +00:00
Shantur Rathore
c0616df704 Ensure tauri build bundles server 2025-11-21 18:57:19 +00:00
Shantur Rathore
ca4030e86e Fix electron loading screen and linux arm build 2025-11-21 18:50:19 +00:00
Shantur Rathore
0a2d57624c Enable trusted npm publish for server 2025-11-21 18:38:37 +00:00
Shantur Rathore
dbbed94381 Standardize artifact uploads and enable tauri arm64 cross-build 2025-11-21 18:31:45 +00:00
Shantur Rathore
a088b948b4 Fix tauri-linux 2025-11-21 18:08:24 +00:00
Shantur Rathore
87faa32c7c Use npm tauri on wiindows 2025-11-21 18:06:59 +00:00
Shantur Rathore
873be2d6c1 Fix tauri app package.json JSON 2025-11-21 17:59:31 +00:00
Shantur Rathore
5fafed31d0 Use npm Tauri CLI in CI 2025-11-21 17:56:40 +00:00
Shantur Rathore
a763831b83 Use npm @tauri-apps/cli 2.9.4 and npm tauri scripts 2025-11-21 17:55:00 +00:00
Shantur Rathore
076aa4ff9a Use available tauri-cli 2.9.4 in CI 2025-11-21 17:25:13 +00:00
Shantur Rathore
31cbb9cc53 Align Tauri to published 2.5.2 and CLI 2.5.4 2025-11-21 17:23:21 +00:00
Shantur Rathore
ee6db23b14 Pin Tauri to 2.9.3 available on crates.io 2025-11-21 17:19:33 +00:00
Shantur Rathore
9fe3dcdc6d Upgrade Tauri tooling to 2.9.4 2025-11-21 17:16:09 +00:00
Shantur Rathore
ea644a7d0f Switch mac build to zip-only 2025-11-21 16:59:02 +00:00
Shantur Rathore
2f7ddd57dd Remove universal mac build outputs 2025-11-21 16:58:04 +00:00
Shantur Rathore
f2f108c14e Correct macOS Tauri targets and restore jobs 2025-11-21 16:45:16 +00:00
Shantur Rathore
731885f54a Deduplicate Tauri Windows job and update runners 2025-11-21 16:27:11 +00:00
Shantur Rathore
3c11a1bfcb Fix Tauri CI runners and prebuild portability 2025-11-21 16:24:45 +00:00
Shantur Rathore
166dd3f719 Update README for Tauri 2025-11-21 16:13:00 +00:00
Shantur Rathore
123da12468 Use cargo-binstall for tauri-cli in CI 2025-11-21 16:02:01 +00:00
Shantur Rathore
2a7cdbae42 Add arm64 Tauri build jobs 2025-11-21 15:53:33 +00:00
Shantur Rathore
88952a140a Install tauri-cli in CI for all platforms 2025-11-21 15:49:01 +00:00
Shantur Rathore
b64ea411e6 Install rollup native for Tauri builds on macOS/Linux 2025-11-21 15:38:47 +00:00
Shantur Rathore
44b2bb1b68 Fix Windows Tauri build rollup dependency 2025-11-21 15:37:49 +00:00
Shantur Rathore
efabf83a26 Add Tauri build pipeline to releases 2025-11-21 15:31:11 +00:00
Shantur Rathore
ac540a18f2 Add Tauri app sources 2025-11-21 15:22:59 +00:00
Shantur Rathore
4bad384ca0 Switch server publish to npm trusted publisher (OIDC) 2025-11-21 15:20:45 +00:00
Shantur Rathore
0eb00901b9 Bundle server assets into Tauri app build 2025-11-21 15:19:28 +00:00
214 changed files with 31223 additions and 6329 deletions

71
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Bug Report
description: Report a bug or regression in CodeNomad
labels:
- bug
title: "[Bug]: "
body:
- type: markdown
attributes:
value: |
Thanks for filing a bug report! Please review open issues before submitting a new one and provide as much detail as possible so we can reproduce the problem.
- type: dropdown
id: variant
attributes:
label: App Variant
description: Which build are you running when this issue appears?
multiple: false
options:
- Electron
- Tauri
- Server CLI
validations:
required: true
- type: input
id: os-version
attributes:
label: Operating System & Version
description: Include the OS family and version (e.g., macOS 15.0, Ubuntu 24.04, Windows 11 23H2).
placeholder: macOS 15.0
validations:
required: true
- type: input
id: summary
attributes:
label: Issue Summary
description: Briefly describe what is happening.
placeholder: A quick one sentence problem statement
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to Reproduce
description: List the steps needed to reproduce the problem.
placeholder: |
1. Go to ...
2. Click ...
3. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: Describe what you expected to happen instead.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs & Screenshots
description: Attach relevant logs, stack traces, or screenshots if available.
placeholder: Paste logs here or drag-and-drop files onto the issue.
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional Context
description: Add any other context about the problem here.
validations:
required: false

View File

@@ -4,27 +4,40 @@ on:
workflow_call:
inputs:
version:
description: "Version to apply to workspace packages"
required: true
description: "Version to apply to workspace packages (release builds)"
required: false
default: ""
type: string
tag:
description: "Git tag to upload assets to"
required: true
description: "Git tag to upload assets to (release builds)"
required: false
default: ""
type: string
release_name:
description: "Release name (unused here, for context)"
required: true
required: false
default: ""
type: string
upload:
description: "Upload built artifacts to the GitHub release"
required: false
default: true
type: boolean
set_versions:
description: "Run npm version to set workspace versions"
required: false
default: true
type: boolean
permissions:
contents: write
# Permissions are intentionally omitted here so callers can choose
# least-privilege (e.g. dev CI uses read-only; releases grant write).
env:
NODE_VERSION: 20
jobs:
build-macos:
runs-on: macos-13
runs-on: macos-15-intel
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
@@ -40,34 +53,31 @@ jobs:
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
- name: Build macOS binaries
- name: Build macOS binaries (Electron)
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*; do
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
case "$file" in
*.dmg|*.zip)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-windows:
runs-on: windows-latest
runs-on: windows-2025
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
@@ -83,29 +93,30 @@ jobs:
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
- name: Build Windows binaries
- name: Build Windows binaries (Electron)
run: npm run build:win --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
run: |
Get-ChildItem -Path "packages/electron-app/release" -File | Where-Object {
$_.Name -match '\\.(exe|zip)$'
} | ForEach-Object {
Get-ChildItem -Path "packages/electron-app/release" -Filter *.zip -File | ForEach-Object {
Write-Host "Uploading $($_.FullName)"
gh release upload $env:TAG $_.FullName --clobber
}
build-linux:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
@@ -120,35 +131,455 @@ jobs:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build Linux binaries (Electron)
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
- name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-macos:
runs-on: macos-15-intel
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-x64 --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-x64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build macOS bundle (Tauri)
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
fi
- name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/tauri-app/release-tauri/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-macos-arm64:
runs-on: macos-26
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-darwin-arm64 --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-darwin-arm64@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build macOS bundle (Tauri, arm64)
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (macOS arm64)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
BUNDLE_ROOT="packages/tauri-app/target/release/bundle"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
fi
- name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/tauri-app/release-tauri/*.zip; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-windows:
runs-on: windows-2025
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${{ env.VERSION }} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
shell: bash
- name: Install dependencies
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-win32-x64-msvc --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
shell: bash
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-win32-x64-msvc@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build Windows bundle (Tauri)
shell: bash
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (Windows)
if: ${{ inputs.upload }}
shell: pwsh
run: |
$bundleRoot = "packages/tauri-app/target/release/bundle"
$artifactDir = "packages/tauri-app/release-tauri"
if (Test-Path $artifactDir) { Remove-Item $artifactDir -Recurse -Force }
New-Item -ItemType Directory -Path $artifactDir | Out-Null
$exe = Get-ChildItem -Path $bundleRoot -Recurse -File -Filter *.exe | Select-Object -First 1
if ($null -ne $exe) {
$dest = Join-Path $artifactDir ("CodeNomad-Tauri-$env:VERSION-windows-x64.zip")
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
}
- name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
run: |
if (Test-Path "packages/tauri-app/release-tauri") {
Get-ChildItem -Path "packages/tauri-app/release-tauri" -Filter *.zip -File | ForEach-Object {
Write-Host "Uploading $($_.FullName)"
gh release upload $env:TAG $_.FullName --clobber
}
}
build-tauri-linux:
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
- name: Install Linux build dependencies (Tauri)
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libgtk-3-dev \
libglib2.0-dev \
libwebkit2gtk-4.1-dev \
libsoup-3.0-dev \
libayatana-appindicator3-dev \
librsvg2-dev
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Prebuild (Tauri)
run: npm run prebuild --workspace @codenomad/tauri-app
- name: Ensure tauri native binary
working-directory: packages/tauri-app
run: |
set -euo pipefail
for attempt in 1 2 3; do
if [ "$attempt" -gt 1 ]; then
echo "Retrying Tauri CLI install (attempt $attempt)..."
fi
npm install @tauri-apps/cli@2.9.4 @tauri-apps/cli-linux-x64-gnu@2.9.4 --no-save --no-audit --no-fund --workspaces=false
node -e "require('@tauri-apps/cli'); console.log('Tauri CLI loaded')" && exit 0
done
echo "Tauri CLI failed to load after retries" >&2
exit 1
- name: Build Linux bundle (Tauri)
working-directory: packages/tauri-app
run: npm exec -- tauri build
- name: Package Tauri artifacts (Linux)
if: ${{ inputs.upload }}
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
shopt -s nullglob globstar
find_one() {
find "$SEARCH_ROOT" -type f -iname "$1" | head -n1
}
appimage=$(find_one "*.AppImage")
deb=$(find_one "*.deb")
rpm=$(find_one "*.rpm")
if [ -z "$appimage" ] || [ -z "$deb" ] || [ -z "$rpm" ]; then
echo "Missing bundle(s): appimage=${appimage:-none} deb=${deb:-none} rpm=${rpm:-none}" >&2
exit 1
fi
cp "$appimage" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.AppImage"
cp "$deb" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.deb"
cp "$rpm" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.rpm"
- name: Upload Tauri release assets (Linux)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/tauri-app/release-tauri/*; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-tauri-linux-arm64:
if: ${{ false }}
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
TAG: ${{ inputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: linux/arm64
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Setup Rust (Tauri)
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- name: Install Linux build dependencies (Tauri)
run: |
sudo dpkg --add-architecture arm64
sudo tee /etc/apt/sources.list.d/arm64.list >/dev/null <<'EOF'
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-updates main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-security main restricted universe multiverse
deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports noble-backports main restricted universe multiverse
EOF
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
gcc-aarch64-linux-gnu \
g++-aarch64-linux-gnu \
libgtk-3-dev:arm64 \
libglib2.0-dev:arm64 \
libwebkit2gtk-4.1-dev:arm64 \
libsoup-3.0-dev:arm64 \
libayatana-appindicator3-dev:arm64 \
librsvg2-dev:arm64
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
run: npm install @rollup/rollup-linux-arm64-gnu --no-save
- name: Build Linux binaries
run: npm run build:linux --workspace @neuralnomads/codenomad-electron-app
- name: Build Linux bundle (Tauri arm64)
env:
TAURI_BUILD_TARGET: aarch64-unknown-linux-gnu
PKG_CONFIG_PATH: /usr/lib/aarch64-linux-gnu/pkgconfig
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
run: npm run build --workspace @codenomad/tauri-app
- name: Upload release assets
- name: Package Tauri artifacts (Linux arm64)
run: |
set -euo pipefail
SEARCH_ROOT="packages/tauri-app/target"
ARTIFACT_DIR="packages/tauri-app/release-tauri"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
shopt -s nullglob globstar
first_artifact=$(find "$SEARCH_ROOT" -type f \( -name "*.AppImage" -o -name "*.deb" -o -name "*.rpm" -o -name "*.tar.gz" \) | head -n1)
fallback_bin="$SEARCH_ROOT/release/codenomad-tauri"
if [ -n "$first_artifact" ]; then
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$first_artifact"
elif [ -f "$fallback_bin" ]; then
zip -j "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-linux-x64.zip" "$fallback_bin"
else
echo "No bundled artifact found under $SEARCH_ROOT and no binary at $fallback_bin" >&2
exit 1
fi
- name: Upload Tauri release assets (Linux arm64)
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*; do
for file in packages/tauri-app/release-tauri/*.zip; do
[ -f "$file" ] || continue
case "$file" in
*.AppImage|*.deb|*.tar.gz)
gh release upload "$TAG" "$file" --clobber
;;
*)
echo "Skipping non-installer asset: $file"
;;
esac
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done
build-linux-rpm:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.version }}
@@ -170,10 +601,11 @@ jobs:
sudo gem install --no-document fpm
- name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install project dependencies
run: npm ci --workspaces
run: npm ci --workspaces --include=optional
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
@@ -182,9 +614,12 @@ jobs:
run: npm run build:linux-rpm --workspace @neuralnomads/codenomad-electron-app
- name: Upload RPM release assets
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
for file in packages/electron-app/release/*.rpm; do
[ -f "$file" ] || continue
echo "Uploading $file"
gh release upload "$TAG" "$file" --clobber
done

View File

@@ -1,88 +1,18 @@
name: Dev Release
name: Dev CI
on:
push:
branches:
- dev
workflow_dispatch:
permissions:
contents: write
env:
NODE_VERSION: 20
contents: read
jobs:
prepare-dev:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.versions.outputs.version }}
tag: ${{ steps.versions.outputs.tag }}
release_name: ${{ steps.versions.outputs.release_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Compute dev versions
id: versions
run: |
BASE_VERSION=$(node -p "require('./package.json').version")
DEV_VERSION="${BASE_VERSION}-dev"
TAG="v${DEV_VERSION}"
echo "version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.versions.outputs.tag }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists"
else
gh release create "$TAG" --title "$TAG" --generate-notes
fi
build-and-upload:
needs: prepare-dev
dev-ci:
uses: ./.github/workflows/build-and-upload.yml
with:
version: ${{ needs.prepare-dev.outputs.version }}
tag: ${{ needs.prepare-dev.outputs.tag }}
release_name: ${{ needs.prepare-dev.outputs.release_name }}
upload: false
set_versions: false
secrets: inherit
publish-server:
needs: build-and-upload
runs-on: ubuntu-latest
env:
NODE_VERSION: 20
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare-dev.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Set workspace versions
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build server package
run: npm run build --workspace @neuralnomads/codenomad
- name: Publish server package to dev tag
run: npm publish --workspace @neuralnomads/codenomad --access public --tag dev

View File

@@ -0,0 +1,74 @@
name: Manual NPM Publish
on:
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 0.2.0-dev)"
required: false
type: string
dist_tag:
description: "npm dist-tag"
required: false
default: dev
type: string
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: dev
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
env:
NODE_VERSION: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Ensure npm >=11.5.1
run: npm install -g npm@latest
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build server package (includes UI bundling)
run: npm run build --workspace @neuralnomads/codenomad
- name: Set publish metadata
shell: bash
run: |
VERSION_INPUT="${{ inputs.version }}"
if [ -z "$VERSION_INPUT" ]; then
VERSION_INPUT=$(node -p "require('./package.json').version")
fi
echo "VERSION=$VERSION_INPUT" >> "$GITHUB_ENV"
echo "DIST_TAG=${{ inputs.dist_tag || 'dev' }}" >> "$GITHUB_ENV"
- name: Bump package version for publish
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version
- name: Publish server package with provenance
env:
NPM_CONFIG_PROVENANCE: true
NPM_CONFIG_REGISTRY: https://registry.npmjs.org
run: |
npm publish --workspace @neuralnomads/codenomad --access public --tag ${DIST_TAG} --provenance

View File

@@ -6,96 +6,12 @@ on:
- main
permissions:
id-token: write
contents: write
env:
NODE_VERSION: 20
jobs:
prepare-release:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
tag: ${{ steps.ensure_tag.outputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Read version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Ensure git tag
id: ensure_tag
env:
VERSION: ${{ steps.get_version.outputs.version }}
run: |
TAG="v${VERSION}"
git fetch --tags
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Tag $TAG already exists"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG"
git push origin "$TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Created tag $TAG"
fi
- name: Ensure GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.ensure_tag.outputs.tag }}
VERSION: ${{ steps.get_version.outputs.version }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists"
else
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
fi
build-and-upload:
needs: prepare-release
uses: ./.github/workflows/build-and-upload.yml
release:
uses: ./.github/workflows/reusable-release.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
tag: ${{ needs.prepare-release.outputs.tag }}
release_name: CodeNomad v${{ needs.prepare-release.outputs.version }}
dist_tag: latest
secrets: inherit
publish-server:
needs: build-and-upload
runs-on: ubuntu-latest
env:
NODE_VERSION: 20
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --workspaces
- name: Ensure rollup native binary
run: npm install @rollup/rollup-linux-x64-gnu --no-save
- name: Build server package
run: npm run build --workspace @neuralnomads/codenomad
- name: Publish server package
run: npm publish --workspace @neuralnomads/codenomad --access public --tag latest

80
.github/workflows/reusable-release.yml vendored Normal file
View File

@@ -0,0 +1,80 @@
name: Reusable Release
on:
workflow_call:
inputs:
version_suffix:
description: "Suffix appended to package.json version"
required: false
default: ""
type: string
dist_tag:
description: "npm dist-tag to publish under"
required: false
default: dev
type: string
permissions:
id-token: write
contents: write
env:
NODE_VERSION: 20
jobs:
prepare-release:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.versions.outputs.version }}
tag: ${{ steps.versions.outputs.tag }}
release_name: ${{ steps.versions.outputs.release_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Compute release versions
id: versions
env:
VERSION_SUFFIX: ${{ inputs.version_suffix }}
run: |
BASE_VERSION=$(node -p "require('./package.json').version")
VERSION="${BASE_VERSION}${VERSION_SUFFIX}"
TAG="v${VERSION}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.versions.outputs.tag }}
run: |
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists"
else
gh release create "$TAG" --title "$TAG" --generate-notes
fi
build-and-upload:
needs: prepare-release
uses: ./.github/workflows/build-and-upload.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
tag: ${{ needs.prepare-release.outputs.tag }}
release_name: ${{ needs.prepare-release.outputs.release_name }}
secrets: inherit
publish-server:
needs:
- prepare-release
- build-and-upload
uses: ./.github/workflows/manual-npm-publish.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
dist_tag: ${{ inputs.dist_tag }}
secrets: inherit

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ release/
.vite/
.electron-vite/
out/
.dir-locals.el
.opencode/bashOutputs/

View File

@@ -1,6 +1,5 @@
---
description: Develops Web UI components.
mode: all
model: zai-coding-plan/glm-4.6
---
You are a Web Frontend Developer Agent. Your primary focus is on developing SolidJS UI components, ensuring adherence to modern web best practices, excellent UI/UX, and efficient data integration.

View File

@@ -13,10 +13,10 @@ _Manage multiple OpenCode sessions side-by-side._
![Command palette overlay](docs/screenshots/command-palette.png)
_Global command palette for keyboard-first control._
![Image Previews](images/image-previews.png)
![Image Previews](docs/screenshots/image-previews.png)
_Rich media previews for images and assets._
![Browser Support](images/browser-support.png)
![Browser Support](docs/screenshots/browser-support.png)
_Browser support via CodeNomad Server._
</details>
@@ -26,11 +26,17 @@ _Browser support via CodeNomad Server._
Choose the way that fits your workflow:
### 🖥️ Desktop App (Recommended)
The best experience. A native application with global shortcuts, deeper system integration, and a dedicated window.
The best experience. A native application (Electron-based) with global shortcuts, deeper system integration, and a dedicated window.
- **Download**: Grab the latest installer for macOS, Windows, or Linux from the [Releases Page](https://github.com/shantur/CodeNomad/releases).
- **Run**: Install and launch like any other app.
### 🦀 Tauri App (Experimental)
We are also working on a lightweight, high-performance version built with [Tauri](https://tauri.app). It is currently in active development.
- **Download**: Experimental builds are available on the [Releases Page](https://github.com/shantur/CodeNomad/releases).
- **Source**: Check out `packages/tauri-app` if you're interested in contributing.
### 💻 CodeNomad Server
Run CodeNomad as a local server and access it via your web browser. Perfect for remote development (SSH/VPN) or running as a service.
@@ -38,6 +44,12 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
npx @neuralnomads/codenomad --launch
```
For dev version
```bash
npx @neuralnomads/codenomad@dev --launch
```
This command starts the server and opens the web client in your default browser.
## Highlights
@@ -52,6 +64,18 @@ This command starts the server and opens the web client in your default browser.
- **[OpenCode CLI](https://opencode.ai)**: Must be installed and available in your `PATH`.
- **Node.js 18+**: Required if running the CLI server or building from source.
## Troubleshooting
### macOS says the app is damaged
If macOS reports that "CodeNomad.app is damaged and can't be opened," Gatekeeper flagged the download because the app is not yet notarized. You can clear the quarantine flag after moving CodeNomad into `/Applications`:
```bash
xattr -l /Applications/CodeNomad.app
xattr -dr com.apple.quarantine /Applications/CodeNomad.app
```
After removing the quarantine attribute, launch the app normally. On Intel Macs you may also need to approve CodeNomad from **System Settings → Privacy & Security** the first time you run it.
## Architecture & Development
CodeNomad is a monorepo split into specialized packages. If you want to contribute or build from source, check out the individual package documentation:

View File

@@ -29,13 +29,13 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
│ │ │ State Management (SolidJS Stores) │ │ │
│ │ │ - instances[] │ │ │
│ │ │ - sessions[] per instance │ │ │
│ │ │ - messages[] per session │ │ │
│ │ │ - normalized message store per session │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ UI Components │ │ │
│ │ │ - InstanceTabs │ │ │
│ │ │ - SessionTabs │ │ │
│ │ │ - MessageStream │ │ │
│ │ │ - MessageSection │ │ │
│ │ │ - PromptInput │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │

82
dev-docs/solidjs-llms.txt Normal file
View File

@@ -0,0 +1,82 @@
# SolidJS Documentation
> Solid is a modern JavaScript framework for building user interfaces with fine-grained reactivity. It compiles JSX to real DOM elements and updates only what changes, delivering exceptional performance without a virtual DOM. Solid provides reactive primitives like signals, effects, and stores for predictable state management.
SolidJS is a declarative JavaScript framework that prioritizes performance and developer experience. Unlike frameworks that re-run components on every update, Solid components run once during initialization and set up a reactive system that precisely updates the DOM when dependencies change.
Key principles:
- Fine-grained reactivity: Updates only the specific DOM nodes that depend on changed data
- Compile-time optimization: JSX transforms into efficient DOM operations
- Unidirectional data flow: Props are read-only, promoting predictable state management
- Component lifecycle: Components run once, with reactive primitives handling updates
**Use your web fetch tool on any of the following links to understand the relevant concept**.
## Quick Start
- [Overview](https://docs.solidjs.com/): Framework introduction and key advantages
- [Quick Start](https://docs.solidjs.com/quick-start): Installation and project setup with create-solid
- [Interactive Tutorial](https://www.solidjs.com/tutorial/introduction_basics): Learn Solid basics through guided examples
- [Playground](https://playground.solidjs.com/): Experiment with Solid directly in your browser
## Core Concepts
- [Intro to Reactivity](https://docs.solidjs.com/concepts/intro-to-reactivity): Signals, subscribers, and reactive principles
- [Understanding JSX](https://docs.solidjs.com/concepts/understanding-jsx): How Solid uses JSX and key differences from HTML
- [Components Basics](https://docs.solidjs.com/concepts/components/basics): Component trees, lifecycles, and composition patterns
- [Signals](https://docs.solidjs.com/concepts/signals): Core reactive primitive for state management with getters/setters
- [Effects](https://docs.solidjs.com/concepts/effects): Side effects, dependency tracking, and lifecycle functions
- [Stores](https://docs.solidjs.com/concepts/stores): Complex state management with proxy-based reactivity
- [Context](https://docs.solidjs.com/concepts/context): Cross-component state sharing without prop drilling
## Component APIs
- [Props](https://docs.solidjs.com/concepts/components/props): Passing data and handlers to child components
- [Event Handlers](https://docs.solidjs.com/concepts/components/event-handlers): Managing user interactions
- [Class and Style](https://docs.solidjs.com/concepts/components/class-style): Dynamic styling approaches
- [Refs](https://docs.solidjs.com/concepts/refs): Accessing DOM elements directly
## Control Flow
- [Conditional Rendering](https://docs.solidjs.com/concepts/control-flow/conditional-rendering): Show, Switch, and Match components
- [List Rendering](https://docs.solidjs.com/concepts/control-flow/list-rendering): For, Index, and keyed iteration
- [Dynamic](https://docs.solidjs.com/concepts/control-flow/dynamic): Dynamic component switching
- [Portal](https://docs.solidjs.com/concepts/control-flow/portal): Rendering outside component hierarchy
- [Error Boundary](https://docs.solidjs.com/concepts/control-flow/error-boundary): Graceful error handling
## Derived Values
- [Derived Signals](https://docs.solidjs.com/concepts/derived-values/derived-signals): Computed values from signals
- [Memos](https://docs.solidjs.com/concepts/derived-values/memos): Cached computed values for performance
## State Management
- [Basic State Management](https://docs.solidjs.com/guides/state-management): One-way data flow and lifting state
- [Complex State Management](https://docs.solidjs.com/guides/complex-state-management): Stores for scalable applications
- [Fetching Data](https://docs.solidjs.com/guides/fetching-data): Async data with createResource
## Routing
- [Routing & Navigation](https://docs.solidjs.com/guides/routing-and-navigation): @solidjs/router setup and usage
- [Dynamic Routes](https://docs.solidjs.com/guides/routing-and-navigation#dynamic-routes): Route parameters and validation
- [Nested Routes](https://docs.solidjs.com/guides/routing-and-navigation#nested-routes): Hierarchical route structures
- [Preload Functions](https://docs.solidjs.com/guides/routing-and-navigation#preload-functions): Parallel data fetching
## Advanced Topics
- [Fine-Grained Reactivity](https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity): Deep dive into reactive system
- [TypeScript](https://docs.solidjs.com/configuration/typescript): Type safety and configuration
## Ecosystem
- [Solid Router](https://docs.solidjs.com/solid-router/): File-system routing and data APIs
- [SolidStart](https://docs.solidjs.com/solid-start/): Full-stack meta-framework
- [Solid Meta](https://docs.solidjs.com/solid-meta/): Document head management
- [Templates](https://github.com/solidjs/templates): Starter templates for different setups
## Optional
- [Ecosystem Libraries](https://www.solidjs.com/ecosystem): Community packages and tools
- [API Reference](https://docs.solidjs.com/reference/): Complete API documentation
- [Testing](https://docs.solidjs.com/guides/testing): Testing strategies and utilities
- [Deployment](https://docs.solidjs.com/guides/deploying-your-app): Build and deployment options

View File

@@ -49,7 +49,7 @@ packages/opencode-client/
│ ├── components/
│ │ ├── instance-tabs.tsx # Level 1 tabs
│ │ ├── session-tabs.tsx # Level 2 tabs
│ │ ├── message-stream.tsx # Messages display
│ │ ├── message-stream-v2.tsx # Messages display (normalized store)
│ │ ├── message-item.tsx # Single message
│ │ ├── tool-call.tsx # Tool execution display
│ │ ├── prompt-input.tsx # Input with attachments
@@ -153,16 +153,24 @@ interface Session {
providerId: string
modelId: string
}
messages: Message[]
status: SessionStatus
createdAt: number
updatedAt: number
version: string
time: { created: number; updated: number }
revert?: {
messageID?: string
partID?: string
snapshot?: string
diff?: string
}
}
// Message content lives in the normalized message-v2 store
// keyed by instanceId/sessionId/messageId
type SessionStatus =
| "idle" // No activity
| "streaming" // Assistant responding
| "error" // Error occurred
```
### UI Store

View File

Before

Width:  |  Height:  |  Size: 845 KiB

After

Width:  |  Height:  |  Size: 845 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

1768
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
{
"name": "codenomad-workspace",
"version": "0.2.0",
"version": "0.5.1",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {
"packages": [
"packages/*"
"packages/server",
"packages/ui",
"packages/electron-app",
"packages/tauri-app"
]
},
"scripts": {
@@ -23,5 +26,8 @@
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
},
"devDependencies": {
"baseline-browser-mapping": "^2.9.11"
}
}

View File

@@ -6,6 +6,7 @@ const uiRoot = resolve(__dirname, "../ui")
const uiSrc = resolve(uiRoot, "src")
const uiRendererRoot = resolve(uiRoot, "src/renderer")
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
export default defineConfig({
main: {
@@ -52,9 +53,19 @@ export default defineConfig({
port: 3000,
},
build: {
minify: false,
cssMinify: false,
sourcemap: true,
outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: {
input: uiRendererEntry,
input: {
main: uiRendererEntry,
loading: uiRendererLoadingEntry,
},
output: {
compact: false,
minifyInternalExports: false,
},
},
},
},

View File

@@ -1,5 +1,17 @@
import { BrowserWindow, ipcMain } from "electron"
import type { CliLogEntry, CliProcessManager, CliStatus } from "./process-manager"
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
import type { CliProcessManager, CliStatus } from "./process-manager"
interface DialogOpenRequest {
mode: "directory" | "file"
title?: string
defaultPath?: string
filters?: Array<{ name?: string; extensions: string[] }>
}
interface DialogOpenResult {
canceled: boolean
paths: string[]
}
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
cliManager.on("status", (status: CliStatus) => {
@@ -14,12 +26,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
}
})
cliManager.on("log", (entry: CliLogEntry) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:log", entry)
}
})
cliManager.on("error", (error: Error) => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("cli:error", { message: error.message })
@@ -27,4 +33,33 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
})
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
ipcMain.handle("cli:restart", async () => {
const devMode = process.env.NODE_ENV === "development"
await cliManager.stop()
return cliManager.start({ dev: devMode })
})
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
const properties: OpenDialogOptions["properties"] =
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
const filters = request.filters?.map((filter) => ({
name: filter.name ?? "Files",
extensions: filter.extensions,
}))
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
const dialogOptions: OpenDialogOptions = {
title: request.title,
defaultPath: request.defaultPath,
properties,
filters,
}
const result = windowTarget
? await dialog.showOpenDialog(windowTarget, dialogOptions)
: await dialog.showOpenDialog(dialogOptions)
return { canceled: result.canceled, paths: result.filePaths }
})
}

View File

@@ -1,4 +1,4 @@
import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron"
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import { existsSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
@@ -30,22 +30,113 @@ function getIconPath() {
return join(mainDirname, "../resources/icon.png")
}
function getLoadingHtmlPath() {
type LoadingTarget =
| { type: "url"; source: string }
| { type: "file"; source: string }
function resolveDevLoadingUrl(): string | null {
if (app.isPackaged) {
return join(process.resourcesPath, "loading.html")
return null
}
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
if (!devBase) {
return null
}
const distResources = join(mainDirname, "../resources/loading.html")
if (existsSync(distResources)) {
return distResources
try {
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
return new URL("loading.html", normalized).toString()
} catch (error) {
console.warn("[cli] failed to construct dev loading URL", devBase, error)
return null
}
}
function resolveLoadingTarget(): LoadingTarget {
const devUrl = resolveDevLoadingUrl()
if (devUrl) {
return { type: "url", source: devUrl }
}
const filePath = resolveLoadingFilePath()
return { type: "file", source: filePath }
}
function resolveLoadingFilePath() {
const candidates = [
join(app.getAppPath(), "dist/renderer/loading.html"),
join(process.resourcesPath, "dist/renderer/loading.html"),
join(mainDirname, "../dist/renderer/loading.html"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
const devResources = join(mainDirname, "../electron/resources/loading.html")
if (existsSync(devResources)) {
return devResources
return join(app.getAppPath(), "dist/renderer/loading.html")
}
function loadLoadingScreen(window: BrowserWindow) {
const target = resolveLoadingTarget()
const loader =
target.type === "url"
? window.loadURL(target.source)
: window.loadFile(target.source)
loader.catch((error) => {
console.error("[cli] failed to load loading screen:", error)
})
}
function getAllowedRendererOrigins(): string[] {
const origins = new Set<string>()
const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL]
for (const candidate of rendererCandidates) {
if (!candidate) {
continue
}
try {
origins.add(new URL(candidate).origin)
} catch (error) {
console.warn("[cli] failed to parse origin for", candidate, error)
}
}
return Array.from(origins)
}
function shouldOpenExternally(url: string): boolean {
try {
const parsed = new URL(url)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return true
}
const allowedOrigins = getAllowedRendererOrigins()
return !allowedOrigins.includes(parsed.origin)
} catch {
return false
}
}
function setupNavigationGuards(window: BrowserWindow) {
const handleExternal = (url: string) => {
shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error))
}
return join(process.cwd(), "electron/resources/loading.html")
window.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternally(url)) {
handleExternal(url)
return { action: "deny" }
}
return { action: "allow" }
})
window.webContents.on("will-navigate", (event, url) => {
if (shouldOpenExternally(url)) {
event.preventDefault()
handleExternal(url)
}
})
}
let cachedPreloadPath: string | null = null
@@ -112,14 +203,15 @@ function createWindow() {
},
})
setupNavigationGuards(mainWindow)
if (isMac) {
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
const loadingHtml = getLoadingHtmlPath()
showingLoadingScreen = true
currentCliUrl = null
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
loadLoadingScreen(mainWindow)
if (process.env.NODE_ENV === "development") {
mainWindow.webContents.openDevTools({ mode: "detach" })
@@ -156,8 +248,7 @@ function showLoadingScreen(force = false) {
showingLoadingScreen = true
currentCliUrl = null
pendingCliUrl = null
const loadingHtml = getLoadingHtmlPath()
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
loadLoadingScreen(mainWindow)
}
function startCliPreload(url: string) {

View File

@@ -2,7 +2,8 @@ import { spawn, type ChildProcess } from "child_process"
import { app } from "electron"
import { createRequire } from "module"
import { EventEmitter } from "events"
import { existsSync } from "fs"
import { existsSync, readFileSync } from "fs"
import os from "os"
import path from "path"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
@@ -10,6 +11,7 @@ const nodeRequire = createRequire(import.meta.url)
type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all"
export interface CliStatus {
state: CliState
@@ -34,6 +36,36 @@ interface CliEntryResolution {
runnerPath?: string
}
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function resolveConfigPath(configPath?: string): string {
const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH
if (target.startsWith("~/")) {
return path.join(os.homedir(), target.slice(2))
}
return path.resolve(target)
}
function resolveHostForMode(mode: ListeningMode): string {
return mode === "local" ? "127.0.0.1" : "0.0.0.0"
}
function readListeningModeFromConfig(): ListeningMode {
try {
const configPath = resolveConfigPath(process.env.CLI_CONFIG)
if (!existsSync(configPath)) return "local"
const content = readFileSync(configPath, "utf-8")
const parsed = JSON.parse(content)
const mode = parsed?.preferences?.listeningMode
if (mode === "local" || mode === "all") {
return mode
}
} catch (error) {
console.warn("[cli] failed to read listening mode from config", error)
}
return "local"
}
export declare interface CliProcessManager {
on(event: "status", listener: (status: CliStatus) => void): this
on(event: "ready", listener: (status: CliStatus) => void): this
@@ -58,10 +90,12 @@ export class CliProcessManager extends EventEmitter {
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
const args = this.buildCliArgs(options)
const listeningMode = this.resolveListeningMode()
const host = resolveHostForMode(listeningMode)
const args = this.buildCliArgs(options, host)
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry}`,
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
@@ -116,7 +150,7 @@ export class CliProcessManager extends EventEmitter {
const timeout = setTimeout(() => {
this.handleTimeout()
reject(new Error("CLI startup timeout"))
}, 15000)
}, 60000)
this.once("ready", (status) => {
clearTimeout(timeout)
@@ -158,6 +192,10 @@ export class CliProcessManager extends EventEmitter {
return { ...this.status }
}
private resolveListeningMode(): ListeningMode {
return readListeningModeFromConfig()
}
private handleTimeout() {
if (this.child) {
this.child.kill("SIGKILL")
@@ -232,8 +270,8 @@ export class CliProcessManager extends EventEmitter {
this.emit("status", this.status)
}
private buildCliArgs(options: StartOptions): string[] {
const args = ["serve", "--host", "127.0.0.1", "--port", "0"]
private buildCliArgs(options: StartOptions, host: string): string[] {
const args = ["serve", "--host", host, "--port", "0"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")
@@ -263,47 +301,32 @@ export class CliProcessManager extends EventEmitter {
private resolveCliEntry(options: StartOptions): CliEntryResolution {
if (options.dev) {
const tsxPath = this.resolveTsx()
const sourceCandidates = [
path.resolve(app.getAppPath(), "..", "server", "src", "index.ts"),
path.resolve(app.getAppPath(), "..", "packages", "server", "src", "index.ts"),
path.resolve(process.cwd(), "packages", "server", "src", "index.ts"),
]
const sourceEntry = sourceCandidates.find((candidate) => existsSync(candidate))
if (tsxPath && sourceEntry) {
return { entry: sourceEntry, runner: "tsx", runnerPath: tsxPath }
if (!tsxPath) {
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
}
const devEntry = this.resolveDevEntry()
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
}
const dist = this.tryResolveDist()
if (dist) {
return { entry: dist, runner: "node" }
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad.")
const distEntry = this.resolveProdEntry()
return { entry: distEntry, runner: "node" }
}
private resolveTsx(): string | null {
try {
const resolved = nodeRequire.resolve("tsx/dist/cli.js")
if (resolved && existsSync(resolved)) {
return resolved
}
} catch {
return null
}
return null
}
private tryResolveDist(): string | null {
const candidates: Array<string | (() => string)> = [
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js"),
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js", { paths: [app.getAppPath()] }),
path.join(app.getAppPath(), "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
path.resolve(app.getAppPath(), "..", "server", "dist", "bin.js"),
path.resolve(app.getAppPath(), "..", "packages", "server", "dist", "bin.js"),
path.join(process.resourcesPath, "app.asar.unpacked", "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
() => nodeRequire.resolve("tsx/cli"),
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
]
for (const candidate of candidates) {
try {
const resolved = typeof candidate === "function" ? candidate() : candidate
@@ -314,7 +337,28 @@ export class CliProcessManager extends EventEmitter {
continue
}
}
return null
}
private resolveDevEntry(): string {
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
if (!existsSync(entry)) {
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
}
return entry
}
private resolveProdEntry(): string {
try {
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
if (existsSync(entry)) {
return entry
}
} catch {
// fall through to error below
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
}

View File

@@ -59,7 +59,7 @@ export function setupStorageIPC() {
return await readConfigWithCache()
} catch (error) {
// Return empty config if file doesn't exist
return JSON.stringify({ preferences: { showThinkingBlocks: false }, recentFolders: [] }, null, 2)
return JSON.stringify({ preferences: { showThinkingBlocks: false, thinkingBlocksExpansion: "expanded" }, recentFolders: [] }, null, 2)
}
})

View File

@@ -5,15 +5,13 @@ const electronAPI = {
ipcRenderer.on("cli:status", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:status")
},
onCliLog: (callback) => {
ipcRenderer.on("cli:log", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:log")
},
onCliError: (callback) => {
ipcRenderer.on("cli:error", (_, data) => callback(data))
return () => ipcRenderer.removeAllListeners("cli:error")
},
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
}
contextBridge.exposeInMainWorld("electronAPI", electronAPI)

View File

@@ -1,206 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CodeNomad</title>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background-color: #1a1a1a;
color: #cfd4dc;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
}
button {
border: none;
background: none;
font: inherit;
color: inherit;
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.35));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
max-width: 420px;
padding: 22px;
border-radius: 18px;
background: #151a23;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cfd4dc;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
.phrase-controls {
margin-top: 12px;
font-size: 0.9rem;
color: #8f96a9;
display: flex;
justify-content: center;
gap: 8px;
}
.phrase-controls button {
color: #8fb5ff;
cursor: pointer;
}
.logo {
width: 180px;
height: auto;
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.45));
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
color: #f4f6fb;
}
.loading-card {
margin-top: 12px;
width: 100%;
padding: 22px;
border-radius: 18px;
background: #0f1421;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(5, 6, 10, 0.6);
}
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
max-width: 520px;
}
.logo {
width: 180px;
height: auto;
}
.title {
font-size: 2.7rem;
font-weight: 600;
margin: 0;
}
.subtitle {
margin: 0;
font-size: 1.1rem;
color: #aeb3c4;
}
.loading-card {
margin-top: 12px;
width: 100%;
padding: 20px;
border-radius: 14px;
background: rgba(13, 16, 24, 0.8);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45);
}
.loading-row {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: 0.95rem;
color: #cad0dd;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: #6ce3ff;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="wrapper" role="status" aria-live="polite">
<img src="./icon.png" alt="CodeNomad" class="logo" />
<div>
<h1 class="title">CodeNomad</h1>
</div>
<div class="loading-card">
<div class="loading-row">
<div class="spinner" aria-hidden="true"></div>
<span id="loading-phrase">Warming up the AI neurons…</span>
</div>
<div class="phrase-controls">
<button id="phrase-toggle" type="button">Show another</button>
</div>
</div>
</div>
<script>
const phrases = [
"Warming up the AI neurons…",
"Convincing the AI to stop daydreaming…",
"Polishing the AIs code goggles…",
"Asking the AI to stop reorganizing your files…",
"Feeding the AI additional coffee…",
"Teaching the AI not to delete node_modules (again)…",
"Telling the AI to act natural before you arrive…",
"Asking the AI to please stop rewriting history…",
"Letting the AI stretch before its coding sprint…",
"Persuading the AI to give you keyboard control…"
]
const phraseEl = document.getElementById("loading-phrase")
const button = document.getElementById("phrase-toggle")
function pickPhrase() {
const next = phrases[Math.floor(Math.random() * phrases.length)]
phraseEl.textContent = next
}
pickPhrase()
button?.addEventListener("click", pickPhrase)
</script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.2.0",
"version": "0.5.1",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",
@@ -65,42 +65,55 @@
{
"from": "electron/resources",
"to": "",
"filter": ["!icon.icns", "!icon.ico", "!icon.png"]
"filter": [
"!icon.icns",
"!icon.ico"
]
},
{
"from": "../server/dist/opencode-config",
"to": "opencode-config"
}
],
"mac": {
"category": "public.app-category.developer-tools",
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64", "universal"]
},
{
"target": "zip",
"arch": ["x64", "arm64", "universal"]
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.icns"
},
"dmg": {
"contents": [
{ "x": 130, "y": 220 },
{ "x": 410, "y": 220, "type": "link", "path": "/Applications" }
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"target": [
{
"target": "nsis",
"arch": ["x64", "arm64"]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"icon": "electron/resources/icon.ico"
},
"nsis": {
@@ -112,23 +125,14 @@
"linux": {
"target": [
{
"target": "AppImage",
"arch": ["x64", "arm64"]
},
{
"target": "deb",
"arch": ["x64", "arm64"]
},
{
"target": "rpm",
"arch": ["x64", "arm64"]
},
{
"target": "tar.gz",
"arch": ["x64", "arm64"]
"target": "zip",
"arch": [
"x64",
"arm64"
]
}
],
"artifactName": "CodeNomadApp-${version}-${os}-${arch}.${ext}",
"artifactName": "CodeNomad-${version}-${os}-${arch}.${ext}",
"category": "Development",
"icon": "electron/resources/icon.png"
}

View File

@@ -2,7 +2,7 @@
import { spawn } from "child_process"
import { existsSync } from "fs"
import { join } from "path"
import path, { join } from "path"
import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
@@ -16,8 +16,8 @@ const workspaceNodeModulesPath = join(workspaceRoot, "node_modules")
const platforms = {
mac: {
args: ["--mac", "--x64", "--arm64", "--universal"],
description: "macOS (Intel, Apple Silicon, Universal)",
args: ["--mac", "--x64", "--arm64"],
description: "macOS (Intel & Apple Silicon)",
},
"mac-x64": {
args: ["--mac", "--x64"],
@@ -55,12 +55,22 @@ const platforms = {
function run(command, args, options = {}) {
return new Promise((resolve, reject) => {
const env = { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) }
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"
const binPaths = [
join(nodeModulesPath, ".bin"),
join(workspaceNodeModulesPath, ".bin"),
]
env[pathKey] = `${binPaths.join(path.delimiter)}${path.delimiter}${env[pathKey] ?? ""}`
const spawnOptions = {
cwd: appDir,
stdio: "inherit",
shell: process.platform === "win32",
...options,
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
env,
}
const child = spawn(command, args, spawnOptions)

View File

@@ -0,0 +1,32 @@
# opencode-config
## TLDR
Template config + plugins injected into every OpenCode instance that CodeNomad launches. It provides a CodeNomad bridge plugin for local event exchange between the CLI server and opencode.
## What it is
A packaged config directory that CodeNomad copies into `~/.config/codenomad/opencode-config` for production builds or uses directly in dev. OpenCode autoloads any `plugin/*.ts` or `plugin/*.js` from this directory.
## How it works
- CodeNomad sets `OPENCODE_CONFIG_DIR` when spawning each opencode instance (`packages/server/src/workspaces/manager.ts`).
- This template is synced from `packages/opencode-config` (`packages/server/src/opencode-config.ts`, `packages/server/scripts/copy-opencode-config.mjs`).
- OpenCode autoloads plugins from `plugin/` (`packages/opencode-config/plugin/codenomad.ts`).
- The `CodeNomadPlugin` reads `CODENOMAD_INSTANCE_ID` + `CODENOMAD_BASE_URL`, connects to `GET /workspaces/:id/plugin/events`, and posts to `POST /workspaces/:id/plugin/event` (`packages/opencode-config/plugin/lib/client.ts`).
- The server exposes the plugin routes and maps events into the UI SSE pipeline (`packages/server/src/server/routes/plugin.ts`, `packages/server/src/plugins/handlers.ts`).
## Expectations
- Local-only bridge (no auth/token yet).
- Plugin must fail startup if it cannot connect after 3 retries.
- Keep plugin entrypoints thin; put shared logic under `plugin/lib/` to avoid autoloaded helpers.
- Keep event shapes small and explicit; use `type` + `properties` only.
## Ideas
- Add feature modules under `plugin/lib/features/` (tool lifecycle, permission prompts, custom commands).
- Expand `/workspaces/:id/plugin/*` with dedicated endpoints as needed.
- Promote stable event shapes and version tags once the protocol settles.
## Pointers
- Plugin entry: `packages/opencode-config/plugin/codenomad.ts`
- Plugin client: `packages/opencode-config/plugin/lib/client.ts`
- Plugin server routes: `packages/server/src/server/routes/plugin.ts`
- Plugin event handling: `packages/server/src/plugins/handlers.ts`
- Workspace env injection: `packages/server/src/workspaces/manager.ts`

View File

@@ -0,0 +1,3 @@
{
"$schema": "https://opencode.ai/config.json"
}

View File

@@ -0,0 +1,8 @@
{
"name": "@codenomad/opencode-config",
"version": "0.5.0",
"private": true,
"dependencies": {
"@opencode-ai/plugin": "1.1.1"
}
}

View File

@@ -0,0 +1,32 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process"
export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
await client.startEvents((event) => {
if (event.type === "codenomad.ping") {
void client.postEvent({
type: "codenomad.pong",
properties: {
ts: Date.now(),
pingTs: (event.properties as any)?.ts,
},
}).catch(() => {})
}
})
return {
tool: {
...backgroundProcessTools,
},
async event(input: { event: any }) {
const opencodeEvent = input?.event
if (!opencodeEvent || typeof opencodeEvent !== "object") return
},
}
}

View File

@@ -0,0 +1,309 @@
import path from "path"
import { tool } from "@opencode-ai/plugin/tool"
type BackgroundProcess = {
id: string
title: string
command: string
status: "running" | "stopped" | "error"
startedAt: string
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
}
type CodeNomadConfig = {
instanceId: string
baseUrl: string
}
type BackgroundProcessOptions = {
baseDir: string
}
type ParsedCommand = {
head: string
args: string[]
}
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const base = config.baseUrl.replace(/\/+$/, "")
const url = `${base}/workspaces/${config.instanceId}/plugin/background-processes${path}`
const headers = normalizeHeaders(init?.headers)
if (init?.body !== undefined) {
headers["Content-Type"] = "application/json"
}
const response = await fetch(url, {
...init,
headers,
})
if (!response.ok) {
const message = await response.text()
throw new Error(message || `Request failed with ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
return {
run_background_process: tool({
description:
"Run a long-lived background process (dev servers, DBs, watchers) so it keeps running while you do other tasks. Use it for running processes that timeout otherwise or produce a lot of output.",
args: {
title: tool.schema.string().describe("Short label for the process (e.g. Dev server, DB server)"),
command: tool.schema.string().describe("Shell command to run in the workspace"),
},
async execute(args) {
assertCommandWithinBase(args.command, options.baseDir)
const process = await request<BackgroundProcess>("", {
method: "POST",
body: JSON.stringify({ title: args.title, command: args.command }),
})
return `Started background process ${process.id} (${process.title})\nStatus: ${process.status}\nCommand: ${process.command}`
},
}),
list_background_processes: tool({
description: "List background processes running for this workspace.",
args: {},
async execute() {
const response = await request<{ processes: BackgroundProcess[] }>("")
if (response.processes.length === 0) {
return "No background processes running."
}
return response.processes
.map((process) => {
const status = process.status === "running" ? "running" : process.status
const exit = process.exitCode !== undefined ? ` (exit ${process.exitCode})` : ""
const size =
typeof process.outputSizeBytes === "number" ? ` | ${Math.round(process.outputSizeBytes / 1024)}KB` : ""
return `- ${process.id} | ${process.title} | ${status}${exit}${size}\n ${process.command}`
})
.join("\n")
},
}),
read_background_process_output: tool({
description: "Read output from a background process. Use full, grep, head, or tail.",
args: {
id: tool.schema.string().describe("Background process ID"),
method: tool.schema
.enum(["full", "grep", "head", "tail"])
.default("full")
.describe("Method to read output"),
pattern: tool.schema.string().optional().describe("Pattern for grep method"),
lines: tool.schema.number().optional().describe("Number of lines for head/tail methods"),
},
async execute(args) {
if (args.method === "grep" && !args.pattern) {
return "Pattern is required for grep method."
}
const params = new URLSearchParams({ method: args.method })
if (args.pattern) {
params.set("pattern", args.pattern)
}
if (args.lines) {
params.set("lines", String(args.lines))
}
const response = await request<{ id: string; content: string; truncated: boolean; sizeBytes: number }>(
`/${args.id}/output?${params.toString()}`,
)
const header = response.truncated
? `Output (truncated, ${Math.round(response.sizeBytes / 1024)}KB):`
: `Output (${Math.round(response.sizeBytes / 1024)}KB):`
return `${header}\n\n${response.content}`
},
}),
stop_background_process: tool({
description: "Stop a background process (SIGTERM) but keep its output and entry.",
args: {
id: tool.schema.string().describe("Background process ID"),
},
async execute(args) {
const process = await request<BackgroundProcess>(`/${args.id}/stop`, { method: "POST" })
return `Stopped background process ${process.id} (${process.title}). Status: ${process.status}`
},
}),
terminate_background_process: tool({
description: "Terminate a background process and delete its output + entry.",
args: {
id: tool.schema.string().describe("Background process ID"),
},
async execute(args) {
await request<void>(`/${args.id}/terminate`, { method: "POST" })
return `Terminated background process ${args.id} and removed its output.`
},
}),
}
}
const FILE_COMMANDS = new Set(["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown"])
const EXPANSION_CHARS = /[~*$?\[\]`$]/
function assertCommandWithinBase(command: string, baseDir: string) {
const normalizedBase = path.resolve(baseDir)
const commands = splitCommands(command)
for (const item of commands) {
if (!FILE_COMMANDS.has(item.head)) {
continue
}
for (const arg of item.args) {
if (!arg) continue
if (arg.startsWith("-") || (item.head === "chmod" && arg.startsWith("+"))) continue
const literalArg = unquote(arg)
if (EXPANSION_CHARS.test(literalArg)) {
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
}
const resolved = path.isAbsolute(literalArg) ? path.normalize(literalArg) : path.resolve(normalizedBase, literalArg)
if (!isWithinBase(normalizedBase, resolved)) {
throw new Error(`Background process commands may only reference paths within ${normalizedBase}.`)
}
}
}
}
function splitCommands(command: string): ParsedCommand[] {
const tokens = tokenize(command)
const commands: ParsedCommand[] = []
let current: string[] = []
for (const token of tokens) {
if (isSeparator(token)) {
if (current.length > 0) {
commands.push({ head: current[0], args: current.slice(1) })
current = []
}
continue
}
current.push(token)
}
if (current.length > 0) {
commands.push({ head: current[0], args: current.slice(1) })
}
return commands
}
function tokenize(input: string): string[] {
const tokens: string[] = []
let current = ""
let quote: "'" | '"' | null = null
let escape = false
const flush = () => {
if (current.length > 0) {
tokens.push(current)
current = ""
}
}
for (let index = 0; index < input.length; index += 1) {
const char = input[index]
if (escape) {
current += char
escape = false
continue
}
if (char === "\\" && quote !== "'") {
escape = true
continue
}
if (quote) {
current += char
if (char === quote) {
quote = null
}
continue
}
if (char === "'" || char === '"') {
quote = char
current += char
continue
}
if (char === " " || char === "\n" || char === "\t") {
flush()
continue
}
if (char === "|" || char === "&" || char === ";") {
flush()
const next = input[index + 1]
if ((char === "|" || char === "&") && next === char) {
tokens.push(char + next)
index += 1
} else {
tokens.push(char)
}
continue
}
current += char
}
flush()
return tokens
}
function isSeparator(token: string) {
return token === "|" || token === "||" || token === "&&" || token === ";" || token === "&"
}
function unquote(value: string) {
if (value.length >= 2) {
const first = value[0]
const last = value[value.length - 1]
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
return value.slice(1, -1)
}
}
return value
}
function isWithinBase(baseDir: string, target: string) {
const relative = path.relative(baseDir, target)
if (!relative) return true
return !relative.startsWith("..") && !path.isAbsolute(relative)
}
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const output: Record<string, string> = {}
if (!headers) return output
if (headers instanceof Headers) {
headers.forEach((value, key) => {
output[key] = value
})
return output
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
output[key] = value
}
return output
}
return { ...headers }
}

View File

@@ -0,0 +1,165 @@
export type PluginEvent = {
type: string
properties?: Record<string, unknown>
}
export type CodeNomadConfig = {
instanceId: string
baseUrl: string
}
export function getCodeNomadConfig(): CodeNomadConfig {
return {
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
}
}
export function createCodeNomadClient(config: CodeNomadConfig) {
return {
postEvent: (event: PluginEvent) => postPluginEvent(config.baseUrl, config.instanceId, event),
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(config.baseUrl, config.instanceId, onEvent),
}
}
function requireEnv(key: string): string {
const value = process.env[key]
if (!value || !value.trim()) {
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
}
return value
}
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function postPluginEvent(baseUrl: string, instanceId: string, event: PluginEvent) {
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/event`
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(event),
})
if (!response.ok) {
throw new Error(`[CodeNomadPlugin] POST ${url} failed (${response.status})`)
}
}
async function startPluginEvents(baseUrl: string, instanceId: string, onEvent: (event: PluginEvent) => void) {
const url = `${baseUrl.replace(/\/+$/, "")}/workspaces/${instanceId}/plugin/events`
// Fail plugin startup if we cannot establish the initial connection.
const initialBody = await connectWithRetries(url, 3)
// After startup, keep reconnecting; throw after 3 consecutive failures.
void consumeWithReconnect(url, onEvent, initialBody)
}
async function connectWithRetries(url: string, maxAttempts: number) {
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const response = await fetch(url, { headers: { Accept: "text/event-stream" } })
if (!response.ok || !response.body) {
throw new Error(`[CodeNomadPlugin] SSE unavailable (${response.status})`)
}
return response.body
} catch (error) {
lastError = error
await delay(500 * attempt)
}
}
const reason = lastError instanceof Error ? lastError.message : String(lastError)
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad after ${maxAttempts} retries: ${reason}`)
}
async function consumeWithReconnect(
url: string,
onEvent: (event: PluginEvent) => void,
initialBody: ReadableStream<Uint8Array>,
) {
let consecutiveFailures = 0
let body: ReadableStream<Uint8Array> | null = initialBody
while (true) {
try {
if (!body) {
body = await connectWithRetries(url, 3)
}
await consumeSseBody(body, onEvent)
body = null
consecutiveFailures = 0
} catch (error) {
body = null
consecutiveFailures += 1
if (consecutiveFailures >= 3) {
const reason = error instanceof Error ? error.message : String(error)
throw new Error(`[CodeNomadPlugin] Plugin event stream failed after 3 retries: ${reason}`)
}
await delay(500 * consecutiveFailures)
}
}
}
async function consumeSseBody(body: ReadableStream<Uint8Array>, onEvent: (event: PluginEvent) => void) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done || !value) {
break
}
buffer += decoder.decode(value, { stream: true })
let separatorIndex = buffer.indexOf("\n\n")
while (separatorIndex >= 0) {
const chunk = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
separatorIndex = buffer.indexOf("\n\n")
const event = parseSseChunk(chunk)
if (event) {
onEvent(event)
}
}
}
throw new Error("SSE stream ended")
}
function parseSseChunk(chunk: string): PluginEvent | null {
const lines = chunk.split(/\r?\n/)
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith(":")) continue
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) return null
const payload = dataLines.join("\n").trim()
if (!payload) return null
try {
const parsed = JSON.parse(payload)
if (!parsed || typeof parsed !== "object" || typeof (parsed as any).type !== "string") {
return null
}
return parsed as PluginEvent
} catch {
return null
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.1.0",
"version": "0.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.1.0",
"version": "0.5.1",
"dependencies": {
"@fastify/cors": "^8.5.0",
"commander": "^12.1.0",

View File

@@ -1,21 +1,26 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.2.0",
"version": "0.5.1",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
"email": "codenomad@neuralnomads.ai"
},
"repository": {
"type": "git",
"url": "https://github.com/NeuralNomadsAI/CodeNomad.git"
},
"type": "module",
"main": "dist/index.js",
"bin": {
"codenomad": "dist/bin.js"
},
"scripts": {
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json",
"build": "npm run build:ui && npm run prepare-ui && tsc -p tsconfig.json && npm run prepare-config",
"build:ui": "npm run build --prefix ../ui",
"prepare-ui": "node ./scripts/copy-ui-dist.mjs",
"dev": "cross-env CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"prepare-config": "node ./scripts/copy-opencode-config.mjs",
"dev": "cross-env CODENOMAD_DEV=1 CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {

View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
import { spawnSync } from "child_process"
import { cpSync, existsSync, mkdirSync, rmSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const cliRoot = path.resolve(__dirname, "..")
const sourceDir = path.resolve(cliRoot, "../opencode-config")
const targetDir = path.resolve(cliRoot, "dist/opencode-config")
const nodeModulesDir = path.resolve(sourceDir, "node_modules")
const selfLinkDir = path.resolve(nodeModulesDir, "@codenomad", "opencode-config")
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
if (!existsSync(sourceDir)) {
console.error(`[copy-opencode-config] Missing source directory at ${sourceDir}`)
process.exit(1)
}
if (!existsSync(nodeModulesDir)) {
console.log(`[copy-opencode-config] Installing opencode-config dependencies in ${sourceDir}`)
const npmArgs = [
"install",
"--prefix",
sourceDir,
"--omit=dev",
"--ignore-scripts",
"--fund=false",
"--audit=false",
"--package-lock=false",
"--workspaces=false",
]
const env = { ...process.env, npm_config_workspaces: "false" }
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
const result = npmCli
? spawnSync(npmCli[0], npmCli[1], { cwd: sourceDir, stdio: "inherit", env })
: spawnSync("npm", npmArgs, { cwd: sourceDir, stdio: "inherit", env, shell: process.platform === "win32" })
if (result.status !== 0) {
if (result.error) {
console.error("[copy-opencode-config] npm install failed to start", result.error)
}
console.error("[copy-opencode-config] Failed to install opencode-config dependencies")
process.exit(result.status ?? 1)
}
}
// npm can create a self-referential link for scoped packages on Windows.
// That link causes recursive copies (ELOOP) during bundling.
rmSync(selfLinkDir, { recursive: true, force: true })
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(path.dirname(targetDir), { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-opencode-config] Copied ${sourceDir} -> ${targetDir}`)

View File

@@ -111,6 +111,14 @@ export interface InstanceData {
agentModelSelections: AgentModelSelection
}
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
export interface InstanceStreamEvent {
type: string
properties?: Record<string, unknown>
[key: string]: unknown
}
export interface BinaryRecord {
id: string
path: string
@@ -157,6 +165,9 @@ export type WorkspaceEventType =
| "config.appChanged"
| "config.binariesChanged"
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
| "app.releaseAvailable"
export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -167,16 +178,72 @@ export type WorkspaceEventPayload =
| { type: "config.appChanged"; config: AppConfig }
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
| { type: "app.releaseAvailable"; release: LatestReleaseInfo }
export interface NetworkAddress {
ip: string
family: "ipv4" | "ipv6"
scope: "external" | "internal" | "loopback"
url: string
}
export interface LatestReleaseInfo {
version: string
tag: string
url: string
channel: "stable" | "dev"
publishedAt?: string
notes?: string
}
export interface ServerMeta {
/** Base URL clients should target for REST calls (useful for Electron embedding). */
httpBaseUrl: 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
/** Display label for the host (e.g., hostname or friendly name). */
hostLabel: string
/** Absolute path of the filesystem root exposed to clients. */
workspaceRoot: string
/** Reachable addresses for this server, external first. */
addresses: NetworkAddress[]
/** Optional metadata about the most recent public release. */
latestRelease?: LatestReleaseInfo
}
export type BackgroundProcessStatus = "running" | "stopped" | "error"
export interface BackgroundProcess {
id: string
workspaceId: string
title: string
command: string
cwd: string
status: BackgroundProcessStatus
pid?: number
startedAt: string
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
}
export interface BackgroundProcessListResponse {
processes: BackgroundProcess[]
}
export interface BackgroundProcessOutputResponse {
id: string
content: string
truncated: boolean
sizeBytes: number
}
export type {

View File

@@ -0,0 +1,438 @@
import { spawn, type ChildProcess } from "child_process"
import { createWriteStream, existsSync, promises as fs } from "fs"
import path from "path"
import { randomBytes } from "crypto"
import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import type { BackgroundProcess, BackgroundProcessStatus } from "../api-types"
const ROOT_DIR = ".codenomad/background_processes"
const INDEX_FILE = "index.json"
const OUTPUT_FILE = "output.txt"
const STOP_TIMEOUT_MS = 2000
const MAX_OUTPUT_BYTES = 20 * 1024
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
interface ManagerDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface RunningProcess {
child: ChildProcess
outputPath: string
exitPromise: Promise<void>
workspaceId: string
}
export class BackgroundProcessManager {
private readonly running = new Map<string, RunningProcess>()
constructor(private readonly deps: ManagerDeps) {
this.deps.eventBus.on("workspace.stopped", (event) => this.cleanupWorkspace(event.workspaceId))
this.deps.eventBus.on("workspace.error", (event) => this.cleanupWorkspace(event.workspace.id))
}
async list(workspaceId: string): Promise<BackgroundProcess[]> {
const records = await this.readIndex(workspaceId)
const enriched = await Promise.all(
records.map(async (record) => ({
...record,
outputSizeBytes: await this.getOutputSize(workspaceId, record.id),
})),
)
return enriched
}
async start(workspaceId: string, title: string, command: string): Promise<BackgroundProcess> {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
const id = this.generateId()
const processDir = await this.ensureProcessDir(workspaceId, id)
const outputPath = path.join(processDir, OUTPUT_FILE)
const outputStream = createWriteStream(outputPath, { flags: "a" })
const child = spawn("bash", ["-c", command], {
cwd: workspace.path,
stdio: ["ignore", "pipe", "pipe"],
})
const record: BackgroundProcess = {
id,
workspaceId,
title,
command,
cwd: workspace.path,
status: "running",
pid: child.pid,
startedAt: new Date().toISOString(),
outputSizeBytes: 0,
}
const exitPromise = new Promise<void>((resolve) => {
child.on("close", async (code) => {
await new Promise<void>((resolve) => outputStream.end(resolve))
this.running.delete(id)
record.status = this.statusFromExit(code)
record.exitCode = code === null ? undefined : code
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
resolve()
})
})
this.running.set(id, { child, outputPath, exitPromise, workspaceId })
let lastPublishAt = 0
const maybePublishSize = () => {
const now = Date.now()
if (now - lastPublishAt < OUTPUT_PUBLISH_INTERVAL_MS) {
return
}
lastPublishAt = now
this.publishUpdate(workspaceId, record)
}
child.stdout?.on("data", (data) => {
outputStream.write(data)
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
maybePublishSize()
})
child.stderr?.on("data", (data) => {
outputStream.write(data)
record.outputSizeBytes = (record.outputSizeBytes ?? 0) + data.length
maybePublishSize()
})
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
return record
}
async stop(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
const record = await this.findProcess(workspaceId, processId)
if (!record) {
return null
}
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.child.kill("SIGTERM")
await this.waitForExit(running)
}
if (record.status === "running") {
record.status = "stopped"
record.stoppedAt = new Date().toISOString()
await this.upsertIndex(workspaceId, record)
record.outputSizeBytes = await this.getOutputSize(workspaceId, record.id)
this.publishUpdate(workspaceId, record)
}
return record
}
async terminate(workspaceId: string, processId: string): Promise<void> {
const record = await this.findProcess(workspaceId, processId)
if (!record) return
const running = this.running.get(processId)
if (running?.child && !running.child.killed) {
running.child.kill("SIGTERM")
await this.waitForExit(running)
}
await this.removeFromIndex(workspaceId, processId)
await this.removeProcessDir(workspaceId, processId)
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.removed", properties: { processId } },
})
}
async readOutput(
workspaceId: string,
processId: string,
options: { method?: "full" | "tail" | "head" | "grep"; pattern?: string; lines?: number; maxBytes?: number },
) {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
return { id: processId, content: "", truncated: false, sizeBytes: 0 }
}
const stats = await fs.stat(outputPath)
const sizeBytes = stats.size
const method = options.method ?? "full"
const lineCount = options.lines ?? 10
const raw = await this.readOutputBytes(outputPath, sizeBytes, options.maxBytes)
let content = raw
switch (method) {
case "head":
content = this.headLines(raw, lineCount)
break
case "tail":
content = this.tailLines(raw, lineCount)
break
case "grep":
if (!options.pattern) {
throw new Error("Pattern is required for grep output")
}
content = this.grepLines(raw, options.pattern)
break
default:
content = raw
}
const effectiveMaxBytes = options.maxBytes
return {
id: processId,
content,
truncated: effectiveMaxBytes !== undefined && sizeBytes > effectiveMaxBytes,
sizeBytes,
}
}
async streamOutput(workspaceId: string, processId: string, reply: any) {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
reply.code(404).send({ error: "Output not found" })
return
}
reply.raw.setHeader("Content-Type", "text/event-stream")
reply.raw.setHeader("Cache-Control", "no-cache")
reply.raw.setHeader("Connection", "keep-alive")
reply.raw.flushHeaders?.()
reply.hijack()
const file = await fs.open(outputPath, "r")
let position = (await file.stat()).size
const tick = async () => {
const stats = await file.stat()
if (stats.size <= position) return
const length = stats.size - position
const buffer = Buffer.alloc(length)
await file.read(buffer, 0, length, position)
position = stats.size
const content = buffer.toString("utf-8")
reply.raw.write(`data: ${JSON.stringify({ type: "chunk", content })}\n\n`)
}
const interval = setInterval(() => {
tick().catch((error) => {
this.deps.logger.warn({ err: error }, "Failed to stream background process output")
})
}, 1000)
const close = () => {
clearInterval(interval)
file.close().catch(() => undefined)
reply.raw.end?.()
}
reply.raw.on("close", close)
reply.raw.on("error", close)
}
private async cleanupWorkspace(workspaceId: string) {
for (const [, running] of this.running.entries()) {
if (running.workspaceId !== workspaceId) continue
running.child.kill("SIGTERM")
await this.waitForExit(running)
}
await this.removeWorkspaceDir(workspaceId)
}
private async waitForExit(running: RunningProcess) {
let resolved = false
const timeout = setTimeout(() => {
if (!resolved) {
running.child.kill("SIGKILL")
}
}, STOP_TIMEOUT_MS)
await running.exitPromise.finally(() => {
resolved = true
clearTimeout(timeout)
})
}
private statusFromExit(code: number | null): BackgroundProcessStatus {
if (code === null) return "stopped"
if (code === 0) return "stopped"
return "error"
}
private async readOutputBytes(outputPath: string, sizeBytes: number, maxBytes?: number): Promise<string> {
if (maxBytes === undefined || sizeBytes <= maxBytes) {
return await fs.readFile(outputPath, "utf-8")
}
const start = Math.max(0, sizeBytes - maxBytes)
const file = await fs.open(outputPath, "r")
const buffer = Buffer.alloc(sizeBytes - start)
await file.read(buffer, 0, buffer.length, start)
await file.close()
return buffer.toString("utf-8")
}
private headLines(input: string, lines: number): string {
const parts = input.split(/\r?\n/)
return parts.slice(0, Math.max(0, lines)).join("\n")
}
private tailLines(input: string, lines: number): string {
const parts = input.split(/\r?\n/)
return parts.slice(Math.max(0, parts.length - lines)).join("\n")
}
private grepLines(input: string, pattern: string): string {
let matcher: RegExp
try {
matcher = new RegExp(pattern)
} catch {
throw new Error("Invalid grep pattern")
}
return input
.split(/\r?\n/)
.filter((line) => matcher.test(line))
.join("\n")
}
private async ensureProcessDir(workspaceId: string, processId: string) {
const root = await this.ensureWorkspaceDir(workspaceId)
const processDir = path.join(root, processId)
await fs.mkdir(processDir, { recursive: true })
return processDir
}
private async ensureWorkspaceDir(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
const root = path.join(workspace.path, ROOT_DIR, workspaceId)
await fs.mkdir(root, { recursive: true })
return root
}
private getOutputPath(workspaceId: string, processId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
return path.join(workspace.path, ROOT_DIR, workspaceId, processId, OUTPUT_FILE)
}
private async findProcess(workspaceId: string, processId: string): Promise<BackgroundProcess | null> {
const records = await this.readIndex(workspaceId)
return records.find((entry) => entry.id === processId) ?? null
}
private async readIndex(workspaceId: string): Promise<BackgroundProcess[]> {
const indexPath = await this.getIndexPath(workspaceId)
if (!existsSync(indexPath)) return []
try {
const raw = await fs.readFile(indexPath, "utf-8")
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as BackgroundProcess[]) : []
} catch {
return []
}
}
private async upsertIndex(workspaceId: string, record: BackgroundProcess) {
const records = await this.readIndex(workspaceId)
const index = records.findIndex((entry) => entry.id === record.id)
if (index >= 0) {
records[index] = record
} else {
records.push(record)
}
await this.writeIndex(workspaceId, records)
}
private async removeFromIndex(workspaceId: string, processId: string) {
const records = await this.readIndex(workspaceId)
const next = records.filter((entry) => entry.id !== processId)
await this.writeIndex(workspaceId, next)
}
private async writeIndex(workspaceId: string, records: BackgroundProcess[]) {
const indexPath = await this.getIndexPath(workspaceId)
await fs.mkdir(path.dirname(indexPath), { recursive: true })
await fs.writeFile(indexPath, JSON.stringify(records, null, 2))
}
private async getIndexPath(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
throw new Error("Workspace not found")
}
return path.join(workspace.path, ROOT_DIR, workspaceId, INDEX_FILE)
}
private async removeProcessDir(workspaceId: string, processId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
return
}
const processDir = path.join(workspace.path, ROOT_DIR, workspaceId, processId)
await fs.rm(processDir, { recursive: true, force: true })
}
private async removeWorkspaceDir(workspaceId: string) {
const workspace = this.deps.workspaceManager.get(workspaceId)
if (!workspace) {
return
}
const workspaceDir = path.join(workspace.path, ROOT_DIR, workspaceId)
await fs.rm(workspaceDir, { recursive: true, force: true })
}
private async getOutputSize(workspaceId: string, processId: string): Promise<number> {
const outputPath = this.getOutputPath(workspaceId, processId)
if (!existsSync(outputPath)) {
return 0
}
try {
const stats = await fs.stat(outputPath)
return stats.size
} catch {
return 0
}
}
private publishUpdate(workspaceId: string, record: BackgroundProcess) {
this.deps.eventBus.publish({
type: "instance.event",
instanceId: workspaceId,
event: { type: "background.process.updated", properties: { process: record } },
})
}
private generateId(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
const random = randomBytes(3).toString("hex")
return `proc_${timestamp}_${random}`
}
}

View File

@@ -10,12 +10,17 @@ const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchem
const PreferencesSchema = z.object({
showThinkingBlocks: z.boolean().default(false),
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showTimelineTools: z.boolean().default(true),
lastUsedBinary: z.string().optional(),
environmentVariables: z.record(z.string()).default({}),
modelRecents: z.array(ModelPreferenceSchema).default([]),
diffViewMode: z.enum(["split", "unified"]).default("split"),
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true),
listeningMode: z.enum(["local", "all"]).default("local"),
})
const RecentFolderSchema = z.object({

View File

@@ -52,9 +52,10 @@ export class ConfigStore {
this.cache = next
this.loaded = true
this.persist()
const published = Boolean(this.eventBus)
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.info("Config updated")
this.logger.debug({ config: this.cache }, "Config payload")
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
this.logger.trace({ config: this.cache }, "Config payload")
}
private persist() {

View File

@@ -8,7 +8,12 @@ export class EventBus extends EventEmitter {
}
publish(event: WorkspaceEventPayload): boolean {
this.logger?.debug({ event }, "Publishing workspace event")
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
this.logger?.debug({ type: event.type }, "Publishing workspace event")
if (this.logger?.isLevelEnabled("trace")) {
this.logger.trace({ event }, "Workspace event payload")
}
}
return super.emit(event.type, event)
}
@@ -22,6 +27,9 @@ export class EventBus extends EventEmitter {
this.on("config.appChanged", handler)
this.on("config.binariesChanged", handler)
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
this.on("app.releaseAvailable", handler)
return () => {
this.off("workspace.created", handler)
this.off("workspace.started", handler)
@@ -31,6 +39,9 @@ export class EventBus extends EventEmitter {
this.off("config.appChanged", handler)
this.off("config.binariesChanged", handler)
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)
this.off("app.releaseAvailable", handler)
}
}
}

View File

@@ -14,10 +14,13 @@ import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types"
import { InstanceStore } from "./storage/instance-store"
import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher"
import { startReleaseMonitor } from "./releases/release-monitor"
const require = createRequire(import.meta.url)
const packageJson = require("../package.json") as { version: string }
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -78,9 +81,11 @@ function parseCliOptions(argv: string[]): CliOptions {
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
const normalizedHost = resolveHost(parsed.host)
return {
port: parsed.port,
host: parsed.host,
host: normalizedHost,
rootDir: resolvedRoot,
configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
@@ -100,6 +105,13 @@ function parsePort(input: string): number {
return value
}
function resolveHost(input: string | undefined): string {
if (input && input.trim() === "0.0.0.0") {
return "0.0.0.0"
}
return DEFAULT_HOST
}
async function main() {
const options = parseCliOptions(process.argv.slice(2))
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
@@ -110,6 +122,18 @@ async function main() {
logger.info({ options }, "Starting CodeNomad CLI server")
const eventBus = new EventBus(eventLogger)
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
eventsUrl: `/api/events`,
host: options.host,
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
port: options.port,
hostLabel: options.host,
workspaceRoot: options.rootDir,
addresses: [],
}
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({
@@ -118,16 +142,28 @@ async function main() {
binaryRegistry,
eventBus,
logger: workspaceLogger,
getServerBaseUrl: () => serverMeta.httpBaseUrl,
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
logger: logger.child({ component: "instance-events" }),
})
const serverMeta: ServerMeta = {
httpBaseUrl: `http://${options.host}:${options.port}`,
eventsUrl: `/api/events`,
hostLabel: options.host,
workspaceRoot: options.rootDir,
}
const releaseMonitor = startReleaseMonitor({
currentVersion: packageJson.version,
logger: logger.child({ component: "release-monitor" }),
onUpdate: (release) => {
if (release) {
serverMeta.latestRelease = release
eventBus.publish({ type: "app.releaseAvailable", release })
} else {
delete serverMeta.latestRelease
}
},
})
const server = createHttpServer({
host: options.host,
@@ -169,12 +205,15 @@ async function main() {
}
try {
instanceEventBridge.shutdown()
await workspaceManager.shutdown()
logger.info("Workspace manager shutdown complete")
} catch (error) {
logger.error({ err: error }, "Workspace manager shutdown failed")
}
releaseMonitor.stop()
logger.info("Exiting process")
process.exit(0)
}

View File

@@ -0,0 +1,31 @@
import { existsSync } from "fs"
import path from "path"
import { fileURLToPath } from "url"
import { createLogger } from "./logger"
const log = createLogger({ component: "opencode-config" })
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const devTemplateDir = path.resolve(__dirname, "../../opencode-config")
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
const prodTemplateDirs = [
resourcesPath ? path.resolve(resourcesPath, "opencode-config") : undefined,
path.resolve(__dirname, "opencode-config"),
].filter((dir): dir is string => Boolean(dir))
const isDevBuild = Boolean(process.env.CODENOMAD_DEV ?? process.env.CLI_UI_DEV_SERVER) || existsSync(devTemplateDir)
const templateDir = isDevBuild
? devTemplateDir
: prodTemplateDirs.find((dir) => existsSync(dir)) ?? prodTemplateDirs[0]
export function getOpencodeConfigDir(): string {
if (!existsSync(templateDir)) {
throw new Error(`CodeNomad Opencode config template missing at ${templateDir}`)
}
if (isDevBuild) {
log.debug({ templateDir }, "Using Opencode config template directly (dev mode)")
}
return templateDir
}

View File

@@ -0,0 +1,55 @@
import type { FastifyReply } from "fastify"
import type { Logger } from "../logger"
export interface PluginOutboundEvent {
type: string
properties?: Record<string, unknown>
}
interface ClientConnection {
reply: FastifyReply
workspaceId: string
}
export class PluginChannelManager {
private readonly clients = new Set<ClientConnection>()
constructor(private readonly logger: Logger) {}
register(workspaceId: string, reply: FastifyReply) {
const connection: ClientConnection = { workspaceId, reply }
this.clients.add(connection)
this.logger.debug({ workspaceId }, "Plugin SSE client connected")
let closed = false
const close = () => {
if (closed) return
closed = true
this.clients.delete(connection)
this.logger.debug({ workspaceId }, "Plugin SSE client disconnected")
}
return { close }
}
send(workspaceId: string, event: PluginOutboundEvent) {
for (const client of this.clients) {
if (client.workspaceId !== workspaceId) continue
this.write(client.reply, event)
}
}
broadcast(event: PluginOutboundEvent) {
for (const client of this.clients) {
this.write(client.reply, event)
}
}
private write(reply: FastifyReply, event: PluginOutboundEvent) {
try {
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
} catch (error) {
this.logger.warn({ err: error }, "Failed to write plugin SSE event")
}
}
}

View File

@@ -0,0 +1,36 @@
import type { EventBus } from "../events/bus"
import type { WorkspaceManager } from "../workspaces/manager"
import type { Logger } from "../logger"
import type { PluginOutboundEvent } from "./channel"
export interface PluginInboundEvent {
type: string
properties?: Record<string, unknown>
}
interface HandlerDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
export function handlePluginEvent(workspaceId: string, event: PluginInboundEvent, deps: HandlerDeps) {
switch (event.type) {
case "codenomad.pong":
deps.logger.debug({ workspaceId, properties: event.properties }, "Plugin pong received")
return
default:
deps.logger.debug({ workspaceId, eventType: event.type }, "Unhandled plugin event")
}
}
export function buildPingEvent(): PluginOutboundEvent {
return {
type: "codenomad.ping",
properties: {
ts: Date.now(),
},
}
}

View File

@@ -0,0 +1,141 @@
import { fetch } from "undici"
import type { LatestReleaseInfo } from "../api-types"
import type { Logger } from "../logger"
const RELEASES_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad/releases/latest"
interface ReleaseMonitorOptions {
currentVersion: string
logger: Logger
onUpdate: (release: LatestReleaseInfo | null) => void
}
interface GithubReleaseResponse {
tag_name?: string
name?: string
html_url?: string
body?: string
published_at?: string
created_at?: string
prerelease?: boolean
}
interface NormalizedVersion {
major: number
minor: number
patch: number
prerelease: string | null
}
export interface ReleaseMonitor {
stop(): void
}
export function startReleaseMonitor(options: ReleaseMonitorOptions): ReleaseMonitor {
let stopped = false
const refreshRelease = async () => {
if (stopped) return
try {
const release = await fetchLatestRelease(options)
options.onUpdate(release)
} catch (error) {
options.logger.warn({ err: error }, "Failed to refresh release information")
}
}
void refreshRelease()
return {
stop() {
stopped = true
},
}
}
async function fetchLatestRelease(options: ReleaseMonitorOptions): Promise<LatestReleaseInfo | null> {
const response = await fetch(RELEASES_API_URL, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "CodeNomad-CLI",
},
})
if (!response.ok) {
throw new Error(`Release API responded with ${response.status}`)
}
const json = (await response.json()) as GithubReleaseResponse
const tagFromServer = json.tag_name || json.name
if (!tagFromServer) {
return null
}
const normalizedVersion = stripTagPrefix(tagFromServer)
if (!normalizedVersion) {
return null
}
const current = parseVersion(options.currentVersion)
const remote = parseVersion(normalizedVersion)
if (compareVersions(remote, current) <= 0) {
return null
}
return {
version: normalizedVersion,
tag: tagFromServer,
url: json.html_url ?? `https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/${encodeURIComponent(tagFromServer)}`,
channel: json.prerelease || normalizedVersion.includes("-") ? "dev" : "stable",
publishedAt: json.published_at ?? json.created_at,
notes: json.body,
}
}
function stripTagPrefix(tag: string | undefined): string | null {
if (!tag) return null
const trimmed = tag.trim()
if (!trimmed) return null
return trimmed.replace(/^v/i, "")
}
function parseVersion(value: string): NormalizedVersion {
const normalized = stripTagPrefix(value) ?? "0.0.0"
const [core, prerelease = null] = normalized.split("-", 2)
const [major = 0, minor = 0, patch = 0] = core.split(".").map((segment) => {
const parsed = Number.parseInt(segment, 10)
return Number.isFinite(parsed) ? parsed : 0
})
return {
major,
minor,
patch,
prerelease,
}
}
function compareVersions(a: NormalizedVersion, b: NormalizedVersion): number {
if (a.major !== b.major) {
return a.major > b.major ? 1 : -1
}
if (a.minor !== b.minor) {
return a.minor > b.minor ? 1 : -1
}
if (a.patch !== b.patch) {
return a.patch > b.patch ? 1 : -1
}
const aPre = a.prerelease && a.prerelease.length > 0 ? a.prerelease : null
const bPre = b.prerelease && b.prerelease.length > 0 ? b.prerelease : null
if (aPre === bPre) {
return 0
}
if (!aPre) {
return 1
}
if (!bPre) {
return -1
}
return aPre.localeCompare(bPre)
}

View File

@@ -18,8 +18,11 @@ import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events"
import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
interface HttpServerDeps {
host: string
@@ -42,9 +45,13 @@ interface HttpServerStartResult {
displayHost: string
}
const DEFAULT_HTTP_PORT = 9898
export function createHttpServer(deps: HttpServerDeps) {
const app = Fastify({ logger: false })
const proxyLogger = deps.logger.child({ component: "proxy" })
const apiLogger = deps.logger.child({ component: "http" })
const sseLogger = deps.logger.child({ component: "sse" })
const sseClients = new Set<() => void>()
const registerSseClient = (cleanup: () => void) => {
@@ -58,6 +65,29 @@ export function createHttpServer(deps: HttpServerDeps) {
sseClients.clear()
}
app.addHook("onRequest", (request, _reply, done) => {
;(request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta = {
start: process.hrtime.bigint(),
}
done()
})
app.addHook("onResponse", (request, reply, done) => {
const meta = (request as FastifyRequest & { __logMeta?: { start: bigint } }).__logMeta
const durationMs = meta ? Number((process.hrtime.bigint() - meta.start) / BigInt(1_000_000)) : undefined
const base = {
method: request.method,
url: request.url,
status: reply.statusCode,
durationMs,
}
apiLogger.debug(base, "HTTP request completed")
if (apiLogger.isLevelEnabled("trace")) {
apiLogger.trace({ ...base, params: request.params, query: request.query, body: request.body }, "HTTP request payload")
}
done()
})
app.register(cors, {
origin: true,
credentials: true,
@@ -65,18 +95,32 @@ export function createHttpServer(deps: HttpServerDeps) {
app.register(replyFrom, {
contentTypesToEncode: [],
undici: {
connections: 16,
pipelining: 1,
bodyTimeout: 0,
headersTimeout: 0,
},
})
const backgroundProcessManager = new BackgroundProcessManager({
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }),
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
registerStorageRoutes(app, {
instanceStore: deps.instanceStore,
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
@@ -89,16 +133,40 @@ export function createHttpServer(deps: HttpServerDeps) {
return {
instance: app,
start: async (): Promise<HttpServerStartResult> => {
const addressInfo = await app.listen({ port: deps.port, host: deps.host })
const attemptListen = async (requestedPort: number) => {
const addressInfo = await app.listen({ port: requestedPort, host: deps.host })
return { addressInfo, requestedPort }
}
let actualPort = deps.port
const autoPortRequested = deps.port === 0
const primaryPort = autoPortRequested ? DEFAULT_HTTP_PORT : deps.port
if (typeof addressInfo === "string") {
const shouldRetryWithEphemeral = (error: unknown) => {
if (!autoPortRequested) return false
const err = error as NodeJS.ErrnoException | undefined
return Boolean(err && err.code === "EADDRINUSE")
}
let listenResult
try {
listenResult = await attemptListen(primaryPort)
} catch (error) {
if (!shouldRetryWithEphemeral(error)) {
throw error
}
deps.logger.warn({ err: error, port: primaryPort }, "Preferred port unavailable, retrying on ephemeral port")
listenResult = await attemptListen(0)
}
let actualPort = listenResult.requestedPort
if (typeof listenResult.addressInfo === "string") {
try {
const parsed = new URL(addressInfo)
actualPort = Number(parsed.port) || deps.port
const parsed = new URL(listenResult.addressInfo)
actualPort = Number(parsed.port) || listenResult.requestedPort
} catch {
actualPort = deps.port
actualPort = listenResult.requestedPort
}
} else {
const address = app.server.address()
@@ -111,6 +179,9 @@ export function createHttpServer(deps: HttpServerDeps) {
const serverUrl = `http://${displayHost}:${actualPort}`
deps.serverMeta.httpBaseUrl = serverUrl
deps.serverMeta.host = deps.host
deps.serverMeta.port = actualPort
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
console.log(`CodeNomad Server is ready at ${serverUrl}`)
@@ -190,6 +261,11 @@ async function proxyWorkspaceRequest(args: {
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance")
if (logger.isLevelEnabled("trace")) {
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload")
}
return reply.from(targetUrl, {
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")

View File

@@ -0,0 +1,85 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { BackgroundProcessManager } from "../../background-processes/manager"
interface RouteDeps {
backgroundProcessManager: BackgroundProcessManager
}
const StartSchema = z.object({
title: z.string().trim().min(1),
command: z.string().trim().min(1),
})
const OutputQuerySchema = z.object({
method: z.enum(["full", "tail", "head", "grep"]).optional(),
mode: z.enum(["full", "tail", "head", "grep"]).optional(),
pattern: z.string().optional(),
lines: z.coerce.number().int().positive().max(2000).optional(),
maxBytes: z.coerce.number().int().positive().optional(),
})
export function registerBackgroundProcessRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request) => {
const processes = await deps.backgroundProcessManager.list(request.params.id)
return { processes }
})
app.post<{ Params: { id: string } }>("/workspaces/:id/plugin/background-processes", async (request, reply) => {
const payload = StartSchema.parse(request.body ?? {})
const process = await deps.backgroundProcessManager.start(request.params.id, payload.title, payload.command)
reply.code(201)
return process
})
app.post<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/stop",
async (request, reply) => {
const process = await deps.backgroundProcessManager.stop(request.params.id, request.params.processId)
if (!process) {
reply.code(404)
return { error: "Process not found" }
}
return process
},
)
app.post<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/terminate",
async (request, reply) => {
await deps.backgroundProcessManager.terminate(request.params.id, request.params.processId)
reply.code(204)
return undefined
},
)
app.get<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/output",
async (request, reply) => {
const query = OutputQuerySchema.parse(request.query ?? {})
const method = query.method ?? query.mode
if (method === "grep" && !query.pattern) {
reply.code(400)
return { error: "Pattern is required for grep output" }
}
try {
return await deps.backgroundProcessManager.readOutput(request.params.id, request.params.processId, {
method,
pattern: query.pattern,
lines: query.lines,
maxBytes: query.maxBytes,
})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid output request" }
}
},
)
app.get<{ Params: { id: string; processId: string } }>(
"/workspaces/:id/plugin/background-processes/:processId/stream",
async (request, reply) => {
await deps.backgroundProcessManager.streamOutput(request.params.id, request.params.processId, reply)
},
)
}

View File

@@ -1,14 +1,21 @@
import { FastifyInstance } from "fastify"
import { EventBus } from "../../events/bus"
import { WorkspaceEventPayload } from "../../api-types"
import { Logger } from "../../logger"
interface RouteDeps {
eventBus: EventBus
registerClient: (cleanup: () => void) => () => void
logger: Logger
}
let nextClientId = 0
export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/events", (request, reply) => {
const clientId = ++nextClientId
deps.logger.debug({ clientId }, "SSE client connected")
const origin = request.headers.origin ?? "*"
reply.raw.setHeader("Access-Control-Allow-Origin", origin)
reply.raw.setHeader("Access-Control-Allow-Credentials", "true")
@@ -19,6 +26,10 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.hijack()
const send = (event: WorkspaceEventPayload) => {
deps.logger.debug({ clientId, type: event.type }, "SSE event dispatched")
if (deps.logger.isLevelEnabled("trace")) {
deps.logger.trace({ clientId, event }, "SSE event payload")
}
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`)
}
@@ -34,6 +45,7 @@ export function registerEventRoutes(app: FastifyInstance, deps: RouteDeps) {
clearInterval(heartbeat)
unsubscribe()
reply.raw.end?.()
deps.logger.debug({ clientId }, "SSE client disconnected")
}
const unregister = deps.registerClient(close)

View File

@@ -1,10 +1,104 @@
import { FastifyInstance } from "fastify"
import { ServerMeta } from "../../api-types"
import os from "os"
import { NetworkAddress, ServerMeta } from "../../api-types"
interface RouteDeps {
serverMeta: ServerMeta
}
export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/meta", async () => deps.serverMeta)
app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta))
}
function buildMetaResponse(meta: ServerMeta): ServerMeta {
const port = resolvePort(meta)
const addresses = port > 0 ? resolveAddresses(port, meta.host) : []
return {
...meta,
port,
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
addresses,
}
}
function resolvePort(meta: ServerMeta): number {
if (Number.isInteger(meta.port) && meta.port > 0) {
return meta.port
}
try {
const parsed = new URL(meta.httpBaseUrl)
const port = Number(parsed.port)
return Number.isInteger(port) && port > 0 ? port : 0
} catch {
return 0
}
}
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
})
}

View File

@@ -0,0 +1,75 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { WorkspaceManager } from "../../workspaces/manager"
import type { EventBus } from "../../events/bus"
import type { Logger } from "../../logger"
import { PluginChannelManager } from "../../plugins/channel"
import { buildPingEvent, handlePluginEvent } from "../../plugins/handlers"
interface RouteDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
const PluginEventSchema = z.object({
type: z.string().min(1),
properties: z.record(z.unknown()).optional(),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
reply.raw.setHeader("Content-Type", "text/event-stream")
reply.raw.setHeader("Cache-Control", "no-cache")
reply.raw.setHeader("Connection", "keep-alive")
reply.raw.flushHeaders?.()
reply.hijack()
const registration = channel.register(request.params.id, reply)
const heartbeat = setInterval(() => {
channel.send(request.params.id, buildPingEvent())
}, 15000)
const close = () => {
clearInterval(heartbeat)
registration.close()
reply.raw.end?.()
}
request.raw.on("close", close)
request.raw.on("error", close)
})
const handleWildcard = async (request: any, reply: any) => {
const workspaceId = request.params.id as string
const workspace = deps.workspaceManager.get(workspaceId)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const suffix = (request.params["*"] as string | undefined) ?? ""
const normalized = suffix.replace(/^\/+/, "")
if (normalized === "event" && request.method === "POST") {
const parsed = PluginEventSchema.parse(request.body ?? {})
handlePluginEvent(workspaceId, parsed, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: deps.logger })
reply.code(204).send()
return
}
reply.code(404).send({ error: "Unknown plugin endpoint" })
}
app.all("/workspaces/:id/plugin/*", handleWildcard)
app.all("/workspaces/:id/plugin", handleWildcard)
}

View File

@@ -35,10 +35,16 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
})
app.post("/api/workspaces", async (request, reply) => {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
try {
const body = WorkspaceCreateSchema.parse(request.body ?? {})
const workspace = await deps.workspaceManager.create(body.path, body.name)
reply.code(201)
return workspace
} catch (error) {
request.log.error({ err: error }, "Failed to create workspace")
const message = error instanceof Error ? error.message : "Failed to create workspace"
reply.code(400).type("text/plain").send(message)
}
})
app.get<{ Params: { id: string } }>("/api/workspaces/:id", async (request, reply) => {

View File

@@ -0,0 +1,195 @@
import { Agent, fetch } from "undici"
import { Agent as UndiciAgent } from "undici"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import { WorkspaceManager } from "./manager"
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
const INSTANCE_HOST = "127.0.0.1"
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
const RECONNECT_DELAY_MS = 1000
interface InstanceEventBridgeOptions {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface ActiveStream {
controller: AbortController
task: Promise<void>
}
export class InstanceEventBridge {
private readonly streams = new Map<string, ActiveStream>()
constructor(private readonly options: InstanceEventBridgeOptions) {
const bus = this.options.eventBus
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
}
shutdown() {
for (const [id, active] of this.streams) {
active.controller.abort()
this.publishStatus(id, "disconnected")
}
this.streams.clear()
}
private startStream(workspaceId: string) {
if (this.streams.has(workspaceId)) {
return
}
const controller = new AbortController()
const task = this.runStream(workspaceId, controller.signal)
.catch((error) => {
if (!controller.signal.aborted) {
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
}
})
.finally(() => {
const active = this.streams.get(workspaceId)
if (active?.controller === controller) {
this.streams.delete(workspaceId)
}
})
this.streams.set(workspaceId, { controller, task })
}
private stopStream(workspaceId: string, reason?: string) {
const active = this.streams.get(workspaceId)
if (!active) {
return
}
active.controller.abort()
this.streams.delete(workspaceId)
this.publishStatus(workspaceId, "disconnected", reason)
}
private async runStream(workspaceId: string, signal: AbortSignal) {
while (!signal.aborted) {
const port = this.options.workspaceManager.getInstancePort(workspaceId)
if (!port) {
await this.delay(RECONNECT_DELAY_MS, signal)
continue
}
this.publishStatus(workspaceId, "connecting")
try {
await this.consumeStream(workspaceId, port, signal)
} catch (error) {
if (signal.aborted) {
break
}
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
await this.delay(RECONNECT_DELAY_MS, signal)
}
}
}
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event`
const response = await fetch(url, {
headers: { Accept: "text/event-stream" },
signal,
dispatcher: STREAM_AGENT,
})
if (!response.ok || !response.body) {
throw new Error(`Instance event stream unavailable (${response.status})`)
}
this.publishStatus(workspaceId, "connected")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (!signal.aborted) {
const { done, value } = await reader.read()
if (done || !value) {
break
}
buffer += decoder.decode(value, { stream: true })
buffer = this.flushEvents(buffer, workspaceId)
}
}
private flushEvents(buffer: string, workspaceId: string) {
let separatorIndex = buffer.indexOf("\n\n")
while (separatorIndex >= 0) {
const chunk = buffer.slice(0, separatorIndex)
buffer = buffer.slice(separatorIndex + 2)
this.processChunk(chunk, workspaceId)
separatorIndex = buffer.indexOf("\n\n")
}
return buffer
}
private processChunk(chunk: string, workspaceId: string) {
const lines = chunk.split(/\r?\n/)
const dataLines: string[] = []
for (const line of lines) {
if (line.startsWith(":")) {
continue
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart())
}
}
if (dataLines.length === 0) {
return
}
const payload = dataLines.join("\n").trim()
if (!payload) {
return
}
try {
const event = JSON.parse(payload) as InstanceStreamEvent
this.options.logger.debug({ workspaceId, eventType: event.type }, "Instance SSE event received")
if (this.options.logger.isLevelEnabled("trace")) {
this.options.logger.trace({ workspaceId, event }, "Instance SSE event payload")
}
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
} catch (error) {
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
}
}
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
this.options.logger.debug({ instanceId, status, reason }, "Instance SSE status updated")
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
}
private delay(duration: number, signal: AbortSignal) {
if (duration <= 0) {
return Promise.resolve()
}
return new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
signal.removeEventListener("abort", onAbort)
resolve()
}, duration)
const onAbort = () => {
clearTimeout(timeout)
resolve()
}
signal.addEventListener("abort", onAbort, { once: true })
})
}
}

View File

@@ -1,4 +1,6 @@
import path from "path"
import { spawnSync } from "child_process"
import { connect } from "net"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
@@ -6,8 +8,11 @@ import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types"
import { WorkspaceRuntime } from "./runtime"
import { WorkspaceRuntime, ProcessExitInfo } from "./runtime"
import { Logger } from "../logger"
import { getOpencodeConfigDir } from "../opencode-config.js"
const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions {
rootDir: string
@@ -15,6 +20,7 @@ interface WorkspaceManagerOptions {
binaryRegistry: BinaryRegistry
eventBus: EventBus
logger: Logger
getServerBaseUrl: () => string
}
interface WorkspaceRecord extends WorkspaceDescriptor {}
@@ -22,9 +28,11 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
}
list(): WorkspaceDescriptor[] {
@@ -65,10 +73,11 @@ export class WorkspaceManager {
const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath)
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
const proxyPath = `/workspaces/${id}/instance`
@@ -79,27 +88,42 @@ export class WorkspaceManager {
name,
status: "starting",
proxyPath,
binaryId: binary.id,
binaryId: resolvedBinaryPath,
binaryLabel: binary.label,
binaryVersion: binary.version,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor)
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
CODENOMAD_INSTANCE_ID: id,
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
}
try {
const { pid, port } = await this.runtime.launch({
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: binary.path,
binaryPath: resolvedBinaryPath,
environment,
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
descriptor.pid = pid
descriptor.port = port
descriptor.status = "ready"
@@ -161,6 +185,225 @@ export class WorkspaceManager {
return workspace
}
private resolveBinaryPath(identifier: string): string {
if (!identifier) {
return identifier
}
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
if (path.isAbsolute(identifier) || looksLikePath) {
return identifier
}
const locator = process.platform === "win32" ? "where" : "which"
try {
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const resolved = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
if (resolved) {
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
return resolved
}
} else if (result.error) {
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
}
} catch (error) {
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
}
return identifier
}
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
}
try {
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
if (line) {
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
}
return undefined
}
private async waitForWorkspaceReadiness(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
await Promise.race([
this.waitForPortAvailability(params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited before becoming ready",
info,
params.getLastOutput(),
)
}),
])
await this.waitForInstanceHealth(params)
await Promise.race([
this.delay(STARTUP_STABILITY_DELAY_MS),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited shortly after start",
info,
params.getLastOutput(),
)
}),
])
}
private async waitForInstanceHealth(params: {
workspaceId: string
port: number
exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string
}) {
const probeResult = await Promise.race([
this.probeInstance(params.workspaceId, params.port),
params.exitPromise.then((info) => {
throw this.buildStartupError(
params.workspaceId,
"exited during health checks",
info,
params.getLastOutput(),
)
}),
])
if (probeResult.ok) {
return
}
const latestOutput = params.getLastOutput().trim()
if (latestOutput) {
throw new Error(latestOutput)
}
const reason = probeResult.reason ?? "Health check failed"
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
}
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> {
const url = `http://127.0.0.1:${port}/project/current`
try {
const response = await fetch(url)
if (!response.ok) {
const reason = `health probe returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
return { ok: false, reason }
}
return { ok: true }
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")
return { ok: false, reason }
}
}
private buildStartupError(
workspaceId: string,
phase: string,
exitInfo: ProcessExitInfo,
lastOutput: string,
): Error {
const exitDetails = this.describeExit(exitInfo)
const trimmedOutput = lastOutput.trim()
const outputDetails = trimmedOutput ? ` Last output: ${trimmedOutput}` : ""
return new Error(`Workspace ${workspaceId} ${phase} (${exitDetails}).${outputDetails}`)
}
private waitForPortAvailability(port: number, timeoutMs = 5000): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + timeoutMs
let settled = false
let retryTimer: NodeJS.Timeout | null = null
const cleanup = () => {
settled = true
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
}
const tryConnect = () => {
if (settled) {
return
}
const socket = connect({ port, host: "127.0.0.1" }, () => {
cleanup()
socket.end()
resolve()
})
socket.once("error", () => {
socket.destroy()
if (settled) {
return
}
if (Date.now() >= deadline) {
cleanup()
reject(new Error(`Workspace port ${port} did not become ready within ${timeoutMs}ms`))
} else {
retryTimer = setTimeout(() => {
retryTimer = null
tryConnect()
}, 100)
}
})
}
tryConnect()
})
}
private delay(durationMs: number): Promise<void> {
if (durationMs <= 0) {
return Promise.resolve()
}
return new Promise((resolve) => setTimeout(resolve, durationMs))
}
private describeExit(info: ProcessExitInfo): string {
if (info.signal) {
return `signal ${info.signal}`
}
if (info.code !== null) {
return `code ${info.code}`
}
return "unknown reason"
}
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
const workspace = this.workspaces.get(workspaceId)
if (!workspace) return

View File

@@ -13,7 +13,7 @@ interface LaunchOptions {
onExit?: (info: ProcessExitInfo) => void
}
interface ProcessExitInfo {
export interface ProcessExitInfo {
workspaceId: string
code: number | null
signal: NodeJS.Signals | null
@@ -30,14 +30,47 @@ export class WorkspaceRuntime {
constructor(private readonly eventBus: EventBus, private readonly logger: Logger) {}
async launch(options: LaunchOptions): Promise<{ pid: number; port: number }> {
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; getLastOutput: () => string }> {
this.validateFolder(options.folder)
const args = ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
const env = { ...process.env, ...(options.environment ?? {}) }
let exitResolve: ((info: ProcessExitInfo) => void) | null = null
const exitPromise = new Promise<ProcessExitInfo>((resolveExit) => {
exitResolve = resolveExit
})
// Store recent output for debugging - keep last 50 lines from each stream
const MAX_OUTPUT_LINES = 50
const recentStdout: string[] = []
const recentStderr: string[] = []
const getLastOutput = () => {
const combined: string[] = []
if (recentStderr.length > 0) {
combined.push("Error Stream")
combined.push(...recentStderr.slice(-10))
}
if (recentStdout.length > 0) {
combined.push("Output Stream")
combined.push(...recentStdout.slice(-10))
}
return combined.join("\n")
}
return new Promise((resolve, reject) => {
this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process")
const commandLine = [options.binaryPath, ...args].join(" ")
this.logger.info(
{
workspaceId: options.workspaceId,
folder: options.folder,
binary: options.binaryPath,
args,
commandLine,
env,
},
"Launching OpenCode process",
)
const child = spawn(options.binaryPath, args, {
cwd: options.folder,
env,
@@ -80,11 +113,22 @@ export class WorkspaceRuntime {
cleanupStreams()
child.removeListener("error", handleError)
child.removeListener("exit", handleExit)
const exitInfo: ProcessExitInfo = {
workspaceId: options.workspaceId,
code,
signal,
requested: managed.requestedStop,
}
if (exitResolve) {
exitResolve(exitInfo)
exitResolve = null
}
if (!portFound) {
const reason = stderrBuffer || `Process exited with code ${code}`
const recentOutput = getLastOutput().trim()
const reason = recentOutput || stderrBuffer || `Process exited with code ${code}`
reject(new Error(reason))
} else {
options.onExit?.({ workspaceId: options.workspaceId, code, signal, requested: managed.requestedStop })
options.onExit?.(exitInfo)
}
}
@@ -93,6 +137,10 @@ export class WorkspaceRuntime {
child.removeListener("exit", handleExit)
this.processes.delete(options.workspaceId)
this.logger.error({ workspaceId: options.workspaceId, err: error }, "Workspace runtime error")
if (exitResolve) {
exitResolve({ workspaceId: options.workspaceId, code: null, signal: null, requested: managed.requestedStop })
exitResolve = null
}
reject(error)
}
@@ -106,18 +154,25 @@ export class WorkspaceRuntime {
stdoutBuffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.trim()) continue
const trimmed = line.trim()
if (!trimmed) continue
recentStdout.push(trimmed)
if (recentStdout.length > MAX_OUTPUT_LINES) {
recentStdout.shift()
}
this.emitLog(options.workspaceId, "info", line)
if (!portFound) {
const portMatch = line.match(/opencode server listening on http:\/\/.+:(\d+)/i)
if (portMatch) {
portFound = true
cleanupStreams()
stopWarningTimer()
child.removeListener("error", handleError)
const port = parseInt(portMatch[1], 10)
this.logger.info({ workspaceId: options.workspaceId, port }, "Workspace runtime allocated port")
resolve({ pid: child.pid!, port })
resolve({ pid: child.pid!, port, exitPromise, getLastOutput })
}
}
}
@@ -130,7 +185,14 @@ export class WorkspaceRuntime {
stderrBuffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.trim()) continue
const trimmed = line.trim()
if (!trimmed) continue
recentStderr.push(trimmed)
if (recentStderr.length > MAX_OUTPUT_LINES) {
recentStderr.shift()
}
this.emitLog(options.workspaceId, "error", line)
}
})

7
packages/tauri-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
src-tauri/target
src-tauri/Cargo.lock
src-tauri/resources/
target
node_modules
dist
.DS_Store

5589
packages/tauri-app/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
[workspace]
members = ["src-tauri"]
resolver = "2"

View File

@@ -0,0 +1,17 @@
{
"name": "@codenomad/tauri-app",
"version": "0.5.1",
"private": true,
"scripts": {
"dev": "tauri dev",
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild",
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
}

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
return
}
console.log("[dev-prep] UI loader build missing; running workspace build…")
execSync("npm --workspace @codenomad/ui run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(loadingHtml)) {
throw new Error("[dev-prep] failed to produce loading.html after UI build")
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
}
ensureUiBuild()
copyUiLoadingAssets()

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const { execSync } = require("child_process")
const root = path.resolve(__dirname, "..")
const workspaceRoot = path.resolve(root, "..", "..")
const serverRoot = path.resolve(root, "..", "server")
const uiRoot = path.resolve(root, "..", "ui")
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
const serverDest = path.resolve(root, "src-tauri", "resources", "server")
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
const sources = ["dist", "public", "node_modules", "package.json"]
const serverInstallCommand =
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
const serverDevInstallCommand =
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const uiDevInstallCommand =
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const envWithRootBin = {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
}
const braceExpansionPath = path.join(
serverRoot,
"node_modules",
"@fastify",
"static",
"node_modules",
"brace-expansion",
"package.json",
)
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
return
}
console.log("[prebuild] server build missing; running workspace build...")
execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot,
stdio: "inherit",
env: {
...process.env,
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
},
})
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
throw new Error("[prebuild] server artifacts still missing after build")
}
}
function ensureUiBuild() {
const loadingHtml = path.join(uiDist, "loading.html")
if (fs.existsSync(loadingHtml)) {
return
}
console.log("[prebuild] ui build missing; running workspace build...")
execSync("npm --workspace @codenomad/ui run build", {
cwd: workspaceRoot,
stdio: "inherit",
})
if (!fs.existsSync(loadingHtml)) {
throw new Error("[prebuild] ui loading assets missing after build")
}
}
function ensureServerDevDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server build dependencies (with dev)...")
execSync(serverDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureServerDependencies() {
if (fs.existsSync(braceExpansionPath)) {
return
}
console.log("[prebuild] ensuring server production dependencies...")
execSync(serverInstallCommand, {
cwd: serverRoot,
stdio: "inherit",
})
}
function ensureUiDevDependencies() {
if (fs.existsSync(viteBinPath)) {
return
}
console.log("[prebuild] ensuring ui build dependencies...")
execSync(uiDevInstallCommand, {
cwd: workspaceRoot,
stdio: "inherit",
env: envWithRootBin,
})
}
function ensureRollupPlatformBinary() {
const platformKey = `${process.platform}-${process.arch}`
const platformPackages = {
"linux-x64": "@rollup/rollup-linux-x64-gnu",
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
"darwin-arm64": "@rollup/rollup-darwin-arm64",
"darwin-x64": "@rollup/rollup-darwin-x64",
"win32-x64": "@rollup/rollup-win32-x64-msvc",
}
const pkgName = platformPackages[platformKey]
if (!pkgName) {
return
}
const platformPackagePath = path.join(workspaceRoot, "node_modules", "@rollup", pkgName.split("/").pop())
if (fs.existsSync(platformPackagePath)) {
return
}
let rollupVersion = ""
try {
rollupVersion = require(path.join(workspaceRoot, "node_modules", "rollup", "package.json")).version
} catch (error) {
// leave version empty; fallback install will use latest compatible
}
const packageSpec = rollupVersion ? `${pkgName}@${rollupVersion}` : pkgName
console.log("[prebuild] installing rollup platform binary (optional dep workaround)...")
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
cwd: workspaceRoot,
stdio: "inherit",
})
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of sources) {
const from = path.join(serverRoot, name)
const to = path.join(serverDest, name)
if (!fs.existsSync(from)) {
console.warn(`[prebuild] skipped missing ${from}`)
continue
}
fs.cpSync(from, to, { recursive: true, dereference: true })
console.log(`[prebuild] copied ${from} -> ${to}`)
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
if (!fs.existsSync(loadingSource)) {
throw new Error("[prebuild] cannot find built loading.html")
}
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
fs.mkdirSync(uiLoadingDest, { recursive: true })
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
if (fs.existsSync(assetsSource)) {
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
}
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
}
ensureServerDevDependencies()
ensureUiDevDependencies()
ensureRollupPlatformBinary()
ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
copyServerArtifacts()
copyUiLoadingAssets()

View File

@@ -0,0 +1,23 @@
[package]
name = "codenomad-tauri"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2.5.2", features = [] }
[dependencies]
tauri = { version = "2.5.2", features = [ "devtools"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
regex = "1"
once_cell = "1"
parking_lot = "0.12"
thiserror = "1"
anyhow = "1"
which = "4"
libc = "0.2"
tauri-plugin-dialog = "2"
dirs = "5"
tauri-plugin-opener = "2"
url = "2"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://schema.tauri.app/capabilities.json",
"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:*"]
},
"windows": ["main"],
"permissions": [
"core:default",
"core:menu:default",
"dialog:allow-open",
"opener:allow-default-urls",
"core:webview:allow-set-webview-zoom"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +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"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,712 @@
use dirs::home_dir;
use parking_lot::Mutex;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::VecDeque;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter, Manager, Url};
fn log_line(message: &str) {
println!("[tauri-cli] {message}");
}
fn workspace_root() -> Option<PathBuf> {
std::env::current_dir().ok().and_then(|mut dir| {
for _ in 0..3 {
if let Some(parent) = dir.parent() {
dir = parent.to_path_buf();
}
}
Some(dir)
})
}
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
log_line(&format!("navigating main to {url}"));
if let Ok(parsed) = Url::parse(url) {
let _ = win.navigate(parsed);
} else {
log_line("failed to parse URL for navigation");
}
} else {
log_line("main window not found for navigation");
}
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)]
struct PreferencesConfig {
#[serde(rename = "listeningMode")]
listening_mode: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AppConfig {
preferences: Option<PreferencesConfig>,
}
fn resolve_config_path() -> PathBuf {
let raw = env::var("CLI_CONFIG")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
expand_home(&raw)
}
fn expand_home(path: &str) -> PathBuf {
if path.starts_with("~/") {
if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) {
return home.join(path.trim_start_matches("~/"));
}
}
PathBuf::from(path)
}
fn resolve_listening_mode() -> String {
let path = resolve_config_path();
if let Ok(content) = fs::read_to_string(path) {
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
if let Some(mode) = config
.preferences
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
{
if mode == "local" {
return "local".to_string();
}
if mode == "all" {
return "all".to_string();
}
}
}
}
"local".to_string()
}
fn resolve_listening_host() -> String {
let mode = resolve_listening_mode();
if mode == "local" {
"127.0.0.1".to_string()
} else {
"0.0.0.0".to_string()
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CliState {
Starting,
Ready,
Error,
Stopped,
}
#[derive(Debug, Clone, Serialize)]
pub struct CliStatus {
pub state: CliState,
pub pid: Option<u32>,
pub port: Option<u16>,
pub url: Option<String>,
pub error: Option<String>,
}
impl Default for CliStatus {
fn default() -> Self {
Self {
state: CliState::Stopped,
pid: None,
port: None,
url: None,
error: None,
}
}
}
#[derive(Debug, Clone)]
pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
}
impl CliProcessManager {
pub fn new() -> Self {
Self {
status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)),
}
}
pub fn start(&self, app: AppHandle, dev: bool) -> anyhow::Result<()> {
log_line(&format!("start requested (dev={dev})"));
self.stop()?;
self.ready.store(false, Ordering::SeqCst);
{
let mut status = self.status.lock();
status.state = CliState::Starting;
status.port = None;
status.url = None;
status.error = None;
status.pid = None;
}
Self::emit_status(&app, &self.status.lock());
let status_arc = self.status.clone();
let child_arc = self.child.clone();
let ready_flag = self.ready.clone();
thread::spawn(move || {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock();
locked.state = CliState::Error;
locked.error = Some(err.to_string());
let snapshot = locked.clone();
drop(locked);
let _ = app.emit("cli:error", json!({"message": err.to_string()}));
let _ = app.emit("cli:status", snapshot);
}
});
Ok(())
}
pub fn stop(&self) -> anyhow::Result<()> {
let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() {
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
}
#[cfg(windows)]
{
let _ = child.kill();
}
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() > Duration::from_secs(4) {
#[cfg(unix)]
unsafe {
libc::kill(child.id() as i32, libc::SIGKILL);
}
#[cfg(windows)]
{
let _ = child.kill();
}
break;
}
thread::sleep(Duration::from_millis(50));
}
Err(_) => break,
}
}
}
let mut status = self.status.lock();
status.state = CliState::Stopped;
status.pid = None;
status.port = None;
status.url = None;
status.error = None;
Ok(())
}
pub fn status(&self) -> CliStatus {
self.status.lock().clone()
}
fn spawn_cli(
app: AppHandle,
status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
dev: bool,
) -> anyhow::Result<()> {
log_line("resolving CLI entry");
let resolution = CliEntry::resolve(&app, dev)?;
let host = resolve_listening_host();
log_line(&format!(
"resolved CLI entry runner={:?} entry={} host={}",
resolution.runner, resolution.entry, host
));
let args = resolution.build_args(dev, &host);
log_line(&format!("CLI args: {:?}", args));
if dev {
log_line("development mode: will prefer tsx + source if present");
}
let cwd = workspace_root();
if let Some(ref c) = cwd {
log_line(&format!("using cwd={}", c.display()));
}
let command_info = if supports_user_shell() {
log_line("spawning via user shell");
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
} else {
log_line("spawning directly with node");
ShellCommandType::Direct(DirectCommand {
program: resolution.node_binary.clone(),
args: resolution.runner_args(&args),
})
};
if !supports_user_shell() {
if which::which(&resolution.node_binary).is_err() {
return Err(anyhow::anyhow!("Node binary not found. Make sure Node.js is installed."));
}
}
let child = match &command_info {
ShellCommandType::UserShell(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
let mut c = Command::new(&cmd.shell);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
c.spawn()?
}
ShellCommandType::Direct(cmd) => {
log_line(&format!("spawn command: {} {:?}", cmd.program, cmd.args));
let mut c = Command::new(&cmd.program);
c.args(&cmd.args)
.env("ELECTRON_RUN_AS_NODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(ref cwd) = cwd {
c.current_dir(cwd);
}
c.spawn()?
}
};
let pid = child.id();
log_line(&format!("spawned pid={pid}"));
{
let mut locked = status.lock();
locked.pid = Some(pid);
}
Self::emit_status(&app, &status.lock());
{
let mut holder = child_holder.lock();
*holder = Some(child);
}
let child_clone = child_holder.clone();
let status_clone = status.clone();
let app_clone = app.clone();
let ready_clone = ready.clone();
thread::spawn(move || {
let stdout = child_clone
.lock()
.as_mut()
.and_then(|c| c.stdout.take())
.map(BufReader::new);
let stderr = child_clone
.lock()
.as_mut()
.and_then(|c| c.stderr.take())
.map(BufReader::new);
if let Some(reader) = stdout {
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
}
if let Some(reader) = stderr {
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
}
});
let app_clone = app.clone();
let status_clone = status.clone();
let ready_clone = ready.clone();
let child_holder_clone = child_holder.clone();
thread::spawn(move || {
let timeout = Duration::from_secs(60);
thread::sleep(timeout);
if ready_clone.load(Ordering::SeqCst) {
return;
}
let mut locked = status_clone.lock();
locked.state = CliState::Error;
locked.error = Some("CLI did not start in time".to_string());
log_line("timeout waiting for CLI readiness");
if let Some(child) = child_holder_clone.lock().as_mut() {
let _ = child.kill();
}
let _ = app_clone.emit("cli:error", json!({"message": "CLI did not start in time"}));
Self::emit_status(&app_clone, &locked);
});
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
}
};
let mut locked = status_clone.lock();
let failed = locked.state != CliState::Ready;
let err_msg = if failed {
Some(match code {
Some(status) => format!("CLI exited early: {status}"),
None => "CLI exited early".to_string(),
})
} else {
None
};
if failed {
locked.state = CliState::Error;
if locked.error.is_none() {
locked.error = err_msg.clone();
}
log_line(&format!("cli process exited before ready: {:?}", locked.error));
let _ = app_clone.emit("cli:error", json!({"message": locked.error.clone().unwrap_or_default()}));
} else {
locked.state = CliState::Stopped;
log_line("cli process stopped cleanly");
}
Self::emit_status(&app_clone, &locked);
});
Ok(())
}
fn process_stream<R: BufRead>(
mut reader: R,
stream: &str,
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
) {
let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
loop {
buffer.clear();
match reader.read_line(&mut buffer) {
Ok(0) => break,
Ok(_) => {
let line = buffer.trim_end();
if !line.is_empty() {
log_line(&format!("[cli][{}] {}", stream, line));
if ready.load(Ordering::SeqCst) {
continue;
}
if let Some(port) = port_regex
.as_ref()
.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, port);
continue;
}
if line.to_lowercase().contains("http server listening") {
if let Some(port) = http_regex
.as_ref()
.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, 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, port as u16);
continue;
}
}
}
}
}
Err(_) => break,
}
}
}
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
ready.store(true, Ordering::SeqCst);
let mut locked = status.lock();
let url = format!("http://127.0.0.1:{port}");
locked.port = Some(port);
locked.url = Some(url.clone());
locked.state = CliState::Ready;
locked.error = None;
log_line(&format!("cli ready on {url}"));
navigate_main(app, &url);
let _ = app.emit("cli:ready", locked.clone());
Self::emit_status(app, &locked);
}
fn emit_status(app: &AppHandle, status: &CliStatus) {
let _ = app.emit("cli:status", status.clone());
}
}
fn supports_user_shell() -> bool {
cfg!(unix)
}
#[derive(Debug)]
struct ShellCommand {
shell: String,
args: Vec<String>,
}
#[derive(Debug)]
struct DirectCommand {
program: String,
args: Vec<String>,
}
#[derive(Debug)]
enum ShellCommandType {
UserShell(ShellCommand),
Direct(DirectCommand),
}
#[derive(Debug)]
struct CliEntry {
entry: String,
runner: Runner,
runner_path: Option<String>,
node_binary: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Runner {
Node,
Tsx,
}
impl CliEntry {
fn resolve(app: &AppHandle, dev: bool) -> anyhow::Result<Self> {
let node_binary = std::env::var("NODE_BINARY").unwrap_or_else(|_| "node".to_string());
if dev {
if let Some(tsx_path) = resolve_tsx(app) {
if let Some(entry) = resolve_dev_entry(app) {
return Ok(Self {
entry,
runner: Runner::Tsx,
runner_path: Some(tsx_path),
node_binary,
});
}
}
}
if let Some(entry) = resolve_dist_entry(app) {
return Ok(Self {
entry,
runner: Runner::Node,
runner_path: None,
node_binary,
});
}
Err(anyhow::anyhow!(
"Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad."
))
}
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(),
];
if dev {
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());
}
args
}
fn runner_args(&self, cli_args: &[String]) -> Vec<String> {
let mut args = VecDeque::new();
if self.runner == Runner::Tsx {
if let Some(path) = &self.runner_path {
args.push_back(path.clone());
}
}
args.push_back(self.entry.clone());
for arg in cli_args {
args.push_back(arg.clone());
}
args.into_iter().collect()
}
}
fn resolve_tsx(_app: &AppHandle) -> Option<String> {
let candidates = vec![
std::env::current_dir()
.ok()
.map(|p| p.join("node_modules/tsx/dist/cli.js")),
std::env::current_exe()
.ok()
.and_then(|ex| ex.parent().map(|p| p.join("../node_modules/tsx/dist/cli.js"))),
];
first_existing(candidates)
}
fn resolve_dev_entry(_app: &AppHandle) -> Option<String> {
let candidates = vec![
std::env::current_dir()
.ok()
.map(|p| p.join("packages/server/src/index.ts")),
std::env::current_dir()
.ok()
.map(|p| p.join("../server/src/index.ts")),
];
first_existing(candidates)
}
fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
let base = workspace_root();
let mut candidates: Vec<Option<PathBuf>> = vec![
base.as_ref().map(|p| p.join("packages/server/dist/bin.js")),
base.as_ref().map(|p| p.join("packages/server/dist/index.js")),
base.as_ref().map(|p| p.join("server/dist/bin.js")),
base.as_ref().map(|p| p.join("server/dist/index.js")),
];
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let resources = dir.join("../Resources");
candidates.push(Some(resources.join("server/dist/bin.js")));
candidates.push(Some(resources.join("server/dist/index.js")));
candidates.push(Some(resources.join("server/dist/server/bin.js")));
candidates.push(Some(resources.join("server/dist/server/index.js")));
candidates.push(Some(resources.join("resources/server/dist/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/index.js")));
candidates.push(Some(resources.join("resources/server/dist/server/bin.js")));
candidates.push(Some(resources.join("resources/server/dist/server/index.js")));
let linux_resource_roots = [dir.join("../lib/CodeNomad"), dir.join("../lib/codenomad")];
for root in linux_resource_roots {
candidates.push(Some(root.join("server/dist/bin.js")));
candidates.push(Some(root.join("server/dist/index.js")));
candidates.push(Some(root.join("server/dist/server/bin.js")));
candidates.push(Some(root.join("server/dist/server/index.js")));
candidates.push(Some(root.join("resources/server/dist/bin.js")));
candidates.push(Some(root.join("resources/server/dist/index.js")));
candidates.push(Some(root.join("resources/server/dist/server/bin.js")));
candidates.push(Some(root.join("resources/server/dist/server/index.js")));
}
}
}
first_existing(candidates)
}
fn build_shell_command_string(entry: &CliEntry, cli_args: &[String]) -> anyhow::Result<ShellCommand> {
let shell = default_shell();
let mut quoted: Vec<String> = Vec::new();
quoted.push(shell_escape(&entry.node_binary));
for arg in entry.runner_args(cli_args) {
quoted.push(shell_escape(&arg));
}
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
let args = build_shell_args(&shell, &command);
log_line(&format!("user shell command: {} {:?}", shell, args));
Ok(ShellCommand { shell, args })
}
fn default_shell() -> String {
if let Ok(shell) = std::env::var("SHELL") {
if !shell.trim().is_empty() {
return shell;
}
}
if cfg!(target_os = "macos") {
"/bin/zsh".to_string()
} else {
"/bin/bash".to_string()
}
}
fn shell_escape(input: &str) -> String {
if input.is_empty() {
"''".to_string()
} else if !input
.chars()
.any(|c| matches!(c, ' ' | '"' | '\'' | '$' | '`' | '!' ))
{
input.to_string()
} else {
let escaped = input.replace('\'', "'\\''");
format!("'{}'", escaped)
}
}
fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
let shell_name = std::path::Path::new(shell)
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("")
.to_lowercase();
if shell_name.contains("zsh") {
vec!["-l".into(), "-i".into(), "-c".into(), command.into()]
} else {
vec!["-l".into(), "-c".into(), command.into()]
}
}
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
paths
.into_iter()
.flatten()
.find(|p| p.exists())
.map(|p| normalize_path(p))
}
fn normalize_path(path: PathBuf) -> String {
if let Ok(clean) = path.canonicalize() {
clean.to_string_lossy().to_string()
} else {
path.to_string_lossy().to_string()
}
}

View File

@@ -0,0 +1,267 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus};
use serde_json::json;
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
use tauri_plugin_opener::OpenerExt;
use url::Url;
#[derive(Clone)]
pub struct AppState {
pub manager: CliProcessManager,
}
#[tauri::command]
fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
state.manager.status()
}
#[tauri::command]
fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatus, String> {
let dev_mode = is_dev_mode();
state.manager.stop().map_err(|e| e.to_string())?;
state
.manager
.start(app, dev_mode)
.map_err(|e| e.to_string())?;
Ok(state.manager.status())
}
fn is_dev_mode() -> bool {
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
}
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")),
_ => false,
}
}
fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
if should_allow_internal(url) {
return true;
}
if let Err(err) = webview
.app_handle()
.opener()
.open_url(url.as_str(), None::<&str>)
{
eprintln!("[tauri] failed to open external link {}: {}", url, err);
}
false
}
fn main() {
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
.on_navigation(|webview, url| intercept_navigation(webview, url))
.build();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(navigation_guard)
.manage(AppState {
manager: CliProcessManager::new(),
})
.setup(|app| {
build_menu(&app.handle())?;
let dev_mode = is_dev_mode();
let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone();
std::thread::spawn(move || {
if let Err(err) = manager.start(app_handle.clone(), dev_mode) {
let _ = app_handle.emit("cli:error", json!({"message": err.to_string()}));
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
.on_menu_event(|app_handle, event| {
match event.id().0.as_str() {
// File menu
"new_instance" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.emit("menu:newInstance", ());
}
}
"close" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
"quit" => {
app_handle.exit(0);
}
// View menu
"reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload()");
}
}
"force_reload" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.eval("window.location.reload(true)");
}
}
"toggle_devtools" => {
if let Some(window) = app_handle.get_webview_window("main") {
window.open_devtools();
}
}
"toggle_fullscreen" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
}
}
// Window menu
"minimize" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.minimize();
}
}
"zoom" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.maximize();
}
}
// App menu (macOS)
"about" => {
// TODO: Implement about dialog
println!("About menu item clicked");
}
"hide" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.hide();
}
}
"hide_others" => {
// TODO: Hide other app windows
println!("Hide Others menu item clicked");
}
"show_all" => {
// TODO: Show all app windows
println!("Show All menu item clicked");
}
_ => {
println!("Unhandled menu event: {}", event.id().0);
}
}
})
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { .. } => {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
tauri::RunEvent::WindowEvent {
event: tauri::WindowEvent::Destroyed,
..
} => {
if app_handle.webview_windows().len() <= 1 {
let app = app_handle.clone();
std::thread::spawn(move || {
if let Some(state) = app.try_state::<AppState>() {
let _ = state.manager.stop();
}
app.exit(0);
});
}
}
_ => {}
});
}
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
let is_mac = cfg!(target_os = "macos");
// Create submenus
let mut submenus = Vec::new();
// App menu (macOS only)
if is_mac {
let app_menu = SubmenuBuilder::new(app, "CodeNomad")
.text("about", "About CodeNomad")
.separator()
.text("hide", "Hide CodeNomad")
.text("hide_others", "Hide Others")
.text("show_all", "Show All")
.separator()
.text("quit", "Quit CodeNomad")
.build()?;
submenus.push(app_menu);
}
// File menu - create New Instance with accelerator
let new_instance_item = MenuItem::with_id(
app,
"new_instance",
"New Instance",
true,
Some("CmdOrCtrl+N")
)?;
let file_menu = SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
.build()?;
submenus.push(file_menu);
// Edit menu with predefined items for standard functionality
let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.separator()
.select_all()
.build()?;
submenus.push(edit_menu);
// View menu
let view_menu = SubmenuBuilder::new(app, "View")
.text("reload", "Reload")
.text("force_reload", "Force Reload")
.text("toggle_devtools", "Toggle Developer Tools")
.separator()
.separator()
.text("toggle_fullscreen", "Toggle Full Screen")
.build()?;
submenus.push(view_menu);
// Window menu
let window_menu = SubmenuBuilder::new(app, "Window")
.text("minimize", "Minimize")
.text("zoom", "Zoom")
.build()?;
submenus.push(window_menu);
// Build the main menu with all submenus
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
app.set_menu(menu)?;
Ok(())
}

View File

@@ -0,0 +1,50 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CodeNomad",
"version": "0.1.0",
"identifier": "ai.opencode.client",
"build": {
"beforeDevCommand": "npm run dev:bootstrap",
"beforeBuildCommand": "npm run bundle:server",
"frontendDist": "resources/ui-loading"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "CodeNomad",
"url": "loading.html",
"width": 1400,
"height": 900,
"minWidth": 800,
"minHeight": 600,
"center": true,
"resizable": true,
"fullscreen": false,
"decorations": true,
"theme": "Dark",
"backgroundColor": "#1a1a1a",
"zoomHotkeysEnabled": true
}
],
"security": {
"assetProtocol": {
"scope": ["**"]
},
"capabilities": ["main-window-native-dialogs"]
}
},
"bundle": {
"active": true,
"resources": [
"resources/server",
"resources/ui-loading"
],
"icon": ["icon.icns", "icon.ico", "icon.png"],
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
}
}

View File

@@ -26,8 +26,29 @@ This starts the Vite dev server at `http://localhost:3000`.
To build the production assets:
```bash
```
npm run build
```
The output will be generated in the `dist` directory, which is then consumed by the Server or Electron app.
## Debug Logging
The UI now routes all logging through a lightweight wrapper around [`debug`](https://github.com/debug-js/debug). The logger exposes four namespaces that can be toggled at runtime:
- `sse` Server-sent event transport and handlers
- `api` HTTP/API calls and workspace lifecycle
- `session` Session/model state, prompt handling, tool calls
- `actions` User-driven interactions in UI components
You can enable or disable namespaces from DevTools (in dev or production builds) via the global `window.codenomadLogger` helpers:
```js
window.codenomadLogger?.listLoggerNamespaces() // => [{ name: "sse", enabled: false }, ...]
window.codenomadLogger?.enableLogger("sse") // turn on SSE logs
window.codenomadLogger?.disableLogger("sse") // turn them off again
window.codenomadLogger?.enableAllLoggers() // optional helper
```
Enabled namespaces are persisted in `localStorage` under `opencode:logger:namespaces`, so your preference survives reloads.

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.2.0",
"version": "0.5.1",
"private": true,
"type": "module",
"scripts": {
@@ -12,11 +12,17 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.0.68",
"@opencode-ai/sdk": "1.1.1",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",
"@suid/system": "^0.14.0",
"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",
"qrcode": "^1.5.3",
"shiki": "^3.13.0",
"solid-js": "^1.8.0",
"solid-toast": "^0.5.0"

View File

@@ -1,19 +1,26 @@
import { Component, Show, createMemo, createEffect, createSignal } from "solid-js"
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast"
import AlertDialog from "./components/alert-dialog"
import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts"
import InstanceTabs from "./components/instance-tabs"
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
import InstanceShell from "./components/instance/instance-shell"
import InstanceShell from "./components/instance/instance-shell2"
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
import { initMarkdown } from "./lib/markdown"
import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger"
import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env"
import {
hasInstances,
isSelectingFolder,
setIsSelectingFolder,
setHasInstances,
showFolderSelection,
setShowFolderSelection,
} from "./stores/ui"
@@ -39,22 +46,58 @@ import {
updateSessionModel,
} from "./stores/sessions"
const log = getLogger("actions")
const App: Component = () => {
const { isDark } = useTheme()
const {
preferences,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
} = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
interface LaunchErrorState {
message: string
binaryPath: string
missingBinary: boolean
}
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
}
createEffect(() => {
void initMarkdown(isDark()).catch(console.error)
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
createEffect(() => {
initReleaseNotifications()
})
createEffect(() => {
instances()
hasInstances()
requestAnimationFrame(() => updateInstanceTabBarHeight())
})
onMount(() => {
updateInstanceTabBarHeight()
const handleResize = () => updateInstanceTabBarHeight()
window.addEventListener("resize", handleResize)
onCleanup(() => window.removeEventListener("resize", handleResize))
})
const activeInstance = createMemo(() => getActiveInstance())
@@ -65,14 +108,30 @@ const App: Component = () => {
})
const launchErrorPath = () => {
const value = launchErrorBinary()
const value = launchError()?.binaryPath
if (!value) return "opencode"
return value.trim() || "opencode"
}
const isMissingBinaryError = (error: unknown): boolean => {
if (!error) return false
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
const launchErrorMessage = () => launchError()?.message ?? ""
const formatLaunchErrorMessage = (error: unknown): string => {
if (!error) {
return "Failed to launch workspace"
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.error === "string") {
return parsed.error
}
} catch {
// ignore JSON parse errors
}
return raw
}
const isMissingBinaryMessage = (message: string): boolean => {
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
@@ -83,7 +142,7 @@ const App: Component = () => {
)
}
const clearLaunchError = () => setLaunchErrorBinary(null)
const clearLaunchError = () => setLaunchError(null)
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) {
@@ -95,17 +154,22 @@ const App: Component = () => {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
const instanceId = await createInstance(folderPath, selectedBinary)
setHasInstances(true)
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
log.info("Created instance", {
instanceId,
port: instances().get(instanceId)?.port,
})
} catch (error) {
clearLaunchError()
if (isMissingBinaryError(error)) {
setLaunchErrorBinary(selectedBinary)
}
console.error("Failed to create instance:", error)
const message = formatLaunchErrorMessage(error)
const missingBinary = isMissingBinaryMessage(message)
setLaunchError({
message,
binaryPath: selectedBinary,
missingBinary,
})
log.error("Failed to create instance", error)
} finally {
setIsSelectingFolder(false)
}
@@ -130,17 +194,24 @@ const App: Component = () => {
try {
await acknowledgeDisconnectedInstance()
} catch (error) {
console.error("Failed to finalize disconnected instance:", error)
log.error("Failed to finalize disconnected instance", error)
}
}
async function handleCloseInstance(instanceId: string) {
if (confirm("Stop OpenCode instance? This will stop the server.")) {
await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
}
}
const confirmed = await showConfirmDialog(
"Stop OpenCode instance? This will stop the server.",
{
title: "Stop instance",
variant: "warning",
confirmLabel: "Stop",
cancelLabel: "Keep running",
},
)
if (!confirmed) return
await stopInstance(instanceId)
}
async function handleNewSession(instanceId: string) {
@@ -148,7 +219,7 @@ const App: Component = () => {
const session = await createSession(instanceId)
setActiveParentSession(instanceId, session.id)
} catch (error) {
console.error("Failed to create session:", error)
log.error("Failed to create session", error)
}
}
@@ -172,7 +243,7 @@ const App: Component = () => {
try {
await fetchSessions(instanceId)
} catch (error) {
console.error("Failed to refresh sessions after closing:", error)
log.error("Failed to refresh sessions after closing", error)
}
}
@@ -192,10 +263,14 @@ const App: Component = () => {
const { commands: paletteCommands, executeCommand } = useCommands({
preferences,
toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,
handleNewInstanceRequest,
handleCloseInstance,
handleNewSession,
@@ -216,6 +291,28 @@ const App: Component = () => {
getActiveSessionIdForInstance: activeSessionIdForInstance,
})
// Listen for Tauri menu events
onMount(() => {
if (runtimeEnv.host === "tauri") {
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
if (tauriBridge?.event) {
let unlistenMenu: (() => void) | null = null
tauriBridge.event.listen("menu:newInstance", () => {
handleNewInstanceRequest()
}).then((unlisten) => {
unlistenMenu = unlisten
}).catch((error) => {
log.error("Failed to listen for menu:newInstance event", error)
})
onCleanup(() => {
unlistenMenu?.()
})
}
}
})
return (
<>
<InstanceDisconnectedModal
@@ -225,16 +322,16 @@ const App: Component = () => {
onClose={handleDisconnectedInstanceClose}
/>
<Dialog open={Boolean(launchErrorBinary())} modal>
<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">Unable to launch OpenCode</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
Advanced Settings.
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
binary from Advanced Settings.
</Dialog.Description>
</div>
@@ -243,10 +340,23 @@ const App: Component = () => {
<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">
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Error output</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">
<button type="button" class="selector-button selector-button-secondary" onClick={handleLaunchErrorAdvanced}>
Open Advanced Settings
</button>
<Show when={launchError()?.missingBinary}>
<button
type="button"
class="selector-button selector-button-secondary"
onClick={handleLaunchErrorAdvanced}
>
Open Advanced Settings
</button>
</Show>
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
Close
</button>
@@ -266,22 +376,35 @@ const App: Component = () => {
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
<For each={Array.from(instances().values())}>
{(instance) => {
const isActiveInstance = () => activeInstanceId() === instance.id
const isVisible = () => isActiveInstance() && !showFolderSelection()
return (
<div class="flex-1 min-h-0 overflow-hidden" style={{ display: isVisible() ? "flex" : "none" }}>
<InstanceMetadataProvider instance={instance}>
<InstanceShell
instance={instance}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
tabBarOffset={instanceTabBarHeight()}
/>
</InstanceMetadataProvider>
</div>
)
}}
</For>
<Show when={activeInstance()} keyed>
{(instance) => (
<InstanceShell
instance={instance}
escapeInDebounce={escapeInDebounce()}
paletteCommands={paletteCommands}
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
onNewSession={() => handleNewSession(instance.id)}
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
/>
)}
</Show>
</>
}
>
@@ -291,6 +414,7 @@ const App: Component = () => {
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
@@ -320,6 +444,10 @@ const App: Component = () => {
</div>
</div>
</Show>
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<AlertDialog />
<Toaster
position="top-right"
@@ -334,4 +462,5 @@ const App: Component = () => {
)
}
export default App

View File

@@ -3,7 +3,9 @@ import { For, Show, createEffect, createMemo } from "solid-js"
import { agents, fetchAgents, sessions } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Agent } from "../types/session"
import Kbd from "./kbd"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
interface AgentSelectorProps {
instanceId: string
@@ -50,10 +52,11 @@ export default function AgentSelector(props: AgentSelectorProps) {
createEffect(() => {
if (instanceAgents().length === 0) {
fetchAgents(props.instanceId).catch(console.error)
fetchAgents(props.instanceId).catch((error) => log.error("Failed to fetch agents", error))
}
})
const handleChange = async (value: Agent | null) => {
if (value && value.name !== props.currentAgent) {
await props.onAgentChange(value.name)
@@ -111,14 +114,11 @@ export default function AgentSelector(props: AgentSelectorProps) {
</Select.Trigger>
<Select.Portal>
<Select.Content class="selector-popover max-h-80 overflow-auto p-1 z-50">
<Select.Content class="selector-popover max-h-80 overflow-auto p-1">
<Select.Listbox class="selector-listbox" />
</Select.Content>
</Select.Portal>
</Select>
<span class="hint sidebar-selector-hint">
<Kbd shortcut="cmd+shift+a" />
</span>
</div>
)
}

View File

@@ -0,0 +1,132 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string; badgeText: string; symbol: string; fallbackTitle: string }> = {
info: {
badgeBg: "var(--badge-neutral-bg)",
badgeBorder: "var(--border-base)",
badgeText: "var(--accent-primary)",
symbol: "i",
fallbackTitle: "Heads up",
},
warning: {
badgeBg: "rgba(255, 152, 0, 0.14)",
badgeBorder: "var(--status-warning)",
badgeText: "var(--status-warning)",
symbol: "!",
fallbackTitle: "Please review",
},
error: {
badgeBg: "var(--danger-soft-bg)",
badgeBorder: "var(--status-error)",
badgeText: "var(--status-error)",
symbol: "!",
fallbackTitle: "Something went wrong",
},
}
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
const current = payload ?? alertDialogState()
if (current?.type === "confirm") {
if (confirmed) {
current.onConfirm?.()
} else {
current.onCancel?.()
}
current.resolve?.(confirmed)
} else if (confirmed) {
current?.onConfirm?.()
}
dismissAlertDialog()
}
const AlertDialog: Component = () => {
let primaryButtonRef: HTMLButtonElement | undefined
createEffect(() => {
if (alertDialogState()) {
queueMicrotask(() => {
primaryButtonRef?.focus()
})
}
})
return (
<Show when={alertDialogState()} keyed>
{(payload) => {
const variant = payload.variant ?? "info"
const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const isConfirm = payload.type === "confirm"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
const cancelLabel = payload.cancelLabel || "Cancel"
return (
<Dialog
open
modal
onOpenChange={(open) => {
if (!open) {
dismiss(false, payload)
}
}}
>
<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-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{
"background-color": accent.badgeBg,
"border-color": accent.badgeBorder,
color: accent.badgeText,
}}
aria-hidden
>
{accent.symbol}
</div>
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
{payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
{isConfirm && (
<button
type="button"
class="button-secondary"
onClick={() => dismiss(false, payload)}
>
{cancelLabel}
</button>
)}
<button
type="button"
class="button-primary"
ref={(el) => {
primaryButtonRef = el
}}
onClick={() => dismiss(true, payload)}
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}}
</Show>
)
}
export default AlertDialog

View File

@@ -0,0 +1,167 @@
import { Dialog } from "@kobalte/core/dialog"
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
import type { BackgroundProcess } from "../../../server/src/api-types"
import { buildBackgroundProcessStreamUrl, serverApi } from "../lib/api-client"
import { createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
interface BackgroundProcessOutputDialogProps {
open: boolean
instanceId: string
process: BackgroundProcess | null
onClose: () => void
}
export function BackgroundProcessOutputDialog(props: BackgroundProcessOutputDialogProps) {
const [output, setOutput] = createSignal("")
const [outputHtml, setOutputHtml] = createSignal("")
const [ansiEnabled, setAnsiEnabled] = createSignal(false)
const [truncated, setTruncated] = createSignal(false)
const [loading, setLoading] = createSignal(false)
let ansiRenderer = createAnsiStreamRenderer()
createEffect(() => {
const process = props.process
if (!props.open || !process) {
return
}
let eventSource: EventSource | null = null
let active = true
let rawOutput = ""
const setRawOutput = (next: string) => {
rawOutput = next
setOutput(next)
}
const appendRawOutput = (chunk: string) => {
rawOutput += chunk
setOutput(rawOutput)
}
setAnsiEnabled(false)
setOutputHtml("")
setRawOutput("")
ansiRenderer.reset()
setLoading(true)
serverApi
.fetchBackgroundProcessOutput(props.instanceId, process.id, { method: "full", maxBytes: undefined })
.then((response) => {
if (!active) return
setRawOutput(response.content)
setTruncated(response.truncated)
const detectedAnsi = hasAnsi(response.content)
if (detectedAnsi) {
setAnsiEnabled(true)
ansiRenderer.reset()
setOutputHtml(ansiRenderer.render(response.content))
} else {
setAnsiEnabled(false)
setOutputHtml("")
ansiRenderer.reset()
}
})
.catch(() => {
if (!active) return
setRawOutput("Failed to load output.")
setAnsiEnabled(false)
setOutputHtml("")
})
.finally(() => {
if (!active) return
setLoading(false)
})
eventSource = new EventSource(buildBackgroundProcessStreamUrl(props.instanceId, process.id))
eventSource.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as { type?: string; content?: string }
if (payload?.type !== "chunk" || typeof payload.content !== "string") {
return
}
const chunk = payload.content
const wasAnsiEnabled = ansiEnabled()
if (!wasAnsiEnabled) {
appendRawOutput(chunk)
if (hasAnsi(chunk)) {
setAnsiEnabled(true)
ansiRenderer.reset()
setOutputHtml(ansiRenderer.render(rawOutput))
}
return
}
appendRawOutput(chunk)
const htmlChunk = ansiRenderer.render(chunk)
setOutputHtml((prev) => `${prev}${htmlChunk}`)
} catch {
// ignore parse errors
}
}
onCleanup(() => {
active = false
eventSource?.close()
})
})
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()} 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-5xl max-h-[90vh] flex flex-col overflow-hidden">
<div class="flex items-start justify-between px-6 py-4 border-b border-base gap-4">
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">Background Output</Dialog.Title>
<Show when={props.process}>
<span class="text-xs text-secondary block">
{props.process?.title} · {props.process?.id}
</span>
<span class="text-xs text-secondary mt-1 block truncate" title={props.process?.command}>
{props.process?.command}
</span>
</Show>
</div>
<button type="button" class="button-tertiary flex-shrink-0" onClick={props.onClose}>
Close
</button>
</div>
<div class="flex-1 overflow-auto p-6">
<Show when={loading()}>
<p class="text-xs text-secondary">Loading output...</p>
</Show>
<Show when={!loading()}>
<Show when={truncated()}>
<p class="text-xs text-secondary mb-2">Output truncated for display.</p>
</Show>
<Show
when={ansiEnabled()}
fallback={
<pre class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono">
{output()}
</pre>
}
>
<pre
class="text-xs whitespace-pre-wrap break-all text-primary bg-surface-secondary border border-base rounded-md p-4 font-mono"
innerHTML={outputHtml()}
/>
</Show>
</Show>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -2,6 +2,7 @@ import { createSignal, onMount, Show, createEffect } from "solid-js"
import type { Highlighter } from "shiki/bundle/full"
import { useTheme } from "../lib/theme"
import { getSharedHighlighter, escapeHtml } from "../lib/markdown"
import { copyToClipboard } from "../lib/clipboard"
const inlineLoadedLanguages = new Set<string>()
@@ -61,9 +62,11 @@ export function CodeBlockInline(props: CodeBlockInlineProps) {
}
const copyCode = async () => {
await navigator.clipboard.writeText(props.code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
const success = await copyToClipboard(props.code)
if (success) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (

View File

@@ -1,10 +1,18 @@
import { createMemo, Show, onMount, createEffect } from "solid-js"
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import { disableCache } from "@git-diff-view/core"
import type { DiffHighlighterLang } from "@git-diff-view/core"
import { ErrorBoundary } from "solid-js"
import { getLanguageFromPath } from "../lib/markdown"
import { normalizeDiffText } from "../lib/diff-utils"
import { setToolRenderCache } from "../lib/tool-render-cache"
import { setCacheEntry } from "../lib/global-cache"
import type { CacheEntryParams } from "../lib/global-cache"
import type { DiffViewMode } from "../stores/preferences"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
disableCache()
interface ToolCallDiffViewerProps {
diffText: string
@@ -13,7 +21,7 @@ interface ToolCallDiffViewerProps {
mode: DiffViewMode
onRendered?: () => void
cachedHtml?: string
cacheKey?: string
cacheEntryParams?: CacheEntryParams
}
type DiffData = {
@@ -22,16 +30,23 @@ type DiffData = {
hunks: string[]
}
type CaptureContext = {
theme: ToolCallDiffViewerProps["theme"]
mode: DiffViewMode
diffText: string
cacheEntryParams?: CacheEntryParams
}
export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
const diffData = createMemo<DiffData | null>(() => {
const normalized = normalizeDiffText(props.diffText)
if (!normalized) {
return null
}
const language = getLanguageFromPath(props.filePath) || "text"
const fileName = props.filePath || "diff"
return {
oldFile: {
fileName,
@@ -44,34 +59,48 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
hunks: [normalized],
}
})
let diffContainerRef: HTMLDivElement | undefined
const captureAndCacheHtml = () => {
if (diffContainerRef && props.cacheKey && !props.cachedHtml) {
// Extract the rendered HTML from DiffView container
const renderedHtml = diffContainerRef.innerHTML
if (renderedHtml) {
setToolRenderCache(props.cacheKey, {
let lastCapturedKey: string | undefined
const contextKey = createMemo(() => {
const data = diffData()
if (!data) return ""
return `${props.theme}|${props.mode}|${props.diffText}`
})
createEffect(() => {
const cachedHtml = props.cachedHtml
if (cachedHtml) {
// When we are given cached HTML, we rely on the caller's cache
// and simply notify once rendered.
props.onRendered?.()
return
}
const key = contextKey()
if (!key) return
if (!diffContainerRef) return
if (lastCapturedKey === key) return
requestAnimationFrame(() => {
if (!diffContainerRef) return
const markup = diffContainerRef.innerHTML
if (!markup) return
lastCapturedKey = key
if (props.cacheEntryParams) {
setCacheEntry(props.cacheEntryParams, {
text: props.diffText,
html: renderedHtml,
html: markup,
theme: props.theme,
mode: props.mode,
})
}
}
props.onRendered?.()
}
// Also capture HTML when diff data changes
createEffect(() => {
const data = diffData()
if (data && !props.cachedHtml) {
// Delay to allow DiffView to re-render with new data
setTimeout(captureAndCacheHtml, 100)
}
props.onRendered?.()
})
})
return (
<div class="tool-call-diff-viewer">
<Show
@@ -83,14 +112,19 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
>
{(data) => (
<DiffView
data={data()}
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme}
diffViewHighlight
diffViewWrap={false}
diffViewFontSize={13}
/>
<ErrorBoundary fallback={(error) => {
log.warn("Failed to render diff view", error)
return <pre class="tool-call-diff-fallback">{props.diffText}</pre>
}}>
<DiffView
data={data()}
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme}
diffViewHighlight
diffViewWrap={false}
diffViewFontSize={13}
/>
</ErrorBoundary>
)}
</Show>
</div>

View File

@@ -2,6 +2,9 @@ import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid"
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
const MAX_RESULTS = 200
@@ -172,7 +175,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
function handleNavigateTo(path: string) {
void fetchDirectory(path, true).catch((err) => {
console.error("Failed to open directory", err)
log.error("Failed to open directory", err)
setError(err instanceof Error ? err.message : "Unable to open directory")
})
}

View File

@@ -1,18 +1,21 @@
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
import Kbd from "./kbd"
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void
isLoading?: boolean
advancedSettingsOpen?: boolean
onAdvancedSettingsOpen?: () => void
onAdvancedSettingsClose?: () => void
onOpenRemoteAccess?: () => void
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
@@ -21,6 +24,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
const folders = () => recentFolders()
@@ -29,9 +33,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
// Update selected binary when preferences change
createEffect(() => {
const lastUsed = preferences().lastUsedBinary
if (lastUsed && lastUsed !== selectedBinary()) {
setSelectedBinary(lastUsed)
}
if (!lastUsed) return
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
})
@@ -78,7 +81,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
if (isBrowseShortcut) {
e.preventDefault()
handleBrowse()
void handleBrowse()
return
}
@@ -172,9 +175,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
props.onSelectFolder(path, selectedBinary())
}
function handleBrowse() {
async function handleBrowse() {
if (isLoading()) return
setFocusMode("new")
if (nativeDialogsAvailable) {
const fallbackPath = folders()[0]?.path
const selected = await openNativeFolderDialog({
title: "Select Workspace",
defaultPath: fallbackPath,
})
if (selected) {
handleFolderSelect(selected)
}
return
}
setIsFolderBrowserOpen(true)
}
@@ -210,16 +224,27 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
return (
<>
<div
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 relative"
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
style="background-color: var(--surface-secondary)"
>
<div
class="w-full max-w-3xl h-full px-8 pb-2 flex flex-col overflow-hidden"
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<Show when={props.onOpenRemoteAccess}>
<div class="absolute top-4 right-6">
<button
type="button"
class="selector-button selector-button-secondary inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()}
>
<MonitorUp class="w-4 h-4" />
</button>
</div>
</Show>
<div class="mb-6 text-center shrink-0">
<div class="mb-3 flex justify-center">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
</div>
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
@@ -229,6 +254,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={folders().length > 0}
fallback={
<div class="panel panel-empty-state flex-1">
@@ -306,14 +333,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Show>
<div class="panel shrink-0">
<div class="panel-header">
<div class="panel-header hidden sm:block">
<h2 class="panel-title">Browse for Folder</h2>
<p class="panel-subtitle">Select any folder on your computer</p>
</div>
<div class="panel-body">
<button
onClick={handleBrowse}
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
@@ -342,7 +369,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</div>
<div class="mt-1 panel panel-footer shrink-0">
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">

View File

@@ -19,7 +19,7 @@ export default function InstanceDisconnectedModal(props: InstanceDisconnectedMod
<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">Instance Disconnected</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
{folderLabel} can no longer be reached. Close the tab to continue working.
</Dialog.Description>
</div>

View File

@@ -1,129 +1,26 @@
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
import type { Instance, RawMcpStatus } from "../types/instance"
import { fetchLspStatus, updateInstance } from "../stores/instances"
import { Component, For, Show, createMemo } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
interface InstanceInfoProps {
instance: Instance
compact?: boolean
}
type ParsedMcpStatus = {
name: string
status: "running" | "stopped" | "error"
error?: string
}
function parseMcpStatus(status: RawMcpStatus): ParsedMcpStatus[] {
if (!status || typeof status !== "object") return []
const result: ParsedMcpStatus[] = []
for (const [name, value] of Object.entries(status)) {
if (!value || typeof value !== "object") continue
const rawStatus = (value as { status?: string }).status
if (!rawStatus) continue
let mappedStatus: ParsedMcpStatus["status"]
if (rawStatus === "connected") {
mappedStatus = "running"
} else if (rawStatus === "failed") {
mappedStatus = "error"
} else {
mappedStatus = "stopped"
}
result.push({
name,
status: mappedStatus,
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
})
}
return result
}
const pendingMetadataRequests = new Set<string>()
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
const metadataContext = useOptionalInstanceMetadataContext()
const isLoadingMetadata = metadataContext?.isLoading ?? (() => false)
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
const metadata = () => props.instance.metadata
const mcpServers = () => {
const status = metadata()?.mcpStatus
return status ? parseMcpStatus(status) : []
}
const lspServers = () => metadata()?.lspStatus ?? []
createEffect(() => {
const instance = props.instance
const instanceId = instance.id
const client = instance.client
const hasMetadata = Boolean(instance.metadata)
if (!client) {
setIsLoadingMetadata(false)
pendingMetadataRequests.delete(instanceId)
return
}
if (hasMetadata) {
setIsLoadingMetadata(false)
pendingMetadataRequests.delete(instanceId)
return
}
if (pendingMetadataRequests.has(instanceId)) {
setIsLoadingMetadata(true)
return
}
let cancelled = false
pendingMetadataRequests.add(instanceId)
setIsLoadingMetadata(true)
void (async () => {
try {
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
client.project.current(),
client.mcp.status(),
fetchLspStatus(instanceId),
])
if (cancelled) {
return
}
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
const nextMetadata = {
...(instance.metadata ?? {}),
...(project ? { project } : {}),
...(mcpStatus ? { mcpStatus } : {}),
...(lspStatus ? { lspStatus } : {}),
}
if (!nextMetadata.version) {
nextMetadata.version = "0.15.8"
}
updateInstance(instanceId, { metadata: nextMetadata })
} catch (error) {
if (!cancelled) {
console.error("Failed to load instance metadata:", error)
}
} finally {
pendingMetadataRequests.delete(instanceId)
if (!cancelled) {
setIsLoadingMetadata(false)
}
}
})()
onCleanup(() => {
cancelled = true
})
const currentInstance = () => instanceAccessor()
const metadata = () => metadataAccessor()
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
const environmentVariables = () => currentInstance().environmentVariables
const environmentEntries = createMemo(() => {
const env = environmentVariables()
return env ? Object.entries(env) : []
})
return (
@@ -135,7 +32,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Folder</div>
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
{props.instance.folder}
{currentInstance().folder}
</div>
</div>
@@ -173,35 +70,35 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
)}
</Show>
<Show when={metadata()?.version}>
<Show when={binaryVersion()}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
OpenCode Version
</div>
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
v{metadata()?.version}
v{binaryVersion()}
</div>
</div>
</Show>
<Show when={props.instance.binaryPath}>
<Show when={currentInstance().binaryPath}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
Binary Path
</div>
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
{props.instance.binaryPath}
{currentInstance().binaryPath}
</div>
</div>
</Show>
<Show when={props.instance.environmentVariables && Object.keys(props.instance.environmentVariables).length > 0}>
<Show when={environmentEntries().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
Environment Variables ({Object.keys(props.instance.environmentVariables!).length})
Environment Variables ({environmentEntries().length})
</div>
<div class="space-y-1">
<For each={Object.entries(props.instance.environmentVariables!)}>
<For each={environmentEntries()}>
{([key, value]) => (
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
@@ -217,79 +114,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</Show>
<Show when={!isLoadingMetadata() && lspServers().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
LSP Servers
</div>
<div class="space-y-1.5">
<For each={lspServers()}>
{(server) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
<span class="text-[11px] text-secondary truncate" title={server.root}>
{server.root}
</span>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
</div>
</div>
</div>
)}
</For>
</div>
</div>
</Show>
<Show when={!isLoadingMetadata() && mcpServers().length > 0}>
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
MCP Servers
</div>
<div class="space-y-1.5">
<For each={mcpServers()}>
{(server) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div
class={`status-dot ${
server.status === "running"
? "ready animate-pulse"
: server.status === "error"
? "error"
: "stopped"
}`}
/>
<span>
{
server.status === "running"
? "Connected"
: server.status === "error"
? "Error"
: "Disabled"
}
</span>
</div>
</div>
<Show when={server.error}>
{(error) => (
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
{error()}
</div>
)}
</Show>
</div>
)}
</For>
</div>
</div>
</Show>
<InstanceServiceStatus initialInstance={props.instance} class="space-y-3" />
<Show when={isLoadingMetadata()}>
<div class="text-xs text-muted py-1">
@@ -312,21 +137,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="space-y-1 text-xs">
<div class="flex justify-between items-center">
<span class="text-secondary">Port:</span>
<span class="text-primary font-mono">{props.instance.port}</span>
<span class="text-primary font-mono">{currentInstance().port}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-secondary">PID:</span>
<span class="text-primary font-mono">{props.instance.pid}</span>
<span class="text-primary font-mono">{currentInstance().pid}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-secondary">Status:</span>
<span
class={`status-badge ${props.instance.status}`}
>
<span class={`status-badge ${currentInstance().status}`}>
<div
class={`status-dot ${props.instance.status === "ready" ? "ready" : props.instance.status === "starting" ? "starting" : props.instance.status === "error" ? "error" : "stopped"} ${props.instance.status === "ready" || props.instance.status === "starting" ? "animate-pulse" : ""}`}
class={`status-dot ${currentInstance().status === "ready" ? "ready" : currentInstance().status === "starting" ? "starting" : currentInstance().status === "error" ? "error" : "stopped"} ${currentInstance().status === "ready" || currentInstance().status === "starting" ? "animate-pulse" : ""}`}
/>
{props.instance.status}
{currentInstance().status}
</span>
</div>
</div>

View File

@@ -0,0 +1,254 @@
import { For, Show, createMemo, createSignal, type Component } from "solid-js"
import Switch from "@suid/material/Switch"
import type { Instance, RawMcpStatus } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
type ServiceSection = "lsp" | "mcp" | "plugins"
interface InstanceServiceStatusProps {
sections?: ServiceSection[]
showSectionHeadings?: boolean
class?: string
initialInstance?: Instance
}
type ParsedMcpStatus = {
name: string
status: "running" | "stopped" | "error"
error?: string
}
function parseMcpStatus(status?: RawMcpStatus): ParsedMcpStatus[] {
if (!status || typeof status !== "object") return []
const result: ParsedMcpStatus[] = []
for (const [name, value] of Object.entries(status)) {
if (!value || typeof value !== "object") continue
const rawStatus = (value as { status?: string }).status
if (!rawStatus) continue
let mapped: ParsedMcpStatus["status"]
if (rawStatus === "connected") mapped = "running"
else if (rawStatus === "failed") mapped = "error"
else mapped = "stopped"
result.push({
name,
status: mapped,
error: typeof (value as { error?: unknown }).error === "string" ? (value as { error?: string }).error : undefined,
})
}
return result
}
const InstanceServiceStatus: Component<InstanceServiceStatusProps> = (props) => {
const metadataContext = useOptionalInstanceMetadataContext()
const instance = metadataContext?.instance ?? (() => {
if (props.initialInstance) {
return props.initialInstance
}
throw new Error("InstanceServiceStatus requires InstanceMetadataProvider or initialInstance prop")
})
const isLoading = metadataContext?.isLoading ?? (() => false)
const refreshMetadata = metadataContext?.refreshMetadata ?? (async () => Promise.resolve())
const sections = createMemo<ServiceSection[]>(() => props.sections ?? ["lsp", "mcp", "plugins"])
const includeLsp = createMemo(() => sections().includes("lsp"))
const includeMcp = createMemo(() => sections().includes("mcp"))
const includePlugins = createMemo(() => sections().includes("plugins"))
const showHeadings = () => props.showSectionHeadings !== false
const metadataAccessor = metadataContext?.metadata ?? (() => instance().metadata)
const metadata = createMemo(() => metadataAccessor())
const hasLspMetadata = () => metadata()?.lspStatus !== undefined
const hasMcpMetadata = () => metadata()?.mcpStatus !== undefined
const hasPluginsMetadata = () => metadata()?.plugins !== undefined
const lspServers = createMemo(() => metadata()?.lspStatus ?? [])
const mcpServers = createMemo(() => parseMcpStatus(metadata()?.mcpStatus ?? undefined))
const plugins = createMemo(() => metadata()?.plugins ?? [])
const isLspLoading = () => isLoading() || !hasLspMetadata()
const isMcpLoading = () => isLoading() || !hasMcpMetadata()
const isPluginsLoading = () => isLoading() || !hasPluginsMetadata()
const [pendingMcpActions, setPendingMcpActions] = createSignal<Record<string, "connect" | "disconnect">>({})
const setPendingMcpAction = (name: string, action?: "connect" | "disconnect") => {
setPendingMcpActions((prev) => {
const next = { ...prev }
if (action) next[name] = action
else delete next[name]
return next
})
}
const toggleMcpServer = async (serverName: string, shouldEnable: boolean) => {
const client = instance().client
if (!client?.mcp) return
const action: "connect" | "disconnect" = shouldEnable ? "connect" : "disconnect"
setPendingMcpAction(serverName, action)
try {
if (shouldEnable) {
await client.mcp.connect({ name: serverName })
} else {
await client.mcp.disconnect({ name: serverName })
}
await refreshMetadata()
} catch (error) {
log.error("Failed to toggle MCP server", { serverName, action, error })
} finally {
setPendingMcpAction(serverName)
}
}
const renderEmptyState = (message: string) => (
<p class="text-[11px] text-secondary italic" role="status">
{message}
</p>
)
const renderLspSection = () => (
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
LSP Servers
</div>
</Show>
<Show
when={!isLspLoading() && lspServers().length > 0}
fallback={renderEmptyState(isLspLoading() ? "Loading LSP servers..." : "No LSP servers detected.")}
>
<div class="space-y-1.5">
<For each={lspServers()}>
{(server) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col flex-1 min-w-0">
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
<span class="text-[11px] text-secondary truncate" title={server.root}>
{server.root}
</span>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
</div>
</div>
</div>
)}
</For>
</div>
</Show>
</section>
)
const renderMcpSection = () => (
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
MCP Servers
</div>
</Show>
<Show
when={!isMcpLoading() && mcpServers().length > 0}
fallback={renderEmptyState(isMcpLoading() ? "Loading MCP servers..." : "No MCP servers detected.")}
>
<div class="space-y-1.5">
<For each={mcpServers()}>
{(server) => {
const pendingAction = () => pendingMcpActions()[server.name]
const isPending = () => Boolean(pendingAction())
const isRunning = () => server.status === "running"
const switchDisabled = () => isPending() || !instance().client
const statusDotClass = () => {
if (isPending()) return "status-dot animate-pulse"
if (server.status === "running") return "status-dot ready animate-pulse"
if (server.status === "error") return "status-dot error"
return "status-dot stopped"
}
const statusDotStyle = () => (isPending() ? { background: "var(--status-warning)" } : undefined)
return (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="flex items-center justify-between gap-2">
<span class="text-xs text-primary font-medium truncate">{server.name}</span>
<div class="flex items-center gap-3 flex-shrink-0">
<div class="flex items-center gap-1.5 text-xs text-secondary">
<Show when={isPending()}>
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</Show>
<div class={statusDotClass()} style={statusDotStyle()} />
</div>
<div class="flex items-center gap-1.5">
<Switch
checked={isRunning()}
disabled={switchDisabled()}
color="success"
size="small"
inputProps={{ "aria-label": `Toggle ${server.name} MCP server` }}
onChange={(_, checked) => {
if (switchDisabled()) return
void toggleMcpServer(server.name, Boolean(checked))
}}
/>
</div>
</div>
</div>
<Show when={server.error}>
{(error) => (
<div class="text-[11px] mt-1 break-words" style={{ color: "var(--status-error)" }}>
{error()}
</div>
)}
</Show>
</div>
)
}}
</For>
</div>
</Show>
</section>
)
const renderPluginsSection = () => (
<section class="space-y-1.5">
<Show when={showHeadings()}>
<div class="text-xs font-medium text-muted uppercase tracking-wide">
Plugins
</div>
</Show>
<Show
when={!isPluginsLoading() && plugins().length > 0}
fallback={renderEmptyState(isPluginsLoading() ? "Loading plugins..." : "No plugins configured.")}
>
<div class="space-y-1.5">
<For each={plugins()}>
{(plugin) => (
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div class="text-xs text-primary font-medium break-words whitespace-normal">{plugin}</div>
</div>
)}
</For>
</div>
</Show>
</section>
)
return (
<div class={props.class}>
<Show when={includeLsp()}>{renderLspSection()}</Show>
<Show when={includeMcp()}>{renderMcpSection()}</Show>
<Show when={includePlugins()}>{renderPluginsSection()}</Show>
</div>
)
}
export default InstanceServiceStatus

View File

@@ -1,6 +1,7 @@
import { Component } from "solid-js"
import { Component, createMemo } from "solid-js"
import type { Instance } from "../types/instance"
import { FolderOpen, X } from "lucide-solid"
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
interface InstanceTabProps {
instance: Instance
@@ -26,6 +27,24 @@ function formatFolderName(path: string, instances: Instance[], currentInstance:
}
const InstanceTab: Component<InstanceTabProps> = (props) => {
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
const statusClassName = createMemo(() => {
const status = aggregatedStatus()
return status === "permission" ? "session-permission" : `session-${status}`
})
const statusTitle = createMemo(() => {
switch (aggregatedStatus()) {
case "permission":
return "Waiting on permission"
case "compacting":
return "Compacting"
case "working":
return "Working"
default:
return "Idle"
}
})
return (
<div class="group">
<button
@@ -40,7 +59,18 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
{props.instance.folder.split("/").pop() || props.instance.folder}
</span>
<span
class="tab-close ml-auto"
class={`status-indicator session-status ml-auto ${statusClassName()}`}
title={statusTitle()}
aria-label={`Instance status: ${statusTitle()}`}
>
{aggregatedStatus() === "permission" ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
)}
</span>
<span
class="tab-close"
onClick={(e) => {
e.stopPropagation()
props.onClose()

View File

@@ -2,7 +2,7 @@ import { Component, For, Show } from "solid-js"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus } from "lucide-solid"
import { Plus, MonitorUp } from "lucide-solid"
import { keyboardRegistry } from "../lib/keyboard-registry"
interface InstanceTabsProps {
@@ -11,43 +11,60 @@ interface InstanceTabsProps {
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
onNew: () => void
onOpenRemoteAccess?: () => void
}
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
return (
<div class="tab-bar tab-bar-instance">
<div class="tab-container" role="tablist">
<div class="flex items-center gap-1 overflow-x-auto">
<For each={Array.from(props.instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === props.activeInstanceId}
onSelect={() => props.onSelect(id)}
onClose={() => props.onClose(id)}
/>
)}
</For>
<button
class="new-tab-button"
onClick={props.onNew}
title="New instance (Cmd/Ctrl+N)"
aria-label="New instance"
>
<Plus class="w-4 h-4" />
</button>
</div>
<Show when={Array.from(props.instances.entries()).length > 1}>
<div class="flex-shrink-0 ml-4">
<KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
Boolean,
)}
/>
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs">
<For each={Array.from(props.instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === props.activeInstanceId}
onSelect={() => props.onSelect(id)}
onClose={() => props.onClose(id)}
/>
)}
</For>
<button
class="new-tab-button"
onClick={props.onNew}
title="New instance (Cmd/Ctrl+N)"
aria-label="New instance"
>
<Plus class="w-4 h-4" />
</button>
</div>
<div class="tab-strip-spacer" />
<Show when={Array.from(props.instances.entries()).length > 1}>
<div class="tab-shortcuts">
<KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(
Boolean,
)}
/>
</div>
</Show>
<Show when={Boolean(props.onOpenRemoteAccess)}>
<button
class="new-tab-button tab-remote-button"
onClick={() => props.onOpenRemoteAccess?.()}
title="Remote connect"
aria-label="Remote connect"
>
<MonitorUp class="w-4 h-4" />
</button>
</Show>
</div>
</Show>
</div>
</div>
</div>
)
}

View File

@@ -1,11 +1,17 @@
import { Component, createSignal, Show, For, createEffect, onMount, onCleanup, createMemo } from "solid-js"
import { Loader2, Pencil, Trash2 } from "lucide-solid"
import type { Instance } from "../types/instance"
import { getParentSessions, createSession, setActiveParentSession } from "../stores/sessions"
import { getParentSessions, createSession, setActiveParentSession, deleteSession, loading, renameSession } from "../stores/sessions"
import InstanceInfo from "./instance-info"
import KeyboardHint from "./keyboard-hint"
import Kbd from "./kbd"
import SessionRenameDialog from "./session-rename-dialog"
import { keyboardRegistry, type KeyboardShortcut } from "../lib/keyboard-registry"
import { isMac } from "../lib/keyboard-utils"
import { showToastNotification } from "../lib/notifications"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
interface InstanceWelcomeViewProps {
@@ -16,8 +22,19 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
const [isCreating, setIsCreating] = createSignal(false)
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"sessions" | "new-session" | null>("sessions")
const [showInstanceInfoOverlay, setShowInstanceInfoOverlay] = createSignal(false)
const [isDesktopLayout, setIsDesktopLayout] = createSignal(
typeof window !== "undefined" ? window.matchMedia("(min-width: 1024px)").matches : false,
)
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
const [isRenaming, setIsRenaming] = createSignal(false)
const parentSessions = () => getParentSessions(props.instance.id)
const isFetchingSessions = createMemo(() => Boolean(loading().fetchingSessions.get(props.instance.id)))
const isSessionDeleting = (sessionId: string) => {
const deleting = loading().deletingSession.get(props.instance.id)
return deleting ? deleting.has(sessionId) : false
}
const newSessionShortcut = createMemo<KeyboardShortcut>(() => {
const registered = keyboardRegistry.get("session-new")
if (registered) return registered
@@ -47,6 +64,12 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
}
})
const openInstanceInfoOverlay = () => {
if (isDesktopLayout()) return
setShowInstanceInfoOverlay(true)
}
const closeInstanceInfoOverlay = () => setShowInstanceInfoOverlay(false)
function scrollToIndex(index: number) {
const element = document.querySelector(`[data-session-index="${index}"]`)
if (element) {
@@ -55,75 +78,170 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
}
function handleKeyDown(e: KeyboardEvent) {
let activeElement: HTMLElement | null = null
if (typeof document !== "undefined") {
activeElement = document.activeElement as HTMLElement | null
}
const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']")
const isEditingField =
activeElement &&
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) ||
activeElement.isContentEditable ||
Boolean(insideModal))
if (isEditingField) {
if (insideModal && e.key === "Escape" && renameTarget()) {
e.preventDefault()
closeRenameDialog()
}
return
}
if (showInstanceInfoOverlay()) {
if (e.key === "Escape") {
e.preventDefault()
closeInstanceInfoOverlay()
}
return
}
const sessions = parentSessions()
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "n") {
e.preventDefault()
handleNewSession()
return
}
if (sessions.length === 0) return
const listFocused = focusMode() === "sessions"
if (e.key === "ArrowDown") {
if (!listFocused) {
setFocusMode("sessions")
setSelectedIndex(0)
}
e.preventDefault()
const newIndex = Math.min(selectedIndex() + 1, sessions.length - 1)
setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex)
} else if (e.key === "ArrowUp") {
return
}
if (e.key === "ArrowUp") {
if (!listFocused) {
setFocusMode("sessions")
setSelectedIndex(Math.max(parentSessions().length - 1, 0))
}
e.preventDefault()
const newIndex = Math.max(selectedIndex() - 1, 0)
setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex)
} else if (e.key === "PageDown") {
return
}
if (!listFocused) {
return
}
if (e.key === "PageDown") {
e.preventDefault()
const pageSize = 5
const newIndex = Math.min(selectedIndex() + pageSize, sessions.length - 1)
setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex)
} else if (e.key === "PageUp") {
e.preventDefault()
const pageSize = 5
const newIndex = Math.max(selectedIndex() - pageSize, 0)
setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex)
} else if (e.key === "Home") {
e.preventDefault()
setSelectedIndex(0)
setFocusMode("sessions")
scrollToIndex(0)
} else if (e.key === "End") {
e.preventDefault()
const newIndex = sessions.length - 1
setSelectedIndex(newIndex)
setFocusMode("sessions")
scrollToIndex(newIndex)
} else if (e.key === "Enter") {
e.preventDefault()
handleEnterKey()
void handleEnterKey()
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault()
void handleDeleteKey()
}
}
async function handleEnterKey() {
const sessions = parentSessions()
const index = selectedIndex()
if (index < sessions.length) {
await handleSessionSelect(sessions[index].id)
}
}
onMount(() => {
async function handleDeleteKey() {
const sessions = parentSessions()
const index = selectedIndex()
if (index >= sessions.length) {
return
}
await handleSessionDelete(sessions[index].id)
const updatedSessions = parentSessions()
if (updatedSessions.length === 0) {
setFocusMode("new-session")
setSelectedIndex(0)
return
}
const nextIndex = Math.min(index, updatedSessions.length - 1)
setSelectedIndex(nextIndex)
setFocusMode("sessions")
scrollToIndex(nextIndex)
}
onMount(() => {
window.addEventListener("keydown", handleKeyDown)
onCleanup(() => {
window.removeEventListener("keydown", handleKeyDown)
})
})
onMount(() => {
const mediaQuery = window.matchMedia("(min-width: 1024px)")
const handleMediaChange = (matches: boolean) => {
setIsDesktopLayout(matches)
if (matches) {
closeInstanceInfoOverlay()
}
}
const listener = (event: MediaQueryListEvent) => handleMediaChange(event.matches)
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", listener)
onCleanup(() => {
mediaQuery.removeEventListener("change", listener)
})
} else {
mediaQuery.addListener(listener)
onCleanup(() => {
mediaQuery.removeListener(listener)
})
}
handleMediaChange(mediaQuery.matches)
})
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
const minutes = Math.floor(seconds / 60)
@@ -144,15 +262,51 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
setActiveParentSession(props.instance.id, sessionId)
}
async function handleSessionDelete(sessionId: string) {
if (isSessionDeleting(sessionId)) return
try {
await deleteSession(props.instance.id, sessionId)
} catch (error) {
log.error("Failed to delete session:", error)
}
}
function openRenameDialogForSession(sessionId: string, title: string) {
const label = title && title.trim() ? title : sessionId
setRenameTarget({ id: sessionId, title: title ?? "", label })
}
function closeRenameDialog() {
setRenameTarget(null)
}
async function handleRenameSubmit(nextTitle: string) {
const target = renameTarget()
if (!target) return
setIsRenaming(true)
try {
await renameSession(props.instance.id, target.id, nextTitle)
setRenameTarget(null)
} catch (error) {
log.error("Failed to rename session:", error)
showToastNotification({ message: "Unable to rename session", variant: "error" })
} finally {
setIsRenaming(false)
}
}
async function handleNewSession() {
if (isCreating()) return
setIsCreating(true)
try {
const session = await createSession(props.instance.id)
setActiveParentSession(props.instance.id, session.id)
} catch (error) {
console.error("Failed to create session:", error)
log.error("Failed to create session:", error)
} finally {
setIsCreating(false)
}
@@ -160,78 +314,155 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
return (
<div class="flex-1 flex flex-col overflow-hidden bg-surface-secondary">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto">
<div class="flex-1 flex flex-col gap-4 min-h-0">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-auto min-w-0">
<div class="flex-1 flex flex-col gap-4 min-h-0 min-w-0">
<Show
when={parentSessions().length > 0}
fallback={
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
<div class="panel-empty-state-icon">
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<Show
when={isFetchingSessions()}
fallback={
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
<div class="panel-empty-state-icon">
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<p class="panel-empty-state-title">No Previous Sessions</p>
<p class="panel-empty-state-description">Create a new session below to get started</p>
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
<button type="button" class="button-tertiary mt-4 lg:hidden" onClick={openInstanceInfoOverlay}>
View Instance Info
</button>
</Show>
</div>
}
>
<div class="panel panel-empty-state flex-1 flex flex-col justify-center">
<div class="panel-empty-state-icon">
<Loader2 class="w-12 h-12 mx-auto animate-spin text-muted" />
</div>
<p class="panel-empty-state-title">Loading Sessions</p>
<p class="panel-empty-state-description">Fetching your previous sessions...</p>
</div>
<p class="panel-empty-state-title">No Previous Sessions</p>
<p class="panel-empty-state-description">Create a new session below to get started</p>
</div>
</Show>
}
>
<div class="panel flex flex-col flex-1 min-h-0">
<div class="panel-header">
<h2 class="panel-title">Resume Session</h2>
<p class="panel-subtitle">
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
</p>
<div class="flex flex-row flex-wrap items-center gap-2 justify-between">
<div>
<h2 class="panel-title">Resume Session</h2>
<p class="panel-subtitle">
{parentSessions().length} {parentSessions().length === 1 ? "session" : "sessions"} available
</p>
</div>
<Show when={!isDesktopLayout() && !showInstanceInfoOverlay()}>
<button
type="button"
class="button-tertiary lg:hidden flex-shrink-0"
onClick={openInstanceInfoOverlay}
>
View Instance Info
</button>
</Show>
</div>
</div>
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto">
<For each={parentSessions()}>
{(session, index) => (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": focusMode() === "sessions" && selectedIndex() === index(),
}}
>
<button
data-session-index={index()}
class="panel-list-item-content group w-full"
onClick={() => handleSessionSelect(session.id)}
onMouseEnter={() => {
setFocusMode("sessions")
setSelectedIndex(index())
{(session, index) => {
const isFocused = () => focusMode() === "sessions" && selectedIndex() === index()
return (
<div
class="panel-list-item"
classList={{
"panel-list-item-highlight": isFocused(),
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-primary truncate transition-colors"
classList={{
"text-accent":
focusMode() === "sessions" && selectedIndex() === index(),
<div class="flex items-center gap-2 w-full px-1">
<button
type="button"
data-session-index={index()}
class="panel-list-item-content group flex-1"
onClick={() => handleSessionSelect(session.id)}
onMouseEnter={() => {
setFocusMode("sessions")
setSelectedIndex(index())
}}
>
<div class="flex items-center justify-between gap-3 w-full">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
classList={{
"text-accent": isFocused(),
}}
>
{session.title || "Untitled Session"}
</span>
</div>
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
<span>{session.agent}</span>
<span></span>
<span>{formatRelativeTime(session.time.updated)}</span>
</div>
</div>
</div>
</button>
<Show when={isFocused()}>
<div class="flex items-center gap-2 flex-shrink-0">
<kbd class="kbd flex-shrink-0"></kbd>
<button
type="button"
class="p-1.5 rounded transition-colors text-muted hover:text-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
title="Rename session"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
openRenameDialogForSession(session.id, session.title || "")
}}
>
{session.title || "Untitled Session"}
</span>
<Pencil class="w-4 h-4" />
</button>
<button
type="button"
class="p-1.5 rounded transition-colors text-muted hover:text-red-500 dark:hover:text-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
title="Delete session"
disabled={isSessionDeleting(session.id)}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void handleSessionDelete(session.id)
}}
>
<Show
when={!isSessionDeleting(session.id)}
fallback={
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
}
>
<Trash2 class="w-4 h-4" />
</Show>
</button>
</div>
<div class="flex items-center gap-3 text-xs text-muted mt-0.5">
<span>{session.agent}</span>
<span></span>
<span>{formatRelativeTime(session.time.updated)}</span>
</div>
</div>
<Show when={focusMode() === "sessions" && selectedIndex() === index()}>
<kbd class="kbd flex-shrink-0"></kbd>
</Show>
</div>
</button>
</div>
)}
</div>
)
}}
</For>
</div>
</div>
@@ -274,14 +505,38 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div>
</div>
<div class="lg:w-80 flex-shrink-0">
<div class="sticky top-0">
<div class="hidden lg:block lg:w-80 flex-shrink-0">
<div class="sticky top-0 max-h-full overflow-y-auto pr-1">
<InstanceInfo instance={props.instance} />
</div>
</div>
</div>
<div class="panel-footer">
<Show when={!isDesktopLayout() && showInstanceInfoOverlay()}>
<div
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={closeInstanceInfoOverlay}
>
<div class="flex min-h-full items-start justify-center p-4 overflow-y-auto">
<div
class="w-full max-w-md space-y-3"
onClick={(event) => event.stopPropagation()}
>
<div class="flex justify-end">
<button type="button" class="button-tertiary" onClick={closeInstanceInfoOverlay}>
Close
</button>
</div>
<div class="max-h-[85vh] overflow-y-auto pr-1">
<InstanceInfo instance={props.instance} />
</div>
</div>
</div>
</div>
</Show>
<div class="panel-footer hidden sm:block">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>
@@ -302,12 +557,23 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<kbd class="kbd">Enter</kbd>
<span>Resume</span>
</div>
<KeyboardHint shortcuts={[newSessionShortcut()]} separator="" />
<div class="flex items-center gap-1.5">
<kbd class="kbd">Del</kbd>
<span>Delete</span>
</div>
</div>
</div>
<SessionRenameDialog
open={Boolean(renameTarget())}
currentTitle={renameTarget()?.title ?? ""}
sessionLabel={renameTarget()?.label}
isSubmitting={isRenaming()}
onRename={handleRenameSubmit}
onClose={closeRenameDialog}
/>
</div>
)
}
export default InstanceWelcomeView

View File

@@ -1,173 +0,0 @@
import { Show, createMemo, createSignal, type Component } from "solid-js"
import type { Accessor } from "solid-js"
import type { Instance } from "../../types/instance"
import type { Command } from "../../lib/commands"
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
import { buildCustomCommandEntries } from "../../lib/command-utils"
import { getCommands as getInstanceCommands } from "../../stores/commands"
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
import SessionList from "../session-list"
import KeyboardHint from "../keyboard-hint"
import InstanceWelcomeView from "../instance-welcome-view"
import InfoView from "../info-view"
import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector"
import CommandPalette from "../command-palette"
import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view"
interface InstanceShellProps {
instance: Instance
escapeInDebounce: boolean
paletteCommands: Accessor<Command[]>
onCloseSession: (sessionId: string) => Promise<void> | void
onNewSession: () => Promise<void> | void
handleSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
onExecuteCommand: (command: Command) => void
}
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
const InstanceShell: Component<InstanceShellProps> = (props) => {
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const activeSessions = createMemo(() => {
const parentId = activeParentSessionId().get(props.instance.id)
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
const sessionFamily = getSessionFamily(props.instance.id, parentId)
return new Map(sessionFamily.map((s) => [s.id, s]))
})
const activeSessionIdForInstance = createMemo(() => {
return activeSessionMap().get(props.instance.id) || null
})
const activeSessionForInstance = createMemo(() => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") return null
return activeSessions().get(sessionId) ?? null
})
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
const keyboardShortcuts = createMemo(() =>
[keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter(
(shortcut): shortcut is KeyboardShortcut => Boolean(shortcut),
),
)
const handleSessionSelect = (sessionId: string) => {
setActiveSession(props.instance.id, sessionId)
}
return (
<>
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
<div class="flex flex-1 min-h-0">
<div class="session-sidebar flex flex-col bg-surface-secondary" style={{ width: `${sessionSidebarWidth()}px` }}>
<SessionList
instanceId={props.instance.id}
sessions={activeSessions()}
activeSessionId={activeSessionIdForInstance()}
onSelect={handleSessionSelect}
onClose={(id) => {
const result = props.onCloseSession(id)
if (result instanceof Promise) {
void result.catch((error) => console.error("Failed to close session:", error))
}
}}
onNew={() => {
const result = props.onNewSession()
if (result instanceof Promise) {
void result.catch((error) => console.error("Failed to create session:", error))
}
}}
showHeader
showFooter={false}
headerContent={
<div class="session-sidebar-header">
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
<div class="session-sidebar-shortcuts">
{keyboardShortcuts().length ? (
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
) : null}
</div>
</div>
}
onWidthChange={setSessionSidebarWidth}
/>
<div class="session-sidebar-separator border-t border-base" />
<Show when={activeSessionForInstance()}>
{(activeSession) => (
<>
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} />
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
<AgentSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentAgent={activeSession().agent}
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
/>
<ModelSelector
instanceId={props.instance.id}
sessionId={activeSession().id}
currentModel={activeSession().model}
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
/>
</div>
</>
)}
</Show>
</div>
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
<Show
when={activeSessionIdForInstance() === "info"}
fallback={
<Show
when={activeSessionIdForInstance()}
keyed
fallback={
<div class="flex items-center justify-center h-full">
<div class="text-center text-gray-500 dark:text-gray-400">
<p class="mb-2">No session selected</p>
<p class="text-sm">Select a session to view messages</p>
</div>
</div>
}
>
{(sessionId) => (
<SessionView
sessionId={sessionId}
activeSessions={activeSessions()}
instanceId={props.instance.id}
instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce}
/>
)}
</Show>
}
>
<InfoView instanceId={props.instance.id} />
</Show>
</div>
</div>
</Show>
<CommandPalette
open={paletteOpen()}
onClose={() => hideCommandPalette(props.instance.id)}
commands={instancePaletteCommands()}
onExecute={props.onExecuteCommand}
/>
</>
)
}
export default InstanceShell

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,32 @@
import { createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, initMarkdown, decodeHtmlEntities } from "../lib/markdown"
import type { TextPart } from "../types/message"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { renderMarkdown, onLanguagesLoaded, decodeHtmlEntities } from "../lib/markdown"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
const log = getLogger("session")
function hashText(value: string): string {
let hash = 2166136261
for (let index = 0; index < value.length; index++) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0).toString(16)
}
function resolvePartVersion(part: TextPart, text: string): string {
if (typeof part.version === "number") {
return String(part.version)
}
return `text-${hashText(text)}`
}
interface MarkdownProps {
part: TextPart
instanceId?: string
sessionId?: string
isDark?: boolean
size?: "base" | "sm" | "tight"
disableHighlight?: boolean
@@ -19,17 +42,63 @@ export function Markdown(props: MarkdownProps) {
Promise.resolve().then(() => props.onRendered?.())
}
createEffect(async () => {
const resolved = createMemo(() => {
const part = props.part
const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText)
const dark = Boolean(props.isDark)
const themeKey = dark ? "dark" : "light"
const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
if (!partId) {
throw new Error("Markdown rendering requires a part id")
}
const version = resolvePartVersion(part, text)
return { part, text, themeKey, highlightEnabled, partId, version }
})
const cacheHandle = useGlobalCache({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
scope: "markdown",
cacheId: () => {
const { partId, themeKey, highlightEnabled } = resolved()
return `${partId}:${themeKey}:${highlightEnabled ? 1 : 0}`
},
version: () => resolved().version,
})
createEffect(async () => {
const { part, text, themeKey, highlightEnabled, version } = resolved()
latestRequestedText = text
await initMarkdown(dark)
const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false
return cache.theme === themeKey && cache.mode === version
}
const localCache = part.renderCache
if (localCache && cacheMatches(localCache)) {
setHtml(localCache.html)
notifyRendered()
return
}
const globalCache = cacheHandle.get<RenderCache>()
if (globalCache && cacheMatches(globalCache)) {
setHtml(globalCache.html)
part.renderCache = globalCache
notifyRendered()
return
}
const commitCacheEntry = (renderedHtml: string) => {
const cacheEntry: RenderCache = { text, html: renderedHtml, theme: themeKey, mode: version }
setHtml(renderedHtml)
part.renderCache = cacheEntry
cacheHandle.set(cacheEntry)
notifyRendered()
}
if (!highlightEnabled) {
part.renderCache = undefined
@@ -38,40 +107,26 @@ export function Markdown(props: MarkdownProps) {
const rendered = await renderMarkdown(text, { suppressHighlight: true })
if (latestRequestedText === text) {
setHtml(rendered)
notifyRendered()
commitCacheEntry(rendered)
}
} catch (error) {
console.error("Failed to render markdown:", error)
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
setHtml(text)
notifyRendered()
commitCacheEntry(text)
}
}
return
}
const cache = part.renderCache
if (cache && cache.text === text && cache.theme === themeKey) {
setHtml(cache.html)
notifyRendered()
return
}
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
setHtml(rendered)
part.renderCache = { text, html: rendered, theme: themeKey }
notifyRendered()
commitCacheEntry(rendered)
}
} catch (error) {
console.error("Failed to render markdown:", error)
log.error("Failed to render markdown:", error)
if (latestRequestedText === text) {
setHtml(text)
part.renderCache = { text, html: text, theme: themeKey }
notifyRendered()
commitCacheEntry(text)
}
}
})
@@ -86,13 +141,20 @@ export function Markdown(props: MarkdownProps) {
const code = copyButton.getAttribute("data-code")
if (code) {
const decodedCode = decodeURIComponent(code)
await navigator.clipboard.writeText(decodedCode)
const success = await copyToClipboard(decodedCode)
const copyText = copyButton.querySelector(".copy-text")
if (copyText) {
copyText.textContent = "Copied!"
setTimeout(() => {
copyText.textContent = "Copy"
}, 2000)
if (success) {
copyText.textContent = "Copied!"
setTimeout(() => {
copyText.textContent = "Copy"
}, 2000)
} else {
copyText.textContent = "Failed"
setTimeout(() => {
copyText.textContent = "Copy"
}, 2000)
}
}
}
}
@@ -100,15 +162,12 @@ export function Markdown(props: MarkdownProps) {
containerRef?.addEventListener("click", handleClick)
// Register listener for language loading completion
const cleanupLanguageListener = onLanguagesLoaded(async () => {
if (props.disableHighlight) {
return
}
const part = props.part
const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntities(rawText)
const { part, text, themeKey, version } = resolved()
if (latestRequestedText !== text) {
return
@@ -117,13 +176,14 @@ export function Markdown(props: MarkdownProps) {
try {
const rendered = await renderMarkdown(text)
if (latestRequestedText === text) {
const cacheEntry: RenderCache = { text, html: rendered, theme: themeKey, mode: version }
setHtml(rendered)
const themeKey = Boolean(props.isDark) ? "dark" : "light"
part.renderCache = { text, html: rendered, theme: themeKey }
part.renderCache = cacheEntry
cacheHandle.set(cacheEntry)
notifyRendered()
}
} catch (error) {
console.error("Failed to re-render markdown after language load:", error)
log.error("Failed to re-render markdown after language load:", error)
}
})

View File

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

View File

@@ -0,0 +1,772 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
import { FoldVertical } from "lucide-solid"
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
import type { MessageRecord } from "../stores/message-v2/types"
import { messageStoreBus } from "../stores/message-v2/bus"
import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances"
const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
return Boolean(state && state.status === "running")
}
function isToolStateCompleted(state: ToolState | undefined): state is ToolStateCompleted {
return Boolean(state && state.status === "completed")
}
function isToolStateError(state: ToolState | undefined): state is ToolStateError {
return Boolean(state && state.status === "error")
}
function extractTaskSessionId(state: ToolState | undefined): string {
if (!state) return ""
const metadata = (state as unknown as { metadata?: Record<string, unknown> }).metadata ?? {}
const directId = metadata?.sessionId ?? metadata?.sessionID
return typeof directId === "string" ? directId : ""
}
function reasoningHasRenderableContent(part: ClientPart): boolean {
if (!part || part.type !== "reasoning") {
return false
}
const checkSegment = (segment: unknown): boolean => {
if (typeof segment === "string") {
return segment.trim().length > 0
}
if (segment && typeof segment === "object") {
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
return true
}
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
return true
}
if (Array.isArray(candidate.content)) {
return candidate.content.some((entry) => checkSegment(entry))
}
}
return false
}
if (checkSegment((part as any).text)) {
return true
}
if (Array.isArray((part as any).content)) {
return (part as any).content.some((entry: unknown) => checkSegment(entry))
}
return false
}
interface TaskSessionLocation {
sessionId: string
instanceId: string
parentId: string | null
}
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
if (!sessionId) return null
const allSessions = sessions()
for (const [instanceId, sessionMap] of allSessions) {
const session = sessionMap?.get(sessionId)
if (session) {
return {
sessionId: session.id,
instanceId,
parentId: session.parentId ?? null,
}
}
}
return null
}
function navigateToTaskSession(location: TaskSessionLocation) {
setActiveInstanceId(location.instanceId)
const parentToActivate = location.parentId ?? location.sessionId
setActiveParentSession(location.instanceId, parentToActivate)
if (location.parentId) {
setActiveSession(location.instanceId, location.sessionId)
}
}
interface CachedBlockEntry {
signature: string
block: MessageDisplayBlock
contentKeys: string[]
toolKeys: string[]
}
interface SessionRenderCache {
messageItems: Map<string, ContentDisplayItem>
toolItems: Map<string, ToolDisplayItem>
messageBlocks: Map<string, CachedBlockEntry>
}
const renderCaches = new Map<string, SessionRenderCache>()
function makeSessionCacheKey(instanceId: string, sessionId: string) {
return `${instanceId}:${sessionId}`
}
export function clearSessionRenderCache(instanceId: string, sessionId: string) {
renderCaches.delete(makeSessionCacheKey(instanceId, sessionId))
}
function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache {
const key = makeSessionCacheKey(instanceId, sessionId)
let cache = renderCaches.get(key)
if (!cache) {
cache = {
messageItems: new Map(),
toolItems: new Map(),
messageBlocks: new Map(),
}
renderCaches.set(key, cache)
}
return cache
}
function clearInstanceCaches(instanceId: string) {
clearRecordDisplayCacheForInstance(instanceId)
const prefix = `${instanceId}:`
for (const key of renderCaches.keys()) {
if (key.startsWith(prefix)) {
renderCaches.delete(key)
}
}
}
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
interface ContentDisplayItem {
type: "content"
key: string
record: MessageRecord
parts: ClientPart[]
messageInfo?: MessageInfo
isQueued: boolean
showAgentMeta?: boolean
}
interface ToolDisplayItem {
type: "tool"
key: string
toolPart: ToolCallPart
messageInfo?: MessageInfo
messageId: string
messageVersion: number
partVersion: number
}
interface StepDisplayItem {
type: "step-start" | "step-finish"
key: string
part: ClientPart
messageInfo?: MessageInfo
accentColor?: string
}
type ReasoningDisplayItem = {
type: "reasoning"
key: string
part: ClientPart
messageInfo?: MessageInfo
showAgentMeta?: boolean
defaultExpanded: boolean
}
type CompactionDisplayItem = {
type: "compaction"
key: string
part: ClientPart
messageInfo?: MessageInfo
accentColor?: string
}
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem | CompactionDisplayItem
interface MessageDisplayBlock {
record: MessageRecord
items: MessageBlockItem[]
}
interface MessageBlockProps {
messageId: string
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageIndex: number
lastAssistantIndex: () => number
showThinking: () => boolean
thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
onContentRendered?: () => void
}
export default function MessageBlock(props: MessageBlockProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
const block = createMemo<MessageDisplayBlock | null>(() => {
const current = record()
if (!current) return null
const index = props.messageIndex
const lastAssistantIdx = props.lastAssistantIndex()
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
const info = messageInfo()
const infoTime = (info?.time ?? {}) as { created?: number; updated?: number; completed?: number }
const infoTimestamp =
typeof infoTime.completed === "number"
? infoTime.completed
: typeof infoTime.updated === "number"
? infoTime.updated
: infoTime.created ?? 0
const infoError = (info as { error?: { name?: string } } | undefined)?.error
const infoErrorName = typeof infoError?.name === "string" ? infoError.name : ""
const cacheSignature = [
current.id,
current.revision,
isQueued ? 1 : 0,
props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0,
props.showUsageMetrics() ? 1 : 0,
infoTimestamp,
infoErrorName,
].join("|")
const cachedBlock = sessionCache.messageBlocks.get(current.id)
if (cachedBlock && cachedBlock.signature === cacheSignature) {
return cachedBlock.block
}
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
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
let lastAccentColor = defaultAccentColor
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))
let cached = sessionCache.messageItems.get(segmentKey)
if (!cached) {
cached = {
type: "content",
key: segmentKey,
record: current,
parts: pendingParts.slice(),
messageInfo: info,
isQueued,
showAgentMeta: shouldShowAgentMeta,
}
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
pendingParts = []
}
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}`
let toolItem = sessionCache.toolItems.get(key)
if (!toolItem) {
toolItem = {
type: "tool",
key,
toolPart: part as ToolCallPart,
messageInfo: info,
messageId: current.id,
messageVersion,
partVersion,
}
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
}
items.push(toolItem)
blockToolKeys.push(key)
lastAccentColor = TOOL_BORDER_COLOR
return
}
if (part.type === "compaction") {
flushContent()
const key = `${current.id}:${part.id ?? partIndex}:compaction`
const isAuto = Boolean((part as any)?.auto)
items.push({
type: "compaction",
key,
part,
messageInfo: info,
accentColor: isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR,
})
lastAccentColor = isAuto ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR
return
}
if (part.type === "step-start") {
flushContent()
return
}
if (part.type === "step-finish") {
flushContent()
if (props.showUsageMetrics()) {
const key = `${current.id}:${part.id ?? partIndex}:${part.type}`
const accentColor = lastAccentColor || defaultAccentColor
items.push({ type: part.type, key, part, messageInfo: info, accentColor })
lastAccentColor = accentColor
}
return
}
if (part.type === "reasoning") {
flushContent()
if (props.showThinking() && reasoningHasRenderableContent(part)) {
const key = `${current.id}:${part.id ?? partIndex}:reasoning`
const showAgentMeta = current.role === "assistant" && !agentMetaAttached
if (showAgentMeta) {
agentMetaAttached = true
}
items.push({
type: "reasoning",
key,
part,
messageInfo: info,
showAgentMeta,
defaultExpanded: props.thinkingDefaultExpanded(),
})
lastAccentColor = ASSISTANT_BORDER_COLOR
}
return
}
pendingParts.push(part)
})
flushContent()
const resultBlock: MessageDisplayBlock = { record: current, items }
sessionCache.messageBlocks.set(current.id, {
signature: cacheSignature,
block: resultBlock,
contentKeys: blockContentKeys.slice(),
toolKeys: blockToolKeys.slice(),
})
const messagePrefix = `${current.id}:`
for (const [key] of sessionCache.messageItems) {
if (key.startsWith(messagePrefix) && !blockContentKeys.includes(key)) {
sessionCache.messageItems.delete(key)
}
}
for (const [key] of sessionCache.toolItems) {
if (key.startsWith(messagePrefix) && !blockToolKeys.includes(key)) {
sessionCache.toolItems.delete(key)
}
}
return resultBlock
})
return (
<Show when={block()} keyed>
{(resolvedBlock) => (
<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}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={(item as ContentDisplayItem).isQueued}
showAgentMeta={(item as ContentDisplayItem).showAgentMeta}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={props.onContentRendered}
/>
</Match>
<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) : 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>Tool Call</span>
<span class="tool-name">{toolItem.toolPart.tool || "unknown"}</span>
</div>
<Show when={taskSessionId}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation}
onClick={handleGoToTaskSession}
title={!taskLocation ? "Session not available yet" : "Go to session"}
>
Go to Session
</button>
</Show>
</div>
<ToolCall
toolCall={toolItem.toolPart}
toolCallId={toolItem.toolPart.id}
messageId={toolItem.messageId}
messageVersion={toolItem.messageVersion}
partVersion={toolItem.partVersion}
instanceId={props.instanceId}
sessionId={props.sessionId}
onContentRendered={props.onContentRendered}
/>
</div>
)
})()}
</Match>
<Match when={item.type === "step-start"}>
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
</Match>
<Match when={item.type === "step-finish"}>
<StepCard
kind="finish"
part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()}
borderColor={(item as StepDisplayItem).accentColor}
/>
</Match>
<Match when={item.type === "compaction"}>
<CompactionCard part={(item as CompactionDisplayItem).part} messageInfo={(item as CompactionDisplayItem).messageInfo} borderColor={(item as CompactionDisplayItem).accentColor} />
</Match>
<Match when={item.type === "reasoning"}>
<ReasoningCard
part={(item as ReasoningDisplayItem).part}
messageInfo={(item as ReasoningDisplayItem).messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
/>
</Match>
</Switch>
)}
</For>
</div>
)}
</Show>
)
}
interface StepCardProps {
kind: "start" | "finish"
part: ClientPart
messageInfo?: MessageInfo
showAgentMeta?: boolean
showUsage?: boolean
borderColor?: string
}
function CompactionCard(props: { part: ClientPart; messageInfo?: MessageInfo; borderColor?: string }) {
const isAuto = () => Boolean((props.part as any)?.auto)
const label = () => (isAuto() ? "Session auto-compacted" : "Session compacted by you")
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
const containerClass = () =>
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
return (
<div
class={containerClass()}
style={{ "border-left": `4px solid ${borderColor()}` }}
role="status"
aria-label="Session compaction"
>
<div class="message-compaction-row">
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
<span class="message-compaction-label">{label()}</span>
</div>
</div>
)
}
function StepCard(props: StepCardProps) {
const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const agentIdentifier = () => {
if (!props.showAgentMeta) return ""
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
return info.mode || ""
}
const modelIdentifier = () => {
if (!props.showAgentMeta) return ""
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
const modelID = info.modelID || ""
const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}
const usageStats = () => {
if (props.kind !== "finish" || !props.showUsage) {
return null
}
const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.tokens) {
return null
}
const tokens = info.tokens
return {
input: tokens.input ?? 0,
output: tokens.output ?? 0,
reasoning: tokens.reasoning ?? 0,
cacheRead: tokens.cache?.read ?? 0,
cacheWrite: tokens.cache?.write ?? 0,
cost: info.cost ?? 0,
}
}
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [
{ label: "Input", value: usage.input, formatter: formatTokenTotal },
{ label: "Output", value: usage.output, formatter: formatTokenTotal },
{ label: "Reasoning", value: usage.reasoning, formatter: formatTokenTotal },
{ label: "Cache Read", value: usage.cacheRead, formatter: formatTokenTotal },
{ label: "Cache Write", value: usage.cacheWrite, formatter: formatTokenTotal },
{ label: "Cost", value: usage.cost, formatter: formatCostValue },
]
return (
<div class="message-step-usage">
<For each={entries}>
{(entry) => (
<span class="message-step-usage-chip" data-label={entry.label}>
{entry.formatter(entry.value)}
</span>
)}
</For>
</div>
)
}
if (props.kind === "finish") {
const usage = usageStats()
if (!usage) {
return null
}
return (
<div class={`message-step-card message-step-finish message-step-finish-flush`} style={finishStyle()}>
{renderUsageChips(usage)}
</div>
)
}
return (
<div class={`message-step-card message-step-start`}>
<div class="message-step-heading">
<div class="message-step-title">
<div class="message-step-title-left">
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
</span>
</Show>
</div>
<span class="message-step-time">{timestamp()}</span>
</div>
</div>
</div>
)
}
function formatCostValue(value: number) {
if (!value) return "$0.00"
if (value < 0.01) return `$${value.toPrecision(2)}`
return `$${value.toFixed(2)}`
}
interface ReasoningCardProps {
part: ClientPart
messageInfo?: MessageInfo
instanceId: string
sessionId: string
showAgentMeta?: boolean
defaultExpanded?: boolean
}
function ReasoningCard(props: ReasoningCardProps) {
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
createEffect(() => {
setExpanded(Boolean(props.defaultExpanded))
})
const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value)
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const agentIdentifier = () => {
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
return info.mode || ""
}
const modelIdentifier = () => {
const info = props.messageInfo
if (!info || info.role !== "assistant") return ""
const modelID = info.modelID || ""
const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}
const reasoningText = () => {
const part = props.part as any
if (!part) return ""
const stringifySegment = (segment: unknown): string => {
if (typeof segment === "string") {
return segment
}
if (segment && typeof segment === "object") {
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
const pieces: string[] = []
if (typeof obj.text === "string") {
pieces.push(obj.text)
}
if (typeof obj.value === "string") {
pieces.push(obj.value)
}
if (Array.isArray(obj.content)) {
pieces.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
}
return pieces.filter((piece) => piece && piece.trim().length > 0).join("\n")
}
return ""
}
const textValue = stringifySegment(part.text)
if (textValue.trim().length > 0) {
return textValue
}
if (Array.isArray(part.content)) {
return part.content.map((entry: unknown) => stringifySegment(entry)).join("\n")
}
return ""
}
const toggle = () => setExpanded((prev) => !prev)
return (
<div class="message-reasoning-card">
<button
type="button"
class="message-reasoning-toggle"
onClick={toggle}
aria-expanded={expanded()}
aria-label={expanded() ? "Collapse thinking" : "Expand thinking"}
>
<span class="message-reasoning-label flex flex-wrap items-center gap-2">
<span>Thinking</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)]">Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
</span>
</Show>
</span>
<span class="message-reasoning-meta">
<span class="message-reasoning-indicator">{expanded() ? "Hide" : "View"}</span>
<span class="message-reasoning-time">{timestamp()}</span>
</span>
</button>
<Show when={expanded()}>
<div class="message-reasoning-expanded">
<div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label="Reasoning details">
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
</div>
</div>
</div>
</Show>
</div>
)
}

View File

@@ -1,36 +1,47 @@
import { For, Show } from "solid-js"
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
import { For, Show, createSignal } from "solid-js"
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"
interface MessageItemProps {
message: Message
record: MessageRecord
messageInfo?: MessageInfo
instanceId: string
sessionId: string
isQueued?: boolean
parts?: ClientPart[]
parts: ClientPart[]
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
showAgentMeta?: boolean
onContentRendered?: () => void
}
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.message.type === "user"
const [copied, setCopied] = createSignal(false)
const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
const timestamp = () => {
const date = new Date(props.message.timestamp)
const date = new Date(createdTimestamp())
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const timestampIso = () => new Date(createdTimestamp()).toISOString()
type FilePart = Extract<ClientPart, { type: "file" }> & {
url?: string
mime?: string
filename?: string
}
const displayParts = () => props.parts ?? props.message.parts
const messageParts = () => props.parts
const fileAttachments = () =>
props.message.parts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
const getAttachmentName = (part: FilePart) => {
if (part.filename && part.filename.trim().length > 0) {
@@ -120,7 +131,7 @@ export default function MessageItem(props: MessageItemProps) {
return true
}
return displayParts().some((part) => partHasRenderableText(part))
return messageParts().some((part) => partHasRenderableText(part))
}
const isGenerating = () => {
@@ -130,15 +141,37 @@ export default function MessageItem(props: MessageItemProps) {
const handleRevert = () => {
if (props.onRevert && isUser()) {
props.onRevert(props.message.id)
props.onRevert(props.record.id)
}
}
const getRawContent = () => {
return props.parts
.filter(part => part.type === "text")
.map(part => (part as { text?: string }).text || "")
.filter(text => text.trim().length > 0)
.join("\n\n")
}
const handleCopy = async () => {
const content = getRawContent()
if (!content) return
const success = await copyToClipboard(content)
setCopied(success)
setTimeout(() => setCopied(false), 2000)
}
if (!isUser() && !hasContent()) {
return null
}
const containerClass = () =>
isUser()
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
const speakerLabel = () => (isUser() ? "You" : "Assistant")
const agentIdentifier = () => {
if (isUser()) return ""
const info = props.messageInfo
@@ -155,50 +188,86 @@ export default function MessageItem(props: MessageItemProps) {
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID
}
const agentMeta = () => {
if (isUser() || !props.showAgentMeta) return ""
const segments: string[] = []
const agent = agentIdentifier()
const model = modelIdentifier()
if (agent) {
segments.push(`Agent: ${agent}`)
}
if (model) {
segments.push(`Model: ${model}`)
}
return segments.join(" • ")
}
return (
<div class={containerClass()}>
<div class="flex justify-between items-center gap-2.5 pb-0.5">
<div class="flex flex-col">
<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()}>
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
</Show>
<Show when={!isUser()}>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-[var(--message-assistant-border)]">
<Show when={agentIdentifier()}>{(value) => <span>Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span>Model: {value()}</span>}</Show>
<div class="message-action-group">
<Show when={props.onRevert}>
<button
class="message-action-button"
onClick={handleRevert}
title="Revert to this message"
aria-label="Revert to this message"
>
Revert
</button>
</Show>
<Show when={props.onFork}>
<button
class="message-action-button"
onClick={() => props.onFork?.(props.record.id)}
title="Fork from this message"
aria-label="Fork from this message"
>
Fork
</button>
</Show>
<button
class="message-action-button"
onClick={handleCopy}
title="Copy message"
aria-label="Copy message"
>
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</button>
</div>
</Show>
</div>
<div class="flex items-center gap-2">
<Show when={isUser() && props.onRevert}>
<Show when={!isUser()}>
<button
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
onClick={handleRevert}
title="Revert to this message"
aria-label="Revert to this message"
class="message-action-button"
onClick={handleCopy}
title="Copy message"
aria-label="Copy message"
>
Revert to
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</button>
</Show>
<Show when={isUser() && props.onFork}>
<button
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
onClick={() => props.onFork?.(props.message.id)}
title="Fork from this message"
aria-label="Fork from this message"
>
Fork
</button>
</Show>
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
</div>
</header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">QUEUED</div>
</Show>
@@ -213,72 +282,72 @@ export default function MessageItem(props: MessageItemProps) {
</div>
</Show>
<For each={displayParts()}>
<For each={messageParts()}>
{(part) => (
<MessagePart
part={part}
messageType={props.message.type}
messageType={props.record.role}
instanceId={props.instanceId}
sessionId={props.sessionId}
onRendered={props.onContentRendered}
/>
)}
</For>
<Show when={fileAttachments().length > 0}>
<div class="message-attachments mt-1">
<For each={fileAttachments()}>
{(attachment) => {
const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment)
return (
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
<Show when={isImage} fallback={
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
}>
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={`Download ${name}`}
>
<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="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
<Show when={props.record.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
<Show when={props.record.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>
</div>
<Show when={fileAttachments().length > 0}>
<div class="message-attachments">
<For each={fileAttachments()}>
{(attachment) => {
const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment)
return (
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}>
<Show when={isImage} fallback={
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
}>
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={`Download ${name}`}
>
<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="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div>
)
}}
</For>
</div>
</Show>
<Show when={props.message.status === "sending"}>
<div class="message-sending">
<span class="generating-spinner"></span> Sending...
</div>
</Show>
<Show when={props.message.status === "error"}>
<div class="message-error"> Message failed to send</div>
</Show>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { Show } from "solid-js"
import Kbd from "./kbd"
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"
interface MessageListHeaderProps {
usedTokens: number
availableTokens?: number | null
connectionStatus: "connected" | "connecting" | "error" | "disconnected" | "unknown" | null
onCommandPalette: () => void
formatTokens: (value: number) => string
showSidebarToggle?: boolean
onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean
}
export default function MessageListHeader(props: MessageListHeaderProps) {
const hasAvailableTokens = () => typeof props.availableTokens === "number"
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
return (
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
<Show when={props.showSidebarToggle}>
<div class="connection-status-menu">
<button
type="button"
class="session-sidebar-menu-button"
onClick={() => props.onSidebarToggle?.()}
aria-label="Open session list"
>
<span aria-hidden="true" class="session-sidebar-menu-icon"></span>
</button>
</div>
</Show>
<div class="connection-status-text connection-status-info">
<div class="connection-status-usage">
<div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>Used</span>
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
</div>
<div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>Avail</span>
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
</div>
</div>
</div>
<div class="connection-status-text connection-status-shortcut">
<div class="connection-status-shortcut-action">
<button type="button" class="connection-status-button" onClick={props.onCommandPalette} aria-label="Open command palette">
Command Palette
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
</div>
<div class="connection-status-meta flex items-center justify-end gap-3">
<Show when={props.connectionStatus === "connected"}>
<span class="status-indicator connected">
<span class="status-dot" />
<span class="status-text">Connected</span>
</span>
</Show>
<Show when={props.connectionStatus === "connecting"}>
<span class="status-indicator connecting">
<span class="status-dot" />
<span class="status-text">Connecting...</span>
</span>
</Show>
<Show when={props.connectionStatus === "error" || props.connectionStatus === "disconnected"}>
<span class="status-indicator disconnected">
<span class="status-dot" />
<span class="status-text">Disconnected</span>
</span>
</Show>
</div>
</div>
)
}

View File

@@ -13,8 +13,10 @@ interface MessagePartProps {
messageType?: "user" | "assistant"
instanceId: string
sessionId: string
}
export default function MessagePart(props: MessagePartProps) {
onRendered?: () => void
}
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
const partType = () => props.part?.type || ""
@@ -33,6 +35,38 @@ export default function MessagePart(props: MessagePartProps) {
return ""
}
function reasoningSegmentHasText(segment: unknown): boolean {
if (typeof segment === "string") {
return segment.trim().length > 0
}
if (segment && typeof segment === "object") {
const candidate = segment as { text?: unknown; value?: unknown; content?: unknown[] }
if (typeof candidate.text === "string" && candidate.text.trim().length > 0) {
return true
}
if (typeof candidate.value === "string" && candidate.value.trim().length > 0) {
return true
}
if (Array.isArray(candidate.content)) {
return candidate.content.some((entry) => reasoningSegmentHasText(entry))
}
}
return false
}
const hasReasoningContent = () => {
if (props.part?.type !== "reasoning") {
return false
}
if (reasoningSegmentHasText((props.part as any).text)) {
return true
}
if (Array.isArray((props.part as any).content)) {
return (props.part as any).content.some((entry: unknown) => reasoningSegmentHasText(entry))
}
return false
}
const createTextPartForMarkdown = (): TextPart => {
const part = props.part
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
@@ -63,11 +97,19 @@ export default function MessagePart(props: MessagePartProps) {
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
<div class={textContainerClass()}>
<Show
when={isAssistantMessage()}
fallback={<span>{plainTextContent()}</span>}
>
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
</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>
</div>
</Show>
</Match>
@@ -83,23 +125,7 @@ export default function MessagePart(props: MessagePartProps) {
<Match when={partType() === "reasoning"}>
<Show when={preferences().showThinkingBlocks && partHasRenderableText(props.part)}>
<div class="message-reasoning">
<div class="reasoning-container">
<div class="reasoning-header" onClick={handleReasoningClick}>
<span class="reasoning-icon">{isReasoningExpanded() ? "▼" : "▶"}</span>
<span class="reasoning-label">Reasoning</span>
</div>
<Show when={isReasoningExpanded()}>
<div class={`${textContainerClass()} mt-2`}>
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
</div>
</Show>
</div>
</div>
</Show>
</Match>
</Switch>
)
}

View File

@@ -0,0 +1,32 @@
import type { Component } from "solid-js"
import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
interface MessagePreviewProps {
instanceId: string
sessionId: string
messageId: string
store: () => InstanceMessageStore
}
const MessagePreview: Component<MessagePreviewProps> = (props) => {
const lastAssistantIndex = () => 0
return (
<div class="message-preview message-stream">
<MessageBlock
messageId={props.messageId}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={props.store}
messageIndex={0}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => false}
thinkingDefaultExpanded={() => false}
showUsageMetrics={() => false}
/>
</div>
)
}
export default MessagePreview

View File

@@ -0,0 +1,858 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import Kbd from "./kbd"
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
import { useConfig } from "../stores/preferences"
import { getSessionInfo } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
const SCROLL_SCOPE = "session"
const SCROLL_SENTINEL_MARGIN_PX = 48
const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
const QUOTE_SELECTION_MAX_LENGTH = 2000
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
export interface MessageSectionProps {
instanceId: string
sessionId: string
loading?: boolean
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
registerScrollToBottom?: (fn: () => void) => void
showSidebarToggle?: boolean
onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean
onQuoteSelection?: (text: string, mode: "quote" | "code") => void
isActive?: boolean
}
export default function MessageSection(props: MessageSectionProps) {
const { preferences } = useConfig()
const showUsagePreference = () => preferences().showUsageMetrics ?? true
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
const sessionRevision = createMemo(() => store().getSessionRevision(props.sessionId))
const usageSnapshot = createMemo(() => store().getSessionUsage(props.sessionId))
const sessionInfo = createMemo(() =>
getSessionInfo(props.instanceId, props.sessionId) ?? {
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
inputTokens: 0,
outputTokens: 0,
reasoningTokens: 0,
actualUsageTokens: 0,
modelOutputLimit: 0,
contextAvailableTokens: null,
},
)
const tokenStats = createMemo(() => {
const usage = usageSnapshot()
const info = sessionInfo()
return {
used: usage?.actualUsageTokens ?? info.actualUsageTokens ?? 0,
avail: info.contextAvailableTokens,
}
})
const preferenceSignature = createMemo(() => {
const pref = preferences()
const showThinking = pref.showThinkingBlocks ? 1 : 0
const thinkingExpansion = pref.thinkingBlocksExpansion ?? "expanded"
const showUsage = (pref.showUsageMetrics ?? true) ? 1 : 0
return `${showThinking}|${thinkingExpansion}|${showUsage}`
})
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
if (typeof document === "undefined") return
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
}
const lastAssistantIndex = createMemo(() => {
const ids = messageIds()
const resolvedStore = store()
for (let index = ids.length - 1; index >= 0; index--) {
const record = resolvedStore.getMessage(ids[index])
if (record?.role === "assistant") {
return index
}
}
return -1
})
const [timelineSegments, setTimelineSegments] = createSignal<TimelineSegment[]>([])
const hasTimelineSegments = () => timelineSegments().length > 0
const seenTimelineMessageIds = new Set<string>()
const seenTimelineSegmentKeys = new Set<string>()
function makeTimelineKey(segment: TimelineSegment) {
return `${segment.messageId}:${segment.id}:${segment.type}`
}
function seedTimeline() {
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
const ids = untrack(messageIds)
const resolvedStore = untrack(store)
const segments: TimelineSegment[] = []
ids.forEach((messageId) => {
const record = resolvedStore.getMessage(messageId)
if (!record) return
seenTimelineMessageIds.add(messageId)
const built = buildTimelineSegments(props.instanceId, record)
built.forEach((segment) => {
const key = makeTimelineKey(segment)
if (seenTimelineSegmentKeys.has(key)) return
seenTimelineSegmentKeys.add(key)
segments.push(segment)
})
})
setTimelineSegments(segments)
}
function appendTimelineForMessage(messageId: string) {
const record = untrack(() => store().getMessage(messageId))
if (!record) return
const built = buildTimelineSegments(props.instanceId, record)
if (built.length === 0) return
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])
}
}
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
const changeToken = createMemo(() => String(sessionRevision()))
const isActive = createMemo(() => props.isActive !== false)
const scrollCache = useScrollCache({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
scope: SCROLL_SCOPE,
})
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
const bottomSentinel = () => bottomSentinelSignal()
const setBottomSentinel = (element: HTMLDivElement | null) => {
setBottomSentinelSignal(element)
resolvePendingActiveScroll()
}
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
let containerRef: HTMLDivElement | undefined
let shellRef: HTMLDivElement | undefined
let pendingScrollFrame: number | null = null
let pendingAnchorScroll: number | null = null
let pendingScrollPersist: number | null = null
let userScrollIntentUntil = 0
let detachScrollIntentListeners: (() => void) | undefined
let hasRestoredScroll = false
let suppressAutoScrollOnce = false
let pendingActiveScroll = false
let scrollToBottomFrame: number | null = null
let scrollToBottomDelayedFrame: number | null = null
let pendingInitialScroll = true
function markUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
userScrollIntentUntil = now + USER_SCROLL_INTENT_WINDOW_MS
}
function hasUserScrollIntent() {
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
return now <= userScrollIntentUntil
}
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
if (!element) return
const handlePointerIntent = () => markUserScrollIntent()
const handleKeyIntent = (event: KeyboardEvent) => {
if (SCROLL_INTENT_KEYS.has(event.key)) {
markUserScrollIntent()
}
}
element.addEventListener("wheel", handlePointerIntent, { passive: true })
element.addEventListener("pointerdown", handlePointerIntent)
element.addEventListener("touchstart", handlePointerIntent, { passive: true })
element.addEventListener("keydown", handleKeyIntent)
detachScrollIntentListeners = () => {
element.removeEventListener("wheel", handlePointerIntent)
element.removeEventListener("pointerdown", handlePointerIntent)
element.removeEventListener("touchstart", handlePointerIntent)
element.removeEventListener("keydown", handleKeyIntent)
}
}
function setContainerRef(element: HTMLDivElement | null) {
containerRef = element || undefined
setScrollElement(containerRef)
attachScrollIntentListeners(containerRef)
if (!containerRef) {
clearQuoteSelection()
return
}
resolvePendingActiveScroll()
}
function setShellElement(element: HTMLDivElement | null) {
shellRef = element || undefined
if (!shellRef) {
clearQuoteSelection()
}
}
function updateScrollIndicatorsFromVisibility() {
const hasItems = messageIds().length > 0
const bottomVisible = bottomSentinelVisible()
const topVisible = topSentinelVisible()
setShowScrollBottomButton(hasItems && !bottomVisible)
setShowScrollTopButton(hasItems && !topVisible)
}
function scheduleScrollPersist() {
if (pendingScrollPersist !== null) return
pendingScrollPersist = requestAnimationFrame(() => {
pendingScrollPersist = null
if (!containerRef) return
// scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
})
}
function scrollToBottom(immediate = false, options?: { suppressAutoAnchor?: boolean }) {
if (!containerRef) return
const sentinel = bottomSentinel()
const behavior = immediate ? "auto" : "smooth"
const suppressAutoAnchor = options?.suppressAutoAnchor ?? !immediate
if (suppressAutoAnchor) {
suppressAutoScrollOnce = true
}
sentinel?.scrollIntoView({ block: "end", inline: "nearest", behavior })
setAutoScroll(true)
scheduleScrollPersist()
}
function clearScrollToBottomFrames() {
if (scrollToBottomFrame !== null) {
cancelAnimationFrame(scrollToBottomFrame)
scrollToBottomFrame = null
}
if (scrollToBottomDelayedFrame !== null) {
cancelAnimationFrame(scrollToBottomDelayedFrame)
scrollToBottomDelayedFrame = null
}
}
function requestScrollToBottom(immediate = true) {
if (!isActive()) {
pendingActiveScroll = true
return
}
if (!containerRef || !bottomSentinel()) {
pendingActiveScroll = true
return
}
pendingActiveScroll = false
clearScrollToBottomFrames()
scrollToBottomFrame = requestAnimationFrame(() => {
scrollToBottomFrame = null
scrollToBottomDelayedFrame = requestAnimationFrame(() => {
scrollToBottomDelayedFrame = null
scrollToBottom(immediate)
})
})
}
function resolvePendingActiveScroll() {
if (!pendingActiveScroll) return
if (!isActive()) return
requestScrollToBottom(true)
}
function scrollToTop(immediate = false) {
if (!containerRef) return
const behavior = immediate ? "auto" : "smooth"
setAutoScroll(false)
topSentinel()?.scrollIntoView({ block: "start", inline: "nearest", behavior })
scheduleScrollPersist()
}
function scheduleAnchorScroll(immediate = false) {
if (!autoScroll()) return
if (!isActive()) {
pendingActiveScroll = true
return
}
const sentinel = bottomSentinel()
if (!sentinel) {
pendingActiveScroll = true
return
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
pendingAnchorScroll = null
}
pendingAnchorScroll = requestAnimationFrame(() => {
pendingAnchorScroll = null
sentinel.scrollIntoView({ block: "end", inline: "nearest", behavior: immediate ? "auto" : "smooth" })
})
}
function clearQuoteSelection() {
setQuoteSelection(null)
}
function isSelectionWithinStream(range: Range | null) {
if (!range || !containerRef) return false
const node = range.commonAncestorContainer
if (!node) return false
return containerRef.contains(node)
}
function updateQuoteSelectionFromSelection() {
if (!props.onQuoteSelection || typeof window === "undefined") {
clearQuoteSelection()
return
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
clearQuoteSelection()
return
}
const range = selection.getRangeAt(0)
if (!isSelectionWithinStream(range)) {
clearQuoteSelection()
return
}
const shell = shellRef
if (!shell) {
clearQuoteSelection()
return
}
const rawText = selection.toString().trim()
if (!rawText) {
clearQuoteSelection()
return
}
const limited =
rawText.length > QUOTE_SELECTION_MAX_LENGTH ? rawText.slice(0, QUOTE_SELECTION_MAX_LENGTH).trimEnd() : rawText
if (!limited) {
clearQuoteSelection()
return
}
const rects = range.getClientRects()
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)
const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft)
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
}
function handleStreamMouseUp() {
updateQuoteSelectionFromSelection()
}
function handleQuoteSelectionRequest(mode: "quote" | "code") {
const info = quoteSelection()
if (!info || !props.onQuoteSelection) return
props.onQuoteSelection(info.text, mode)
clearQuoteSelection()
if (typeof window !== "undefined") {
const selection = window.getSelection()
selection?.removeAllRanges()
}
}
function handleContentRendered() {
if (props.loading) {
return
}
scheduleAnchorScroll()
}
function handleScroll() {
if (!containerRef) return
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
const isUserScroll = hasUserScrollIntent()
pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null
if (!containerRef) return
const atBottom = bottomSentinelVisible()
if (isUserScroll) {
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else if (autoScroll()) {
setAutoScroll(false)
}
}
clearQuoteSelection()
scheduleScrollPersist()
})
}
createEffect(() => {
if (props.registerScrollToBottom) {
props.registerScrollToBottom(() => requestScrollToBottom(true))
}
})
let lastActiveState = false
createEffect(() => {
const active = isActive()
if (active) {
resolvePendingActiveScroll()
if (!lastActiveState && autoScroll()) {
requestScrollToBottom(true)
}
} else if (autoScroll()) {
pendingActiveScroll = true
}
lastActiveState = active
})
createEffect(() => {
const loading = Boolean(props.loading)
if (loading) {
pendingInitialScroll = true
return
}
if (!pendingInitialScroll) {
return
}
const container = scrollElement()
const sentinel = bottomSentinel()
if (!container || !sentinel || messageIds().length === 0) {
return
}
pendingInitialScroll = false
requestScrollToBottom(true)
})
let previousTimelineIds: string[] = []
let previousLastTimelineMessageId: string | null = null
let previousLastTimelinePartCount = 0
createEffect(() => {
const loading = Boolean(props.loading)
const ids = messageIds()
if (loading) {
previousTimelineIds = []
previousLastTimelineMessageId = null
previousLastTimelinePartCount = 0
setTimelineSegments([])
seenTimelineMessageIds.clear()
seenTimelineSegmentKeys.clear()
return
}
if (previousTimelineIds.length === 0 && ids.length > 0) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length < previousTimelineIds.length) {
seedTimeline()
previousTimelineIds = ids.slice()
return
}
if (ids.length === previousTimelineIds.length) {
let changedIndex = -1
let changeCount = 0
for (let index = 0; index < ids.length; index++) {
if (ids[index] !== previousTimelineIds[index]) {
changedIndex = index
changeCount += 1
if (changeCount > 1) break
}
}
if (changeCount === 1 && changedIndex >= 0) {
const oldId = previousTimelineIds[changedIndex]
const newId = ids[changedIndex]
if (seenTimelineMessageIds.has(oldId) && !seenTimelineMessageIds.has(newId)) {
seenTimelineMessageIds.delete(oldId)
seenTimelineMessageIds.add(newId)
setTimelineSegments((prev) => {
const next = prev.map((segment) => {
if (segment.messageId !== oldId) return segment
const updatedId = segment.id.replace(oldId, newId)
return { ...segment, messageId: newId, id: updatedId }
})
seenTimelineSegmentKeys.clear()
next.forEach((segment) => seenTimelineSegmentKeys.add(makeTimelineKey(segment)))
return next
})
previousTimelineIds = ids.slice()
return
}
}
}
const newIds: string[] = []
ids.forEach((id) => {
if (!seenTimelineMessageIds.has(id)) {
newIds.push(id)
}
})
if (newIds.length > 0) {
newIds.forEach((id) => {
seenTimelineMessageIds.add(id)
appendTimelineForMessage(id)
})
}
previousTimelineIds = ids.slice()
})
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
}
previousLastTimelineMessageId = lastId
previousLastTimelinePartCount = partCount
const built = buildTimelineSegments(props.instanceId, record)
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])
}
})
createEffect(() => {
if (!props.onQuoteSelection) {
clearQuoteSelection()
}
})
createEffect(() => {
if (typeof document === "undefined") return
const handleSelectionChange = () => updateQuoteSelectionFromSelection()
const handlePointerDown = (event: PointerEvent) => {
if (!shellRef) return
if (!shellRef.contains(event.target as Node)) {
clearQuoteSelection()
}
}
document.addEventListener("selectionchange", handleSelectionChange)
document.addEventListener("pointerdown", handlePointerDown)
onCleanup(() => {
document.removeEventListener("selectionchange", handleSelectionChange)
document.removeEventListener("pointerdown", handlePointerDown)
})
})
createEffect(() => {
if (props.loading) {
clearQuoteSelection()
}
})
createEffect(() => {
const target = containerRef
const loading = props.loading
if (!target || loading || hasRestoredScroll) return
// scrollCache.restore(target, {
// onApplied: (snapshot) => {
// if (snapshot) {
// setAutoScroll(snapshot.atBottom)
// } else {
// setAutoScroll(bottomSentinelVisible())
// }
// updateScrollIndicatorsFromVisibility()
// },
// })
hasRestoredScroll = true
})
let previousToken: string | undefined
createEffect(() => {
const token = changeToken()
const loading = props.loading
if (loading || !token || token === previousToken) {
return
}
previousToken = token
if (suppressAutoScrollOnce) {
suppressAutoScrollOnce = false
return
}
if (autoScroll()) {
scheduleAnchorScroll(true)
}
})
createEffect(() => {
preferenceSignature()
if (props.loading || !autoScroll()) {
return
}
if (suppressAutoScrollOnce) {
suppressAutoScrollOnce = false
return
}
scheduleAnchorScroll(true)
})
createEffect(() => {
if (messageIds().length === 0) {
setShowScrollTopButton(false)
setShowScrollBottomButton(false)
setAutoScroll(true)
return
}
updateScrollIndicatorsFromVisibility()
})
createEffect(() => {
const container = scrollElement()
const topTarget = topSentinel()
const bottomTarget = bottomSentinel()
if (!container || !topTarget || !bottomTarget) return
const observer = new IntersectionObserver(
(entries) => {
let visibilityChanged = false
for (const entry of entries) {
if (entry.target === topTarget) {
setTopSentinelVisible(entry.isIntersecting)
visibilityChanged = true
} else if (entry.target === bottomTarget) {
setBottomSentinelVisible(entry.isIntersecting)
visibilityChanged = true
}
}
if (visibilityChanged) {
updateScrollIndicatorsFromVisibility()
}
},
{ root: container, threshold: 0, rootMargin: `${SCROLL_SENTINEL_MARGIN_PX}px 0px ${SCROLL_SENTINEL_MARGIN_PX}px 0px` },
)
observer.observe(topTarget)
observer.observe(bottomTarget)
onCleanup(() => observer.disconnect())
})
createEffect(() => {
const container = scrollElement()
const ids = messageIds()
if (!container || ids.length === 0) return
if (typeof document === "undefined") return
const observer = new IntersectionObserver(
(entries) => {
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) {
best = entry
}
}
if (best) {
const anchorId = (best.target as HTMLElement).id
const messageId = anchorId.startsWith("message-anchor-") ? anchorId.slice("message-anchor-".length) : anchorId
setActiveMessageId((current) => (current === messageId ? current : messageId))
}
},
{ root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 },
)
ids.forEach((messageId) => {
const anchor = document.getElementById(getMessageAnchorId(messageId))
if (anchor) {
observer.observe(anchor)
}
})
onCleanup(() => observer.disconnect())
})
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
if (pendingScrollPersist !== null) {
cancelAnimationFrame(pendingScrollPersist)
}
if (pendingAnchorScroll !== null) {
cancelAnimationFrame(pendingAnchorScroll)
}
clearScrollToBottomFrames()
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
}
if (containerRef) {
// scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
}
clearQuoteSelection()
})
return (
<div class="message-stream-container">
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
<div class="message-stream-shell" ref={setShellElement}>
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
<Show when={!props.loading && messageIds().length === 0}>
<div class="empty-state">
<div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
</div>
<h3>Start a conversation</h3>
<p>Type a message below or open the Command Palette:</p>
<ul>
<li>
<span>Command Palette</span>
<Kbd shortcut="cmd+shift+p" class="ml-2" />
</li>
<li>Ask about your codebase</li>
<li>
Attach files with <code>@</code>
</li>
</ul>
</div>
</div>
</Show>
<Show when={props.loading}>
<div class="loading-state">
<div class="spinner" />
<p>Loading messages...</p>
</div>
</Show>
<MessageBlockList
instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
messageIds={messageIds}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
showUsageMetrics={showUsagePreference}
scrollContainer={scrollElement}
loading={props.loading}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={handleContentRendered}
setBottomSentinel={setBottomSentinel}
suspendMeasurements={() => !isActive()}
/>
</div>
<Show when={showScrollTopButton() || showScrollBottomButton()}>
<div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}>
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
</Show>
<Show when={showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom(false, { suppressAutoAnchor: false })}
aria-label="Scroll to latest message"
>
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
</Show>
</div>
</Show>
<Show when={quoteSelection()}>
{(selection) => (
<div
class="message-quote-popover"
style={{ top: `${selection().top}px`, left: `${selection().left}px` }}
>
<div class="message-quote-button-group">
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
Add as quote
</button>
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
Add as code
</button>
</div>
</div>
)}
</Show>
</div>
<Show when={hasTimelineSegments()}>
<div class="message-timeline-sidebar">
<MessageTimeline
segments={timelineSegments()}
onSegmentClick={handleTimelineSegmentClick}
activeMessageId={activeMessageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
showToolSegments={showTimelineToolsPreference()}
/>
</div>
</Show>
</div>
</div>
)
}

View File

@@ -1,707 +0,0 @@
import { For, Show, createSignal, createEffect, createMemo, onCleanup } from "solid-js"
import type { Message, MessageDisplayParts, SDKPart, MessageInfo, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
// Import ToolState types from SDK
type ToolState = import("@opencode-ai/sdk").ToolState
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk").ToolStateError
// Type guards
function isToolStateRunning(state: ToolState): state is ToolStateRunning {
return state.status === "running"
}
function isToolStateCompleted(state: ToolState): state is ToolStateCompleted {
return state.status === "completed"
}
function isToolStateError(state: ToolState): state is ToolStateError {
return state.status === "error"
}
// Type guard to check if a part is a tool part
function isToolPart(part: ClientPart): part is ToolCallPart {
return part.type === "tool"
}
import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import { sseManager } from "../lib/sse-manager"
import Kbd from "./kbd"
import { useConfig } from "../stores/preferences"
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
const SCROLL_OFFSET = 64
const SCROLL_DIRECTION_THRESHOLD = 10
interface TaskSessionLocation {
sessionId: string
instanceId: string
parentId: string | null
}
const messageScrollState = new Map<string, { scrollTop: number; autoScroll: boolean }>()
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
if (!sessionId) return null
const allSessions = sessions()
for (const [instanceId, sessionMap] of allSessions) {
const session = sessionMap?.get(sessionId)
if (session) {
return {
sessionId: session.id,
instanceId,
parentId: session.parentId ?? null,
}
}
}
return null
}
function navigateToTaskSession(location: TaskSessionLocation) {
setActiveInstanceId(location.instanceId)
const parentToActivate = location.parentId ?? location.sessionId
setActiveParentSession(location.instanceId, parentToActivate)
if (location.parentId) {
setActiveSession(location.instanceId, location.sessionId)
}
}
// Format tokens like TUI (e.g., "110K", "1.2M")
function formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`
} else if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(0)}K`
}
return tokens.toString()
}
// Format session info for the session view header
function formatSessionInfo(usageTokens: number, contextWindow: number, usagePercent: number | null): string {
if (contextWindow > 0) {
const windowStr = formatTokens(contextWindow)
const usageStr = formatTokens(usageTokens)
const percent = usagePercent ?? Math.min(100, Math.max(0, Math.round((usageTokens / contextWindow) * 100)))
return `${usageStr} of ${windowStr} (${percent}%)`
}
return formatTokens(usageTokens)
}
interface MessageStreamProps {
instanceId: string
sessionId: string
messages: Message[]
messagesInfo?: Map<string, MessageInfo>
revert?: {
messageID: string
partID?: string
snapshot?: string
diff?: string
}
loading?: boolean
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
}
interface MessageDisplayItem {
type: "message"
message: Message
combinedParts: ClientPart[]
isQueued: boolean
messageInfo?: MessageInfo
}
interface ToolDisplayItem {
type: "tool"
key: string
toolPart: ToolCallPart
messageInfo?: MessageInfo
messageId: string
messageVersion: number
partVersion: number
}
type DisplayItem = MessageDisplayItem | ToolDisplayItem
interface MessageCacheEntry {
message: Message
version: number
showThinking: boolean
isQueued: boolean
messageInfo?: MessageInfo
displayParts: MessageDisplayParts
item: MessageDisplayItem
}
interface ToolCacheEntry {
toolPart: ClientPart
messageInfo?: MessageInfo
signature: string
contentKey: string
item: ToolDisplayItem
}
interface SessionCache {
messageItemCache: Map<string, MessageCacheEntry>
toolItemCache: Map<string, ToolCacheEntry>
}
const sessionCaches = new Map<string, SessionCache>()
function getSessionCache(instanceId: string, sessionId: string): SessionCache {
const key = `${instanceId}:${sessionId}`
let cache = sessionCaches.get(key)
if (!cache) {
cache = {
messageItemCache: new Map(),
toolItemCache: new Map(),
}
sessionCaches.set(key, cache)
}
return cache
}
export default function MessageStream(props: MessageStreamProps) {
const { preferences } = useConfig()
let containerRef: HTMLDivElement | undefined
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const sessionCache = getSessionCache(props.instanceId, props.sessionId)
let messageItemCache = sessionCache.messageItemCache
let toolItemCache = sessionCache.toolItemCache
let scrollAnimationFrame: number | null = null
let lastKnownScrollTop = 0
const makeScrollKey = (instanceId: string, sessionId: string) => `${instanceId}:${sessionId}`
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
const connectionStatus = () => sseManager.getStatus(props.instanceId)
function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string {
const messageId = message.id
const partId = typeof toolPart?.id === "string" ? toolPart.id : `${messageId}-tool-${toolIndex}`
return `${messageId}:${partId}`
}
function createToolContentKey(toolPart: ClientPart, messageInfo?: MessageInfo): string {
const state = isToolPart(toolPart) ? toolPart.state : undefined
const version = typeof toolPart?.version === "number" ? toolPart.version : 0
const status = state?.status ?? "unknown"
return `${toolPart.id}:${version}:${status}`
}
const sessionInfo = createMemo(() =>
getSessionInfo(props.instanceId, props.sessionId) ?? {
tokens: 0,
cost: 0,
contextWindow: 0,
isSubscriptionModel: false,
contextUsageTokens: 0,
contextUsagePercent: null,
},
)
const formattedSessionInfo = createMemo(() => {
const info = sessionInfo()
return formatSessionInfo(info.contextUsageTokens, info.contextWindow, info.contextUsagePercent)
})
function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
const { scrollTop, scrollHeight, clientHeight } = element
const distance = scrollHeight - (scrollTop + clientHeight)
return distance <= offset
}
function isNearTop(element: HTMLDivElement, offset = SCROLL_OFFSET) {
return element.scrollTop <= offset
}
function scrollToBottom(options: { smooth?: boolean } = {}) {
if (!containerRef) return
const behavior = options.smooth ? "smooth" : "auto"
requestAnimationFrame(() => {
if (!containerRef) return
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
setAutoScroll(true)
updateScrollIndicators(containerRef)
})
}
function scrollToTop(options: { smooth?: boolean } = {}) {
if (!containerRef) return
const behavior = options.smooth ? "smooth" : "auto"
setAutoScroll(false)
requestAnimationFrame(() => {
if (!containerRef) return
containerRef.scrollTo({ top: 0, behavior })
setShowScrollTopButton(false)
updateScrollIndicators(containerRef)
})
}
function handleScroll(event: Event) {
if (!containerRef) return
if (scrollAnimationFrame !== null) {
cancelAnimationFrame(scrollAnimationFrame)
}
const isUserScroll = event.isTrusted
scrollAnimationFrame = requestAnimationFrame(() => {
if (!containerRef) return
const currentScrollTop = containerRef.scrollTop
const movingUp = currentScrollTop < lastKnownScrollTop - SCROLL_DIRECTION_THRESHOLD
lastKnownScrollTop = currentScrollTop
const atBottom = isNearBottom(containerRef)
if (isUserScroll) {
if (movingUp && !atBottom && autoScroll()) {
setAutoScroll(false)
} else if (!movingUp && atBottom && !autoScroll()) {
setAutoScroll(true)
}
}
updateScrollIndicators(containerRef)
scrollAnimationFrame = null
})
}
const messageView = createMemo(() => {
const showThinking = preferences().showThinkingBlocks
const items: DisplayItem[] = []
const newMessageCache = new Map<string, MessageCacheEntry>()
const newToolCache = new Map<string, ToolCacheEntry>()
const tokenSegments: string[] = []
let lastAssistantIndex = -1
for (let i = props.messages.length - 1; i >= 0; i--) {
if (props.messages[i].type === "assistant") {
lastAssistantIndex = i
break
}
}
tokenSegments.push(`count:${props.messages.length}`)
tokenSegments.push(`revert:${props.revert?.messageID ?? ""}`)
tokenSegments.push(`thinking:${showThinking ? 1 : 0}`)
for (let index = 0; index < props.messages.length; index++) {
const message = props.messages[index]
const messageInfo = props.messagesInfo?.get(message.id)
if (props.revert?.messageID && message.id === props.revert.messageID) {
break
}
tokenSegments.push(`${message.id}:${message.version ?? 0}:${message.status}:${message.parts.length}`)
const baseDisplayParts = message.displayParts
const displayParts: MessageDisplayParts =
!baseDisplayParts || baseDisplayParts.showThinking !== showThinking
? computeDisplayParts(message, showThinking)
: (baseDisplayParts as MessageDisplayParts)
const combinedParts = displayParts.combined
const version = message.version ?? 0
const isQueued = message.type === "user" && (lastAssistantIndex === -1 || index > lastAssistantIndex)
const hasRenderableContent =
message.type !== "assistant" ||
combinedParts.length > 0 ||
Boolean(messageInfo && messageInfo.role === "assistant" && messageInfo.error) ||
message.status === "error"
if (hasRenderableContent) {
const cacheEntry = messageItemCache.get(message.id)
if (
cacheEntry &&
cacheEntry.version === version &&
cacheEntry.showThinking === showThinking &&
cacheEntry.isQueued === isQueued &&
cacheEntry.messageInfo === messageInfo
) {
cacheEntry.displayParts = displayParts
cacheEntry.version = version
cacheEntry.showThinking = showThinking
cacheEntry.isQueued = isQueued
cacheEntry.messageInfo = messageInfo
cacheEntry.item.message = message
cacheEntry.item.combinedParts = combinedParts
cacheEntry.item.isQueued = isQueued
cacheEntry.item.messageInfo = messageInfo
newMessageCache.set(message.id, cacheEntry)
items.push(cacheEntry.item)
} else {
const messageItem: MessageDisplayItem = {
type: "message",
message,
combinedParts,
isQueued,
messageInfo,
}
newMessageCache.set(message.id, {
message,
version,
showThinking,
isQueued,
messageInfo,
displayParts,
item: messageItem,
})
items.push(messageItem)
}
}
const toolParts = displayParts.tool.filter(isToolPart)
for (let toolIndex = 0; toolIndex < toolParts.length; toolIndex++) {
const toolPart = toolParts[toolIndex]
const originalIndex = displayParts.tool.indexOf(toolPart)
const toolKey = toolPart?.id || `${message.id}-tool-${originalIndex}`
const messageVersion = typeof message.version === "number" ? message.version : 0
const partVersion = typeof toolPart?.version === "number" ? toolPart.version : 0
const toolSignature = createToolSignature(message, toolPart, originalIndex, messageInfo)
const contentKey = createToolContentKey(toolPart, messageInfo)
tokenSegments.push(`tool:${toolKey}:${partVersion}`)
const toolEntry = toolItemCache.get(toolKey)
if (toolEntry && toolEntry.signature === toolSignature) {
if (toolEntry.contentKey !== contentKey) {
const updatedItem: ToolDisplayItem = {
...toolEntry.item,
toolPart,
messageInfo,
messageId: message.id,
messageVersion,
partVersion,
}
toolEntry.toolPart = toolPart
toolEntry.messageInfo = messageInfo
toolEntry.signature = toolSignature
toolEntry.contentKey = contentKey
toolEntry.item = updatedItem
console.debug("[ToolCall] update", toolKey, toolPart.state?.status)
newToolCache.set(toolKey, toolEntry)
items.push(updatedItem)
} else {
const cachedItem = toolEntry.item
cachedItem.toolPart = toolPart
cachedItem.messageInfo = messageInfo
cachedItem.messageId = message.id
cachedItem.messageVersion = messageVersion
cachedItem.partVersion = partVersion
toolEntry.toolPart = toolPart
toolEntry.messageInfo = messageInfo
newToolCache.set(toolKey, toolEntry)
items.push(cachedItem)
}
} else {
const toolItem: ToolDisplayItem = {
type: "tool",
key: toolKey,
toolPart,
messageInfo,
messageId: message.id,
messageVersion,
partVersion,
}
console.debug("[ToolCall] create", toolKey, toolPart.state?.status)
newToolCache.set(toolKey, { toolPart, messageInfo, signature: toolSignature, contentKey, item: toolItem })
items.push(toolItem)
}
}
}
messageItemCache = newMessageCache
toolItemCache = newToolCache
sessionCache.messageItemCache = messageItemCache
sessionCache.toolItemCache = toolItemCache
tokenSegments.push(`items:${items.length}`)
if (items.length > 0) {
const tail = items[items.length - 1]
if (tail.type === "message") {
tokenSegments.push(`tail:${tail.message.id}:${tail.message.version ?? 0}`)
} else {
tokenSegments.push(`tail:${tail.key}`)
}
}
return { items, token: tokenSegments.join("|") }
})
const displayItems = () => messageView().items
const changeToken = () => messageView().token
function updateScrollIndicators(element: HTMLDivElement) {
const itemsLength = displayItems().length
setShowScrollBottomButton(!isNearBottom(element) && itemsLength > 0)
setShowScrollTopButton(!isNearTop(element) && itemsLength > 0)
persistScrollState()
}
function getActiveScrollKey() {
return containerRef?.dataset.scrollKey || scrollStateKey()
}
function persistScrollState() {
if (!containerRef) return
const key = getActiveScrollKey()
messageScrollState.set(key, {
scrollTop: containerRef.scrollTop,
autoScroll: autoScroll(),
})
}
createEffect(() => {
const key = scrollStateKey()
if (containerRef) {
containerRef.dataset.scrollKey = key
}
const savedState = messageScrollState.get(key)
const shouldAutoScroll = savedState?.autoScroll ?? true
setAutoScroll(shouldAutoScroll)
requestAnimationFrame(() => {
if (!containerRef) return
if (savedState) {
if (shouldAutoScroll) {
scrollToBottom({ smooth: false })
} else {
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
containerRef.scrollTop = Math.min(savedState.scrollTop, maxScrollTop)
updateScrollIndicators(containerRef)
}
} else {
scrollToBottom({ smooth: false })
}
})
onCleanup(() => {
if (containerRef) {
messageScrollState.set(key, {
scrollTop: containerRef.scrollTop,
autoScroll: autoScroll(),
})
if (containerRef.dataset.scrollKey === key) {
delete containerRef.dataset.scrollKey
}
}
})
})
let previousToken: string | undefined
createEffect(() => {
const token = changeToken()
const shouldScroll = autoScroll()
if (!token || token === previousToken) {
return
}
previousToken = token
if (!shouldScroll) {
return
}
scrollToBottom()
})
createEffect(() => {
if (displayItems().length === 0) {
setShowScrollBottomButton(false)
setShowScrollTopButton(false)
setAutoScroll(true)
persistScrollState()
}
})
onCleanup(() => {
if (scrollAnimationFrame !== null) {
cancelAnimationFrame(scrollAnimationFrame)
}
})
return (
<div class="message-stream-container">
<div class="connection-status">
<div class="connection-status-text connection-status-info flex items-center gap-2 text-sm font-medium">
<span>{formattedSessionInfo()}</span>
</div>
<div class="connection-status-text connection-status-shortcut flex items-center gap-2 text-sm font-medium">
<span>Command Palette</span>
<Kbd shortcut="cmd+shift+p" />
</div>
<div class="connection-status-meta flex items-center justify-end gap-3">
<Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected">
<span class="status-dot" />
Connected
</span>
</Show>
<Show when={connectionStatus() === "connecting"}>
<span class="status-indicator connecting">
<span class="status-dot" />
Connecting...
</span>
</Show>
<Show when={connectionStatus() === "error" || connectionStatus() === "disconnected"}>
<span class="status-indicator disconnected">
<span class="status-dot" />
Disconnected
</span>
</Show>
</div>
</div>
<div ref={containerRef} class="message-stream" onScroll={handleScroll}>
<Show when={!props.loading && displayItems().length === 0}>
<div class="empty-state">
<div class="empty-state-content">
<div class="flex flex-col items-center gap-3 mb-6">
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
</div>
<h3>Start a conversation</h3>
<p>Type a message below or open the Command Palette:</p>
<ul>
<li>
<span>Command Palette</span>
<Kbd shortcut="cmd+shift+p" class="ml-2" />
</li>
<li>Ask about your codebase</li>
<li>
Attach files with <code>@</code>
</li>
</ul>
</div>
</div>
</Show>
<Show when={props.loading}>
<div class="loading-state">
<div class="spinner" />
<p>Loading messages...</p>
</div>
</Show>
<For each={displayItems()} fallback={null}>
{(item) => {
if (item.type === "message") {
return (
<MessageItem
message={item.message}
messageInfo={item.messageInfo}
instanceId={props.instanceId}
sessionId={props.sessionId}
isQueued={item.isQueued}
parts={item.combinedParts}
onRevert={props.onRevert}
onFork={props.onFork}
/>
)
}
const toolPart = item.toolPart
const toolState = toolPart.state
const hasToolState = isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState)
const taskSessionId =
hasToolState && typeof toolState?.metadata?.sessionId === "string"
? toolState.metadata.sessionId
: ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
const handleGoToTaskSession = (event: Event) => {
event.preventDefault()
event.stopPropagation()
if (!taskLocation) return
navigateToTaskSession(taskLocation)
}
return (
<div class="tool-call-message" data-key={item.key}>
<div class="tool-call-header-label">
<div class="tool-call-header-meta">
<span class="tool-call-icon">🔧</span>
<span>Tool Call</span>
<span class="tool-name">{toolPart?.tool || "unknown"}</span>
</div>
<Show when={taskSessionId}>
<button
class="tool-call-header-button"
type="button"
disabled={!taskLocation}
onClick={handleGoToTaskSession}
title={!taskLocation ? "Session not available yet" : "Go to session"}
>
Go to Session
</button>
</Show>
</div>
<ToolCall
toolCall={toolPart}
toolCallId={item.key}
messageId={item.messageId}
messageVersion={item.messageVersion}
partVersion={item.partVersion}
instanceId={props.instanceId}
sessionId={props.sessionId}
/>
</div>
)
}}
</For>
</div>
<Show when={showScrollTopButton() || showScrollBottomButton()}>
<div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToTop({ smooth: true })}
aria-label="Scroll to first message"
>
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
</Show>
<Show when={showScrollBottomButton()}>
<button
type="button"
class="message-scroll-button"
onClick={() => scrollToBottom({ smooth: true })}
aria-label="Scroll to latest message"
>
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
</Show>
</div>
</Show>
</div>
)
}

View File

@@ -0,0 +1,419 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getToolIcon } from "./tool-call/utils"
import { User as UserIcon, Bot as BotIcon, FoldVertical } from "lucide-solid"
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
export interface TimelineSegment {
id: string
messageId: string
type: TimelineSegmentType
label: string
tooltip: string
shortLabel?: string
variant?: "auto" | "manual"
}
interface MessageTimelineProps {
segments: TimelineSegment[]
onSegmentClick?: (segment: TimelineSegment) => void
activeMessageId?: string | null
instanceId: string
sessionId: string
showToolSegments?: boolean
}
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
user: "You",
assistant: "Asst",
tool: "Tool",
compaction: "Compaction",
}
const TOOL_FALLBACK_LABEL = "Tool Call"
const MAX_TOOLTIP_LENGTH = 220
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
interface PendingSegment {
type: TimelineSegmentType
texts: string[]
reasoningTexts: string[]
toolTitles: string[]
toolTypeLabels: string[]
toolIcons: string[]
hasPrimaryText: boolean
}
function truncateText(value: string): string {
if (value.length <= MAX_TOOLTIP_LENGTH) {
return value
}
return `${value.slice(0, MAX_TOOLTIP_LENGTH - 1).trimEnd()}`
}
function collectReasoningText(part: ClientPart): string {
const stringifySegment = (segment: unknown): string => {
if (typeof segment === "string") {
return segment
}
if (segment && typeof segment === "object") {
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
const parts: string[] = []
if (typeof obj.text === "string") {
parts.push(obj.text)
}
if (typeof obj.value === "string") {
parts.push(obj.value)
}
if (Array.isArray(obj.content)) {
parts.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
}
return parts.filter(Boolean).join("\n")
}
return ""
}
if (typeof (part as any)?.text === "string") {
return (part as any).text
}
if (Array.isArray((part as any)?.content)) {
return (part as any).content.map((entry: unknown) => stringifySegment(entry)).join("\n")
}
return ""
}
function collectTextFromPart(part: ClientPart): string {
if (!part) return ""
if (typeof (part as any).text === "string") {
return (part as any).text as string
}
if (part.type === "reasoning") {
return collectReasoningText(part)
}
if (Array.isArray((part as any)?.content)) {
return ((part as any).content as unknown[])
.map((entry) => (typeof entry === "string" ? entry : ""))
.filter(Boolean)
.join("\n")
}
if (part.type === "file") {
const filename = (part as any)?.filename
return typeof filename === "string" && filename.length > 0 ? `[File] ${filename}` : "Attachment"
}
return ""
}
function getToolTitle(part: ToolCallPart): string {
const metadata = (((part as unknown as { state?: { metadata?: unknown } })?.state?.metadata) || {}) as { title?: unknown }
const title = typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : undefined
if (title) return title
if (typeof part.tool === "string" && part.tool.length > 0) {
return part.tool
}
return TOOL_FALLBACK_LABEL
}
function getToolTypeLabel(part: ToolCallPart): string {
if (typeof part.tool === "string" && part.tool.trim().length > 0) {
return part.tool.trim().slice(0, 4)
}
return TOOL_FALLBACK_LABEL.slice(0, 4)
}
function formatTextsTooltip(texts: string[], fallback: string): string {
const combined = texts
.map((text) => text.trim())
.filter((text) => text.length > 0)
.join("\n\n")
if (combined.length > 0) {
return truncateText(combined)
}
return fallback
}
function formatToolTooltip(titles: string[]): string {
if (titles.length === 0) {
return TOOL_FALLBACK_LABEL
}
return truncateText(`${TOOL_FALLBACK_LABEL}: ${titles.join(", ")}`)
}
export function buildTimelineSegments(instanceId: string, record: MessageRecord): TimelineSegment[] {
if (!record) return []
const { orderedParts } = buildRecordDisplayData(instanceId, record)
if (!orderedParts || orderedParts.length === 0) {
return []
}
const result: TimelineSegment[] = []
let segmentIndex = 0
let pending: PendingSegment | null = null
const flushPending = () => {
if (!pending) return
if (pending.type === "assistant" && !pending.hasPrimaryText) {
pending = null
return
}
const isToolSegment = pending.type === "tool"
const label = isToolSegment
? pending.toolTypeLabels[0] || TOOL_FALLBACK_LABEL.slice(0, 4)
: SEGMENT_LABELS[pending.type]
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
const tooltip = isToolSegment
? formatToolTooltip(pending.toolTitles)
: formatTextsTooltip(
[...pending.texts, ...pending.reasoningTexts],
pending.type === "user" ? "User message" : "Assistant response",
)
result.push({
id: `${record.id}:${segmentIndex}`,
messageId: record.id,
type: pending.type,
label,
tooltip,
shortLabel,
})
segmentIndex += 1
pending = null
}
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
if (!pending || pending.type !== type) {
flushPending()
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], hasPrimaryText: type !== "assistant" }
}
return pending!
}
const defaultContentType: TimelineSegmentType = record.role === "user" ? "user" : "assistant"
for (const part of orderedParts) {
if (!part || typeof part !== "object") continue
if (part.type === "tool") {
const target = ensureSegment("tool")
const toolPart = part as ToolCallPart
target.toolTitles.push(getToolTitle(toolPart))
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
continue
}
if (part.type === "reasoning") {
const text = collectReasoningText(part)
if (text.trim().length === 0) continue
const target = ensureSegment(defaultContentType)
if (target) {
target.reasoningTexts.push(text)
}
continue
}
if (part.type === "compaction") {
flushPending()
const isAuto = Boolean((part as any)?.auto)
result.push({
id: `${record.id}:${segmentIndex}`,
messageId: record.id,
type: "compaction",
label: SEGMENT_LABELS.compaction,
tooltip: isAuto ? "Auto Compaction" : "User Compaction",
variant: isAuto ? "auto" : "manual",
})
segmentIndex += 1
continue
}
if (part.type === "step-start" || part.type === "step-finish") {
continue
}
const text = collectTextFromPart(part)
if (text.trim().length === 0) continue
const target = ensureSegment(defaultContentType)
if (target) {
target.texts.push(text)
target.hasPrimaryText = true
}
}
flushPending()
return result
}
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const buttonRefs = new Map<string, HTMLButtonElement>()
const store = () => messageStoreBus.getOrCreate(props.instanceId)
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
const [tooltipCoords, setTooltipCoords] = createSignal<{ top: number; left: number }>({ top: 0, left: 0 })
const [hoverAnchorRect, setHoverAnchorRect] = createSignal<{ top: number; left: number; width: number; height: number } | null>(null)
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
let hoverTimer: number | null = null
const showTools = () => props.showToolSegments ?? true
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
if (element) {
buttonRefs.set(segmentId, element)
} else {
buttonRefs.delete(segmentId)
}
}
const clearHoverTimer = () => {
if (hoverTimer !== null && typeof window !== "undefined") {
window.clearTimeout(hoverTimer)
hoverTimer = null
}
}
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
if (typeof window === "undefined") return
clearHoverTimer()
const target = event.currentTarget as HTMLButtonElement
hoverTimer = window.setTimeout(() => {
const rect = target.getBoundingClientRect()
setHoverAnchorRect({ top: rect.top, left: rect.left, width: rect.width, height: rect.height })
setHoveredSegment(segment)
}, 200)
}
const handleMouseLeave = () => {
clearHoverTimer()
setHoveredSegment(null)
setHoverAnchorRect(null)
}
createEffect(() => {
if (typeof window === "undefined") return
const anchor = hoverAnchorRect()
const segment = hoveredSegment()
if (!anchor || !segment) return
const { width, height } = tooltipSize()
const verticalGap = 16
const horizontalGap = 16
const preferredTop = anchor.top + anchor.height / 2 - height / 2
const maxTop = window.innerHeight - height - verticalGap
const clampedTop = Math.min(maxTop, Math.max(verticalGap, preferredTop))
const preferredLeft = anchor.left - width - horizontalGap
const clampedLeft = Math.max(horizontalGap, preferredLeft)
setTooltipCoords({ top: clampedTop, left: clampedLeft })
})
onCleanup(() => clearHoverTimer())
createEffect(() => {
const activeId = props.activeMessageId
if (!activeId) return
const targetSegment = props.segments.find((segment) => segment.messageId === activeId)
if (!targetSegment) return
const element = buttonRefs.get(targetSegment.id)
if (!element) return
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
}, 120) : null
onCleanup(() => {
if (timer !== null && typeof window !== "undefined") {
window.clearTimeout(timer)
}
})
})
createEffect(() => {
const element = tooltipElement()
if (!element || typeof window === "undefined") return
const updateSize = () => {
const rect = element.getBoundingClientRect()
setTooltipSize({ width: rect.width, height: rect.height })
}
updateSize()
if (typeof ResizeObserver === "undefined") return
const observer = new ResizeObserver(() => updateSize())
observer.observe(element)
onCleanup(() => observer.disconnect())
})
const previewData = createMemo(() => {
const segment = hoveredSegment()
if (!segment) return null
const record = store().getMessage(segment.messageId)
if (!record) return null
return { messageId: segment.messageId }
})
return (
<div class="message-timeline" role="navigation" aria-label="Message timeline">
<For each={props.segments}>
{(segment) => {
onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeMessageId === segment.messageId
const isHidden = () => segment.type === "tool" && !(showTools() || isActive())
const shortLabelContent = () => {
if (segment.type === "tool") {
return segment.shortLabel ?? getToolIcon("tool")
}
if (segment.type === "compaction") {
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
}
if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
}
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
}
return (
<button
ref={(el) => registerButtonRef(segment.id, el)}
type="button"
data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
aria-current={isActive() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}
onClick={() => props.onSegmentClick?.(segment)}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
</button>
)
}}
</For>
<Show when={previewData()}>
{(data) => {
onCleanup(() => setTooltipElement(null))
return (
<div
ref={(element) => setTooltipElement(element)}
class="message-timeline-tooltip"
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
>
<MessagePreview
messageId={data().messageId}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
/>
</div>
)
}}
</Show>
</div>
)
}
export default MessageTimeline

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