Compare commits

...

247 Commits

Author SHA1 Message Date
Shantur Rathore
bd2a0d1bec Bump version to 0.7.2 2026-01-15 20:55:59 +00:00
Shantur Rathore
df9722cd16 fix(server): run background processes via cmd.exe on Windows 2026-01-15 20:53:13 +00:00
Shantur Rathore
dffa4907ec fix(server): validate OpenCode binary by spawning --version 2026-01-15 20:47:30 +00:00
Shantur Rathore
e567d35438 fix(server): prefer .exe/.cmd candidates on Windows 2026-01-15 20:45:14 +00:00
Shantur Rathore
62f52fc534 fix(server): spawn opencode shims via Windows shells 2026-01-15 20:43:40 +00:00
Shantur Rathore
69f221942c Bump version to 0.7.1 2026-01-15 08:39:06 +00:00
Shantur Rathore
7749225f71 fix(ui): restore pasted text expand controls\n\nFixes #67 2026-01-15 08:36:56 +00:00
Shantur Rathore
ae322c53cc fix(ui): correct Go to Session navigation across instances 2026-01-15 08:26:49 +00:00
Shantur Rathore
37da426ab4 Bump version to 0.7.0 2026-01-14 21:36:45 +00:00
Shantur Rathore
591f55bef9 fix(ui): render prompt attachments above input 2026-01-14 21:35:18 +00:00
Shantur Rathore
aabaadbe1d fix(ui): expand prompt via rows, keep placeholder padding 2026-01-14 21:28:04 +00:00
Shantur Rathore
3ab14e8de6 Merge pull request #62 from bizzkoot/feat/expand-chat-input
feat: Implement expandable chat input with double-click detection and gradient tooltip
2026-01-14 18:22:56 +00:00
Shantur Rathore
40634138bc feat(server): add authenticated remote access and desktop bootstrap
Adds cookie-based login with a bootstrap token flow for desktop apps, secures OpenCode instance traffic with per-instance Basic auth, and updates UI/plugin clients to use credentials.
2026-01-14 18:18:14 +00:00
bizzkoot
b17087b610 refactor: remove mobile-specific placeholder text for simplicity
- Remove isMobileWidth signal and updateMobileWidth resize listener
- Use same placeholder text for all devices/platforms
- "Type your message, @file, @agent, or paste images and text..."

Simplifies implementation per dev feedback - one approach for all
2026-01-13 06:48:33 +08:00
bizzkoot
71f58e7c5f refactor: simplify expand chat input to 2-state with optimized button layout
- Remove 3-state logic (normal/50%/80%) - now only normal/expanded
- Remove double-click detection and tooltips for simplicity
- Remove platform-specific behavior (same UX for Electron and web)
- Optimize button layout: reduce from 36px to 28px to fit 3 buttons
- Position expand button above history buttons in vertical stack
- Keep 15-line expanded height (360px, capped to available space)

Per upstream dev feedback to keep it simple with one approach
2026-01-13 06:45:56 +08:00
Shantur Rathore
927e4e1281 perf(ui): reduce session list churn and message block invalidation 2026-01-12 16:37:09 +00:00
bizzkoot
2e56a5e9f4 feat: implement platform-specific expand chat input with mobile optimizations
- Add platform detection (Electron vs Web) for expand behavior
  - Electron: 3-state (normal → 50% → 80%) with double-click
  - Web/Mobile: 2-state (normal → expanded) with instant single tap
- Implement fixed 15-line height for web/mobile (360px, capped)
- Add orientation-aware height calculation (landscape vs portrait)
- Remove tooltip on web/mobile, keep for Electron desktop
- Add responsive placeholder text to prevent overlap on mobile
  - Desktop: "Type your message, @file, @agent, or paste images and text..."
  - Mobile (≤640px): "Type message, @file, @agent..."
- Delete dev-docs/expand-chat-input.md per upstream feedback

Addresses PR feedback to simplify from 3-state to 2-state for web/mobile
while maintaining rich desktop experience in Electron app.
2026-01-12 20:40:19 +08:00
bizzkoot
296d07a0d6 Move expand chat input doc to dev-docs and remove empty plans folder 2026-01-12 05:24:24 +08:00
bizzkoot
0d8a844af8 feat: implement expandable chat input with double-click detection and gradient tooltip
- Add expand button with Maximize2/Minimize2 icons
- Implement 3-state height management (normal/50%/80%)
- Smart double-click detection with 300ms delay
- Height calculation based on session-view - 200px message space
- Custom CSS tooltip with dark gradient background and backdrop blur
- Send button anchored at bottom via margin-top: auto
- Smooth CSS transitions throughout
- Double-click at 80% now reduces to 50% (not normal)
- Removed all debug console.log statements
2026-01-11 21:59:28 +08:00
bizzkoot
bf9cef4cd5 docs: add expand chat input implementation plan 2026-01-11 20:17:19 +08:00
bizzkoot
9dde33aba7 style: add expand button positioning and styles 2026-01-11 20:09:13 +08:00
bizzkoot
0fefff3b0a feat: integrate ExpandButton and apply dynamic height to textarea 2026-01-11 20:07:48 +08:00
bizzkoot
1122c19648 feat: create ExpandButton component with click/double-click logic 2026-01-11 20:05:16 +08:00
bizzkoot
f06359a1fc feat: add expand state signal and height calculation helpers 2026-01-11 20:04:25 +08:00
Shantur Rathore
72f420b6f6 feat(ui): support question tool requests
Add question queue hydration, inline answering UI, and unify pending requests with permissions.
2026-01-10 09:46:23 +00:00
Shantur Rathore
147c9e3e4b Bump version to 0.6.0 2026-01-09 21:46:14 +00:00
Shantur Rathore
ab38cdccac fix(ui): simplify prompt input hints 2026-01-09 21:10:00 +00:00
Shantur Rathore
8168d52295 chore(config): bump @opencode-ai/plugin
Update opencode config plugin dependency to 1.1.8.
2026-01-09 18:56:51 +00:00
Shantur Rathore
1081bfb276 fix(ui): keep session view after delete
When deleting the active session from the sidebar list, automatically select a nearby visible session instead of falling back to the info view.
2026-01-09 18:53:00 +00:00
Shantur Rathore
38064b229c ui: move instance info to drawer header
Remove the Instance section from the session list and replace it with an info icon button in the left drawer header.
2026-01-09 17:30:53 +00:00
Shantur Rathore
1a7aefcbae feat(ui): session nav follows visible list
Cmd+Shift+[ and Cmd+Shift+] now cycle through visible sessions only (parents + expanded children) and no longer include Instance Info. Sidebar session list auto-scrolls to keep the active session row in view.
2026-01-09 16:34:44 +00:00
Shantur Rathore
e50d9f461a feat(ui): thread sessions in sidebar list
Show sessions as parent/child threads with expand/collapse and improved agent row styling. Keep a 5-session cache to avoid refetching messages when switching between recently visited sessions.
2026-01-09 16:02:53 +00:00
Shantur Rathore
d76cf8a3f7 Merge pull request #56 from bizzkoot/feat/centralized-permission-notifications
feat: Add Centralized Permission Notification System
2026-01-08 23:19:29 +00:00
bizzkoot
c7370fe7bc fix(ui): resolve multi-line border issue in permission notification cards
- Removed box-shadow from active permission items to eliminate double-border effect
- Added CSS rule to remove borders from nested ToolCall components within permission items
- Ensures consistent single-border styling aligned with existing design system
2026-01-09 06:02:58 +08:00
Shantur Rathore
3dfbe2a5b2 docs: add Linux NVIDIA Wayland workaround for Tauri AppImage 2026-01-08 20:43:11 +00:00
Shantur Rathore
e30c8b0253 fix(ui): auto-close permission center when queue empty 2026-01-08 20:25:54 +00:00
Shantur Rathore
df9fc529f9 feat(ui): rework permission center to reuse tool call view 2026-01-08 20:15:09 +00:00
Shantur Rathore
2e9f5b916c Merge remote-tracking branch 'origin/dev' into feat/centralized-permission-notifications 2026-01-08 20:09:42 +00:00
Shantur Rathore
fd464f349a ui: show permission-blocked tool calls in timeline 2026-01-08 19:16:39 +00:00
Shantur Rathore
ff6d6f4f76 Remove custom commands from palette 2026-01-08 17:52:55 +00:00
Shantur Rathore
cb2966fb08 Add slash command prompt support 2026-01-08 17:41:29 +00:00
bizzkoot
888e365d72 feat: enhance permission modal with tool details, queue nav, session nav, and responsive design
Modal Enhancements:
- Add accurate tool name extraction from message store (same method as inline chat)
- Display 'Tool Call [name]' badge (e.g., 'Tool Call read', 'Tool Call write')
- Add 'Go to Session ↗' button to navigate to originating session
- Add Prev/Next buttons for queue navigation with keyboard shortcuts (←/→)
- Add queue counter showing current position

Responsive Web Design:
- Portrait phones: 90vh max-height with safe margins (avoids browser URL bar)
- Landscape phones: 95vw with 50vh body scroll
- Tablets: adaptive layout
- Touch devices: 44-48px touch targets

Technical Changes:
- Import messageStoreBus for tool part lookup
- Query linked part.tool via permission messageId/callId
- Export setActivePermissionIdForInstance for queue navigation
- Add tool badge CSS styling
2026-01-08 06:38:59 +08:00
Shantur Rathore
e9241a1b93 Ensure child processes are stopped 2026-01-07 19:35:33 +00:00
bizzkoot
f01a06d85b feat: add centralized permission notification system for agent/subagent requests
Implements a unified permission notification UI that adapts to different runtime
environments (Electron desktop vs web browser) with distinct visual presentations.

## What Changed

### New Components
- `permission-notification-banner.tsx`: Adaptive notification component
  * Electron (desktop): Full banner with "⚠️ Approval Required" text and count badge
  * Web browser (portrait): Circular indicator badge showing pending count
- `permission-approval-modal.tsx`: Interactive modal for reviewing/approving permissions
  * Displays permission type, detailed message, and diff viewer for file changes
  * Keyboard shortcuts: Enter (allow once), A (always), D (deny), Esc (close)
  * Queue management with "X of Y" counter for multiple pending permissions
- `permission-notification.css`: Comprehensive styling with pulsing animations

### Integration
- Updated `instance-shell2.tsx`:
  * Added banner to desktop center toolbar (next to Command Palette)
  * Added banner to mobile/phone layout center section
  * Added modal component for permission approval workflow
- Updated `controls.css`: Imported new permission notification styles

## Why This Change

**Before**: Permission requests had no visual indicator in the UI, making it
difficult for users to know when agent/subagent actions required approval.

**After**: Users receive clear, persistent visual notifications with:
- Pulsing animation to draw attention
- Environment-appropriate UI (full banner on desktop, compact badge on web)
- Click-to-review workflow with full permission details

## Benefits

1. **Better UX**: Users immediately see when permissions need approval
2. **Responsive Design**: Adapts to desktop (Electron) and web browser contexts
3. **Accessible**: Proper ARIA labels, keyboard shortcuts, and focus management
4. **Queue Management**: Handles multiple pending permissions gracefully
5. **Contextual Information**: Shows diffs for file changes, permission types, etc.

## Impact

- **No Breaking Changes**: Purely additive feature
- **Build**:  Verified successful build
- **Testing**:  Tested in Electron app and web browser
2026-01-07 21:44:43 +08:00
Shantur Rathore
a68285da68 Merge pull request #54 from NeuralNomadsAI/dev
Release v0.5.1 - Build fixes
2026-01-07 09:48:46 +00:00
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
31940f972f Merge pull request #53 from NeuralNomadsAI/dev
Release v0.5.0
2026-01-06 19:40:17 +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
b4663fb250 Merge pull request #44 from NeuralNomadsAI/dev
Release 0.4.0
2025-12-15 16:46:29 +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
202 changed files with 19069 additions and 5620 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,21 +4,33 @@ 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:
id-token: write
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
@@ -41,10 +53,11 @@ 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
@@ -53,6 +66,7 @@ jobs:
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
@@ -79,11 +93,12 @@ 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
@@ -92,6 +107,7 @@ jobs:
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" -Filter *.zip -File | ForEach-Object {
@@ -116,10 +132,11 @@ 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-linux-x64-gnu --no-save
@@ -128,6 +145,7 @@ jobs:
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
@@ -157,18 +175,38 @@ jobs:
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
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)
run: npm run build --workspace @codenomad/tauri-app
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"
@@ -180,6 +218,7 @@ jobs:
fi
- name: Upload Tauri release assets (macOS)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -209,18 +248,38 @@ jobs:
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
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)
run: npm run build --workspace @codenomad/tauri-app
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"
@@ -232,6 +291,7 @@ jobs:
fi
- name: Upload Tauri release assets (macOS arm64)
if: ${{ inputs.upload && inputs.tag != '' }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -261,19 +321,41 @@ jobs:
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
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)
run: npm run build --workspace @codenomad/tauri-app
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"
@@ -287,6 +369,7 @@ jobs:
}
- name: Upload Tauri release assets (Windows)
if: ${{ inputs.upload && inputs.tag != '' }}
shell: pwsh
run: |
if (Test-Path "packages/tauri-app/release-tauri") {
@@ -329,18 +412,38 @@ jobs:
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
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)
run: npm run build --workspace @codenomad/tauri-app
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"
@@ -367,6 +470,7 @@ jobs:
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
@@ -429,7 +533,7 @@ jobs:
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-arm64-gnu --no-save
@@ -497,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
@@ -509,6 +614,7 @@ 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

View File

@@ -1,67 +1,18 @@
name: Dev Release
name: Dev CI
on:
push:
branches:
- dev
workflow_dispatch:
permissions:
id-token: write
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 }}
secrets: inherit
publish-server:
needs:
- prepare-dev
- build-and-upload
uses: ./.github/workflows/manual-npm-publish.yml
with:
version: ${{ needs.prepare-dev.outputs.version }}
dist_tag: dev
upload: false
set_versions: false
secrets: inherit

View File

@@ -9,77 +9,9 @@ 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 }}
secrets: inherit
publish-server:
needs:
- prepare-release
- build-and-upload
if: ${{ needs.build-and-upload.result == 'success' }}
uses: ./.github/workflows/manual-npm-publish.yml
with:
version: ${{ needs.prepare-release.outputs.version }}
dist_tag: latest
secrets: inherit

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

3
.gitignore vendored
View File

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

View File

@@ -44,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
@@ -58,6 +64,41 @@ 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.
### Linux (Wayland + NVIDIA): Tauri AppImage closes immediately
On some Wayland compositor + NVIDIA driver setups, WebKitGTK can fail to initialize its DMA-BUF/GBM path and the Tauri build may exit right away.
Try running with one of these environment variables:
```bash
# Most reliable workaround (can reduce rendering performance)
WEBKIT_DISABLE_DMABUF_RENDERER=1 codenomad
# Alternative for some Wayland setups
__NV_DISABLE_EXPLICIT_SYNC=1 codenomad
```
If you're running the Tauri AppImage and want the workaround applied every time, create a tiny wrapper script on your `PATH`:
```bash
#!/bin/bash
export WEBKIT_DISABLE_DMABUF_RENDERER=1
exec ~/.local/share/bauh/appimage/installed/codenomad/CodeNomad-Tauri-0.4.0-linux-x64.AppImage "$@"
```
Upstream tracking: https://github.com/tauri-apps/tauri/issues/10702
## 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

@@ -35,7 +35,7 @@ CodeNomad is a cross-platform desktop application built with Electron that provi
│ │ │ UI Components │ │ │
│ │ │ - InstanceTabs │ │ │
│ │ │ - SessionTabs │ │ │
│ │ │ - MessageStreamV2 │ │ │
│ │ │ - MessageSection │ │ │
│ │ │ - PromptInput │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │

1834
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.7",
"version": "0.7.2",
"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

@@ -53,12 +53,19 @@ export default defineConfig({
port: 3000,
},
build: {
minify: false,
cssMinify: false,
sourcemap: true,
outDir: resolve(__dirname, "dist/renderer"),
rollupOptions: {
input: {
main: uiRendererEntry,
loading: uiRendererLoadingEntry,
},
output: {
compact: false,
minifyInternalExports: false,
},
},
},
},

View File

@@ -34,6 +34,12 @@ 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"]

View File

@@ -1,4 +1,6 @@
import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron"
import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron"
import http from "node:http"
import https from "node:https"
import { existsSync } from "fs"
import { dirname, join } from "path"
import { fileURLToPath } from "url"
@@ -15,6 +17,7 @@ const cliManager = new CliProcessManager()
let mainWindow: BrowserWindow | null = null
let currentCliUrl: string | null = null
let pendingCliUrl: string | null = null
let pendingBootstrapToken: string | null = null
let showingLoadingScreen = false
let preloadingView: BrowserView | null = null
@@ -89,6 +92,56 @@ function loadLoadingScreen(window: BrowserWindow) {
})
}
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))
}
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
function getPreloadPath() {
if (cachedPreloadPath && existsSync(cachedPreloadPath)) {
@@ -153,6 +206,8 @@ function createWindow() {
},
})
setupNavigationGuards(mainWindow)
if (isMac) {
mainWindow.webContents.session.setSpellCheckerEnabled(false)
}
@@ -199,6 +254,15 @@ function showLoadingScreen(force = false) {
loadLoadingScreen(mainWindow)
}
function isBootstrapTokenUrl(url: string): boolean {
try {
const parsed = new URL(url)
return parsed.pathname === "/auth/token" && parsed.hash.length > 1
} catch {
return false
}
}
function startCliPreload(url: string) {
if (!mainWindow || mainWindow.isDestroyed()) {
pendingCliUrl = url
@@ -216,6 +280,13 @@ function startCliPreload(url: string) {
showLoadingScreen(true)
}
// Important: /auth/token#... is one-time. Preloading + swapping would load it twice,
// consuming the token in the hidden view and then failing in the main window.
if (isBootstrapTokenUrl(url)) {
finalizeCliSwap(url)
return
}
const view = new BrowserView({
webPreferences: {
contextIsolation: true,
@@ -256,6 +327,75 @@ function finalizeCliSwap(url: string) {
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
}
const SESSION_COOKIE_NAME = "codenomad_session"
let bootstrapExchangeInFlight = false
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader
if (!raw) return null
const first = raw.split(";")[0] ?? ""
const index = first.indexOf("=")
if (index < 0) return null
const key = first.slice(0, index).trim()
const value = first.slice(index + 1).trim()
if (key !== name || !value) return null
try {
return decodeURIComponent(value)
} catch {
return value
}
}
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
const target = new URL("/api/auth/token", baseUrl)
const body = JSON.stringify({ token })
const transport = target.protocol === "https:" ? https : http
const result = await new Promise<{ statusCode: number; setCookie: string | string[] | undefined }>((resolve, reject) => {
const req = transport.request(
target,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
},
},
(res) => {
res.resume()
resolve({ statusCode: res.statusCode ?? 0, setCookie: res.headers["set-cookie"] })
},
)
req.on("error", reject)
req.write(body)
req.end()
})
if (result.statusCode !== 200) {
return false
}
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
if (!sessionId) {
return false
}
await session.defaultSession.cookies.set({
url: baseUrl,
name: SESSION_COOKIE_NAME,
value: sessionId,
httpOnly: true,
path: "/",
sameSite: "lax",
})
return true
}
async function startCli() {
try {
@@ -271,11 +411,53 @@ async function startCli() {
}
}
async function maybeExchangeAndNavigate(baseUrl: string) {
if (bootstrapExchangeInFlight) {
return
}
const token = pendingBootstrapToken
if (!token) {
startCliPreload(baseUrl)
return
}
bootstrapExchangeInFlight = true
try {
const ok = await exchangeBootstrapToken(baseUrl, token)
pendingBootstrapToken = null
if (!ok) {
startCliPreload(`${baseUrl}/login`)
return
}
startCliPreload(baseUrl)
} catch (error) {
console.error("[cli] bootstrap token exchange failed:", error)
pendingBootstrapToken = null
startCliPreload(`${baseUrl}/login`)
} finally {
bootstrapExchangeInFlight = false
}
}
cliManager.on("bootstrapToken", (token) => {
pendingBootstrapToken = token
const status = cliManager.getStatus()
if (status.url) {
void maybeExchangeAndNavigate(status.url)
}
})
cliManager.on("ready", (status) => {
if (!status.url) {
return
}
startCliPreload(status.url)
void maybeExchangeAndNavigate(status.url)
})
cliManager.on("status", (status) => {

View File

@@ -2,14 +2,17 @@ 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"
const nodeRequire = createRequire(import.meta.url)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
type CliState = "starting" | "ready" | "error" | "stopped"
type ListeningMode = "local" | "all"
export interface CliStatus {
state: CliState
@@ -34,9 +37,40 @@ 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
on(event: "bootstrapToken", listener: (token: string) => void): this
on(event: "log", listener: (entry: CliLogEntry) => void): this
on(event: "exit", listener: (status: CliStatus) => void): this
on(event: "error", listener: (error: Error) => void): this
@@ -47,6 +81,7 @@ export class CliProcessManager extends EventEmitter {
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
private bootstrapToken: string | null = null
async start(options: StartOptions): Promise<CliStatus> {
if (this.child) {
@@ -55,13 +90,16 @@ export class CliProcessManager extends EventEmitter {
this.stdoutBuffer = ""
this.stderrBuffer = ""
this.bootstrapToken = null
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 +154,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 +196,10 @@ export class CliProcessManager extends EventEmitter {
return { ...this.status }
}
private resolveListeningMode(): ListeningMode {
return readListeningModeFromConfig()
}
private handleTimeout() {
if (this.child) {
this.child.kill("SIGKILL")
@@ -189,11 +231,22 @@ export class CliProcessManager extends EventEmitter {
}
for (const line of lines) {
if (!line.trim()) continue
console.info(`[cli][${stream}] ${line}`)
this.emit("log", { stream, message: line })
const trimmed = line.trim()
if (!trimmed) continue
const port = this.extractPort(line)
if (trimmed.startsWith(BOOTSTRAP_TOKEN_PREFIX)) {
const token = trimmed.slice(BOOTSTRAP_TOKEN_PREFIX.length).trim()
if (token && !this.bootstrapToken) {
this.bootstrapToken = token
this.emit("bootstrapToken", token)
}
continue
}
console.info(`[cli][${stream}] ${trimmed}`)
this.emit("log", { stream, message: trimmed })
const port = this.extractPort(trimmed)
if (port && this.status.state === "starting") {
const url = `http://127.0.0.1:${port}`
console.info(`[cli] ready on ${url}`)
@@ -232,8 +285,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", "--generate-token"]
if (options.dev) {
args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug")

View File

@@ -10,6 +10,7 @@ const electronAPI = {
return () => ipcRenderer.removeAllListeners("cli:error")
},
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
restartCli: () => ipcRenderer.invoke("cli:restart"),
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
}

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.2.7",
"version": "0.7.2",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",
@@ -69,6 +69,10 @@
"!icon.icns",
"!icon.ico"
]
},
{
"from": "../server/dist/opencode-config",
"to": "opencode-config"
}
],
"mac": {

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))
@@ -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.16"
}
}

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,253 @@
import path from "path"
import { tool } from "@opencode-ai/plugin/tool"
import { createCodeNomadRequester, type CodeNomadConfig } from "./request"
type BackgroundProcess = {
id: string
title: string
command: string
status: "running" | "stopped" | "error"
startedAt: string
stoppedAt?: string
exitCode?: number
outputSizeBytes?: number
}
type BackgroundProcessOptions = {
baseDir: string
}
type ParsedCommand = {
head: string
args: string[]
}
export function createBackgroundProcessTools(config: CodeNomadConfig, options: BackgroundProcessOptions) {
const requester = createCodeNomadRequester(config)
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
return requester.requestJson<T>(`/background-processes${path}`, init)
}
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()
tokens.push(char)
continue
}
current += char
}
flush()
return tokens
}
function isSeparator(token: string): boolean {
return token === "|" || token === "&" || token === ";"
}
function unquote(token: string): string {
if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
return token.slice(1, -1)
}
return token
}
function isWithinBase(base: string, candidate: string): boolean {
const relative = path.relative(base, candidate)
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}

View File

@@ -0,0 +1,133 @@
import { createCodeNomadRequester, type CodeNomadConfig, type PluginEvent } from "./request"
export { getCodeNomadConfig, type CodeNomadConfig, type PluginEvent } from "./request"
export function createCodeNomadClient(config: CodeNomadConfig) {
const requester = createCodeNomadRequester(config)
return {
postEvent: (event: PluginEvent) =>
requester.requestVoid("/event", {
method: "POST",
body: JSON.stringify(event),
}),
startEvents: (onEvent: (event: PluginEvent) => void) => startPluginEvents(requester, onEvent),
}
}
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms))
}
async function startPluginEvents(
requester: ReturnType<typeof createCodeNomadRequester>,
onEvent: (event: PluginEvent) => void,
) {
// Fail plugin startup if we cannot establish the initial connection.
const initialBody = await connectWithRetries(requester, 3)
// After startup, keep reconnecting; throw after 3 consecutive failures.
void consumeWithReconnect(requester, onEvent, initialBody)
}
async function connectWithRetries(requester: ReturnType<typeof createCodeNomadRequester>, maxAttempts: number) {
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await requester.requestSseBody("/events")
} catch (error) {
lastError = error
await delay(500 * attempt)
}
}
const reason = lastError instanceof Error ? lastError.message : String(lastError)
const url = requester.buildUrl("/events")
throw new Error(`[CodeNomadPlugin] Failed to connect to CodeNomad at ${url} after ${maxAttempts} retries: ${reason}`)
}
async function consumeWithReconnect(
requester: ReturnType<typeof createCodeNomadRequester>,
onEvent: (event: PluginEvent) => void,
initialBody: ReadableStream<Uint8Array>,
) {
let consecutiveFailures = 0
let body: ReadableStream<Uint8Array> | null = initialBody
while (true) {
try {
if (!body) {
body = await connectWithRetries(requester, 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

@@ -0,0 +1,124 @@
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 createCodeNomadRequester(config: CodeNomadConfig) {
const baseUrl = config.baseUrl.replace(/\/+$/, "")
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
const authorization = buildInstanceAuthorizationHeader()
const buildUrl = (path: string) => {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path
}
const normalized = path.startsWith("/") ? path : `/${path}`
return `${pluginBase}${normalized}`
}
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
const output: Record<string, string> = normalizeHeaders(headers)
output.Authorization = authorization
if (hasBody) {
output["Content-Type"] = output["Content-Type"] ?? "application/json"
}
return output
}
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
const url = buildUrl(path)
const hasBody = init?.body !== undefined
const headers = buildHeaders(init?.headers, hasBody)
return fetch(url, {
...init,
headers,
})
}
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetchWithAuth(path, init)
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed with ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
const response = await fetchWithAuth(path, init)
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Request failed with ${response.status}`)
}
}
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
if (!response.ok || !response.body) {
throw new Error(`SSE unavailable (${response.status})`)
}
return response.body as ReadableStream<Uint8Array>
}
return {
buildUrl,
fetch: fetchWithAuth,
requestJson,
requestVoid,
requestSseBody,
}
}
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 buildInstanceAuthorizationHeader(): string {
const username = requireEnv("OPENCODE_SERVER_USERNAME")
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
return `Basic ${token}`
}
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

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

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.2.7",
"version": "0.7.2",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",
@@ -16,10 +16,11 @@
"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 && node ./scripts/copy-auth-pages.mjs && 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 CODENOMAD_SERVER_PASSWORD=codenomad-dev CLI_UI_DEV_SERVER=http://localhost:3000 tsx src/index.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env node
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, "src/server/routes/auth-pages")
const targetDir = path.resolve(cliRoot, "dist/server/routes/auth-pages")
if (!existsSync(sourceDir)) {
console.error(`[copy-auth-pages] Missing auth pages at ${sourceDir}`)
process.exit(1)
}
rmSync(targetDir, { recursive: true, force: true })
mkdirSync(targetDir, { recursive: true })
cpSync(sourceDir, targetDir, { recursive: true })
console.log(`[copy-auth-pages] Copied ${sourceDir} -> ${targetDir}`)

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

@@ -167,6 +167,7 @@ export type WorkspaceEventType =
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
| "app.releaseAvailable"
export type WorkspaceEventPayload =
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
@@ -179,16 +180,70 @@ export type WorkspaceEventPayload =
| { 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,175 @@
import fs from "fs"
import path from "path"
import type { Logger } from "../logger"
import { hashPassword, type PasswordHashRecord, verifyPassword } from "./password-hash"
export interface AuthFile {
version: 1
username: string
password: PasswordHashRecord
userProvided: boolean
updatedAt: string
}
export interface AuthStatus {
username: string
passwordUserProvided: boolean
}
export class AuthStore {
private cachedFile: AuthFile | null = null
private overrideAuth: AuthFile | null = null
private bootstrapUsername: string | null = null
constructor(private readonly authFilePath: string, private readonly logger: Logger) {}
getAuthFilePath() {
return this.authFilePath
}
load(): AuthFile | null {
if (this.overrideAuth) {
return this.overrideAuth
}
if (this.cachedFile) {
return this.cachedFile
}
try {
if (!fs.existsSync(this.authFilePath)) {
return null
}
const raw = fs.readFileSync(this.authFilePath, "utf-8")
const parsed = JSON.parse(raw) as AuthFile
if (!parsed || parsed.version !== 1) {
this.logger.warn({ authFilePath: this.authFilePath }, "Auth file has unsupported version")
return null
}
this.cachedFile = parsed
return parsed
} catch (error) {
this.logger.warn({ err: error, authFilePath: this.authFilePath }, "Failed to load auth file")
return null
}
}
ensureInitialized(params: {
username: string
password?: string
allowBootstrapWithoutPassword: boolean
}): void {
const password = params.password?.trim()
if (password) {
const now = new Date().toISOString()
const runtime: AuthFile = {
version: 1,
username: params.username,
password: hashPassword(password),
userProvided: true,
updatedAt: now,
}
this.overrideAuth = runtime
this.cachedFile = null
this.bootstrapUsername = null
this.logger.debug({ authFilePath: this.authFilePath }, "Using runtime auth password override; ignoring auth file")
return
}
const existing = this.load()
if (existing) {
if (existing.username !== params.username) {
// Keep existing username unless explicitly overridden later.
this.logger.debug({ existing: existing.username, requested: params.username }, "Auth username differs from requested")
}
this.bootstrapUsername = null
return
}
if (params.allowBootstrapWithoutPassword) {
this.bootstrapUsername = params.username
this.logger.debug({ authFilePath: this.authFilePath }, "No auth file present; bootstrap-only mode enabled")
return
}
throw new Error(
`No server password configured. Create ${this.authFilePath} or start with --password / CODENOMAD_SERVER_PASSWORD.`,
)
}
validateCredentials(username: string, password: string): boolean {
const auth = this.load()
if (!auth) {
return false
}
if (username !== auth.username) {
return false
}
return verifyPassword(password, auth.password)
}
setPassword(params: { password: string; markUserProvided: boolean }): AuthStatus {
if (this.overrideAuth) {
throw new Error(
"Server password is provided via CLI/env and cannot be changed while running. Restart without --password / CODENOMAD_SERVER_PASSWORD to use auth.json.",
)
}
const current = this.load()
if (!current) {
if (!this.bootstrapUsername) {
throw new Error("Auth is not initialized")
}
const created: AuthFile = {
version: 1,
username: this.bootstrapUsername,
password: hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}
this.persist(created)
this.bootstrapUsername = null
return { username: created.username, passwordUserProvided: created.userProvided }
}
const next: AuthFile = {
...current,
password: hashPassword(params.password),
userProvided: params.markUserProvided,
updatedAt: new Date().toISOString(),
}
this.persist(next)
return { username: next.username, passwordUserProvided: next.userProvided }
}
getStatus(): AuthStatus {
const current = this.load()
if (current) {
return { username: current.username, passwordUserProvided: current.userProvided }
}
if (this.bootstrapUsername) {
return { username: this.bootstrapUsername, passwordUserProvided: false }
}
throw new Error("Auth is not initialized")
}
private persist(auth: AuthFile) {
try {
fs.mkdirSync(path.dirname(this.authFilePath), { recursive: true })
fs.writeFileSync(this.authFilePath, JSON.stringify(auth, null, 2), "utf-8")
this.cachedFile = auth
this.logger.debug({ authFilePath: this.authFilePath }, "Persisted auth file")
} catch (error) {
this.logger.error({ err: error, authFilePath: this.authFilePath }, "Failed to persist auth file")
throw error
}
}
}

View File

@@ -0,0 +1,38 @@
import type { FastifyReply, FastifyRequest } from "fastify"
export function parseCookies(header: string | undefined): Record<string, string> {
const result: Record<string, string> = {}
if (!header) return result
const parts = header.split(";")
for (const part of parts) {
const index = part.indexOf("=")
if (index < 0) continue
const key = part.slice(0, index).trim()
const value = part.slice(index + 1).trim()
if (!key) continue
result[key] = decodeURIComponent(value)
}
return result
}
export function isLoopbackAddress(remoteAddress: string | undefined): boolean {
if (!remoteAddress) return false
if (remoteAddress === "127.0.0.1" || remoteAddress === "::1") return true
if (remoteAddress === "::ffff:127.0.0.1") return true
return false
}
export function wantsHtml(request: FastifyRequest): boolean {
const accept = (request.headers["accept"] ?? "").toString().toLowerCase()
return accept.includes("text/html") || accept.includes("application/xhtml")
}
export function sendUnauthorized(request: FastifyRequest, reply: FastifyReply) {
if (request.method === "GET" && !request.url.startsWith("/api/") && wantsHtml(request)) {
reply.redirect("/login")
return
}
reply.code(401).send({ error: "Unauthorized" })
}

View File

@@ -0,0 +1,113 @@
import type { FastifyReply, FastifyRequest } from "fastify"
import path from "path"
import type { Logger } from "../logger"
import { AuthStore } from "./auth-store"
import { TokenManager } from "./token-manager"
import { SessionManager } from "./session-manager"
import { isLoopbackAddress, parseCookies } from "./http-auth"
export const BOOTSTRAP_TOKEN_STDOUT_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" as const
export const DEFAULT_AUTH_USERNAME = "codenomad" as const
export const DEFAULT_AUTH_COOKIE_NAME = "codenomad_session" as const
export interface AuthManagerInit {
configPath: string
username: string
password?: string
generateToken: boolean
}
export class AuthManager {
private readonly authStore: AuthStore
private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
const authFilePath = resolveAuthFilePath(init.configPath)
this.authStore = new AuthStore(authFilePath, logger.child({ component: "auth" }))
// Startup: password comes from CLI/env, auth.json, or bootstrap-only mode.
this.authStore.ensureInitialized({
username: init.username,
password: init.password,
allowBootstrapWithoutPassword: init.generateToken,
})
this.tokenManager = init.generateToken ? new TokenManager(60_000) : null
}
getCookieName(): string {
return this.cookieName
}
isTokenBootstrapEnabled(): boolean {
return Boolean(this.tokenManager)
}
issueBootstrapToken(): string | null {
if (!this.tokenManager) return null
return this.tokenManager.generate()
}
consumeBootstrapToken(token: string): boolean {
if (!this.tokenManager) return false
return this.tokenManager.consume(token)
}
validateLogin(username: string, password: string): boolean {
return this.authStore.validateCredentials(username, password)
}
createSession(username: string) {
return this.sessionManager.createSession(username)
}
getStatus() {
return this.authStore.getStatus()
}
setPassword(password: string) {
return this.authStore.setPassword({ password, markUserProvided: true })
}
isLoopbackRequest(request: FastifyRequest): boolean {
return isLoopbackAddress(request.socket.remoteAddress)
}
getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null {
const cookies = parseCookies(request.headers.cookie)
const sessionId = cookies[this.cookieName]
const session = this.sessionManager.getSession(sessionId)
if (!session) return null
return { username: session.username, sessionId: session.id }
}
setSessionCookie(reply: FastifyReply, sessionId: string) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, sessionId))
}
clearSessionCookie(reply: FastifyReply) {
reply.header("Set-Cookie", buildSessionCookie(this.cookieName, "", { maxAgeSeconds: 0 }))
}
}
function resolveAuthFilePath(configPath: string) {
const resolvedConfigPath = resolvePath(configPath)
return path.join(path.dirname(resolvedConfigPath), "auth.json")
}
function resolvePath(filePath: string) {
if (filePath.startsWith("~/")) {
return path.join(process.env.HOME ?? "", filePath.slice(2))
}
return path.resolve(filePath)
}
function buildSessionCookie(name: string, value: string, options?: { maxAgeSeconds?: number }) {
const parts = [`${name}=${encodeURIComponent(value)}`, "HttpOnly", "Path=/", "SameSite=Lax"]
if (options?.maxAgeSeconds !== undefined) {
parts.push(`Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`)
}
return parts.join("; ")
}

View File

@@ -0,0 +1,49 @@
import crypto from "crypto"
export interface PasswordHashRecord {
algorithm: "scrypt"
saltBase64: string
hashBase64: string
keyLength: number
params: {
N: number
r: number
p: number
maxmem: number
}
}
const DEFAULT_SCRYPT_PARAMS = {
N: 16384,
r: 8,
p: 1,
maxmem: 32 * 1024 * 1024,
}
export function hashPassword(password: string): PasswordHashRecord {
const salt = crypto.randomBytes(16)
const params = DEFAULT_SCRYPT_PARAMS
const keyLength = 64
const derived = crypto.scryptSync(password, salt, keyLength, params)
return {
algorithm: "scrypt",
saltBase64: salt.toString("base64"),
hashBase64: Buffer.from(derived).toString("base64"),
keyLength,
params,
}
}
export function verifyPassword(password: string, record: PasswordHashRecord): boolean {
if (record.algorithm !== "scrypt") {
return false
}
const salt = Buffer.from(record.saltBase64, "base64")
const expected = Buffer.from(record.hashBase64, "base64")
const derived = crypto.scryptSync(password, salt, record.keyLength, record.params)
if (expected.length !== derived.length) {
return false
}
return crypto.timingSafeEqual(expected, Buffer.from(derived))
}

View File

@@ -0,0 +1,23 @@
import crypto from "crypto"
export interface SessionInfo {
id: string
createdAt: number
username: string
}
export class SessionManager {
private sessions = new Map<string, SessionInfo>()
createSession(username: string): SessionInfo {
const id = crypto.randomBytes(32).toString("base64url")
const info: SessionInfo = { id, createdAt: Date.now(), username }
this.sessions.set(id, info)
return info
}
getSession(id: string | undefined): SessionInfo | undefined {
if (!id) return undefined
return this.sessions.get(id)
}
}

View File

@@ -0,0 +1,32 @@
import crypto from "crypto"
export interface BootstrapToken {
token: string
createdAt: number
consumed: boolean
}
export class TokenManager {
private token: BootstrapToken | null = null
constructor(private readonly ttlMs: number) {}
generate(): string {
const token = crypto.randomBytes(32).toString("base64url")
this.token = { token, createdAt: Date.now(), consumed: false }
return token
}
consume(token: string): boolean {
if (!this.token) return false
if (this.token.consumed) return false
if (Date.now() - this.token.createdAt > this.ttlMs) return false
if (token !== this.token.token) return false
this.token.consumed = true
return true
}
peek(): string | null {
return this.token?.token ?? null
}
}

View File

@@ -0,0 +1,519 @@
import { spawn, spawnSync, 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 EXIT_WAIT_TIMEOUT_MS = 5000
const MAX_OUTPUT_BYTES = 20 * 1024
const OUTPUT_PUBLISH_INTERVAL_MS = 1000
interface ManagerDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
}
interface RunningProcess {
id: string
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 { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command)
const child = spawn(shellCommand, shellArgs, {
cwd: workspace.path,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
...spawnOptions,
})
child.on("exit", () => {
this.killProcessTree(child, "SIGTERM")
})
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, { 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) {
this.killProcessTree(running.child, "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) {
this.killProcessTree(running.child, "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
this.killProcessTree(running.child, "SIGTERM")
await this.waitForExit(running)
}
await this.removeWorkspaceDir(workspaceId)
}
private killProcessTree(child: ChildProcess, signal: NodeJS.Signals) {
const pid = child.pid
if (!pid) return
if (process.platform === "win32") {
const args = this.buildWindowsTaskkillArgs(pid, signal)
try {
spawnSync("taskkill", args, { stdio: "ignore" })
return
} catch {
// Fall back to killing the direct child.
}
} else {
try {
process.kill(-pid, signal)
return
} catch {
// Fall back to killing the direct child.
}
}
try {
child.kill(signal)
} catch {
// ignore
}
}
private async waitForExit(running: RunningProcess) {
let exited = false
const exitPromise = running.exitPromise.finally(() => {
exited = true
})
const killTimeout = setTimeout(() => {
if (!exited) {
this.killProcessTree(running.child, "SIGKILL")
}
}, STOP_TIMEOUT_MS)
try {
await Promise.race([
exitPromise,
new Promise<void>((resolve) => {
setTimeout(resolve, EXIT_WAIT_TIMEOUT_MS)
}),
])
if (!exited) {
this.killProcessTree(running.child, "SIGKILL")
this.running.delete(running.id)
this.deps.logger.warn({ pid: running.child.pid }, "Timed out waiting for background process to exit")
}
} finally {
clearTimeout(killTimeout)
}
}
private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record<string, unknown> } {
if (process.platform === "win32") {
const comspec = process.env.ComSpec || "cmd.exe"
return {
shellCommand: comspec,
shellArgs: ["/d", "/s", "/c", command],
spawnOptions: { windowsVerbatimArguments: true },
}
}
// Keep bash for macOS/Linux.
return { shellCommand: "bash", shellArgs: ["-c", command] }
}
private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] {
// Default to graceful termination (no /F), then force kill when we escalate.
const force = signal === "SIGKILL"
const args = ["/PID", String(pid), "/T"]
if (force) {
args.push("/F")
}
return args
}
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

@@ -4,10 +4,12 @@ import {
BinaryUpdateRequest,
BinaryValidationResult,
} from "../api-types"
import { spawnSync } from "child_process"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFile } from "./schema"
import { Logger } from "../logger"
import { buildSpawnSpec } from "../workspaces/runtime"
export class BinaryRegistry {
constructor(
@@ -135,8 +137,42 @@ export class BinaryRegistry {
}
private validateRecord(record: BinaryRecord): BinaryValidationResult {
// TODO: call actual binary -v check.
return { valid: true, version: record.version }
const inputPath = record.path
if (!inputPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(inputPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdout = (result.stdout ?? "").trim()
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
const normalized = firstLine?.trim()
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
const version = versionMatch?.[1]
return { valid: true, version }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
private buildFallbackRecord(path: string): BinaryRecord {

View File

@@ -11,6 +11,7 @@ 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([]),
@@ -19,6 +20,7 @@ const PreferencesSchema = z.object({
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

@@ -9,7 +9,10 @@ export class EventBus extends EventEmitter {
publish(event: WorkspaceEventPayload): boolean {
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
this.logger?.debug({ event }, "Publishing workspace event")
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)
}
@@ -26,6 +29,7 @@ export class EventBus extends EventEmitter {
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)
@@ -37,6 +41,7 @@ export class EventBus extends EventEmitter {
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)
this.off("app.releaseAvailable", handler)
}
}
}

View File

@@ -17,6 +17,8 @@ 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"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
const require = createRequire(import.meta.url)
@@ -36,6 +38,9 @@ interface CliOptions {
uiStaticDir: string
uiDevServer?: string
launch: boolean
authUsername: string
authPassword?: string
generateToken: boolean
}
const DEFAULT_PORT = 9898
@@ -62,6 +67,17 @@ function parseCliOptions(argv: string[]): CliOptions {
)
.addOption(new Option("--ui-dev-server <url>", "Proxy UI requests to a running dev server").env("CLI_UI_DEV_SERVER"))
.addOption(new Option("--launch", "Launch the UI in a browser after start").env("CLI_LAUNCH").default(false))
.addOption(
new Option("--username <username>", "Username for server authentication")
.env("CODENOMAD_SERVER_USERNAME")
.default(DEFAULT_AUTH_USERNAME),
)
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
.addOption(
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
.env("CODENOMAD_GENERATE_TOKEN")
.default(false),
)
program.parse(argv, { from: "user" })
const parsed = program.opts<{
@@ -76,6 +92,9 @@ function parseCliOptions(argv: string[]): CliOptions {
uiDir: string
uiDevServer?: string
launch?: boolean
username: string
password?: string
generateToken?: boolean
}>()
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
@@ -93,6 +112,9 @@ function parseCliOptions(argv: string[]): CliOptions {
uiStaticDir: parsed.uiDir,
uiDevServer: parsed.uiDevServer,
launch: Boolean(parsed.launch),
authUsername: parsed.username,
authPassword: parsed.password,
generateToken: Boolean(parsed.generateToken),
}
}
@@ -118,9 +140,43 @@ async function main() {
const configLogger = logger.child({ component: "config" })
const eventLogger = logger.child({ component: "events" })
logger.info({ options }, "Starting CodeNomad CLI server")
const logOptions = {
...options,
authPassword: options.authPassword ? "[REDACTED]" : undefined,
}
logger.info({ options: logOptions }, "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 authManager = new AuthManager(
{
configPath: options.configPath,
username: options.authUsername,
password: options.authPassword,
generateToken: options.generateToken,
},
logger.child({ component: "auth" }),
)
if (options.generateToken) {
const token = authManager.issueBootstrapToken()
if (token) {
console.log(`${BOOTSTRAP_TOKEN_STDOUT_PREFIX}${token}`)
}
}
const configStore = new ConfigStore(options.configPath, eventBus, configLogger)
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const workspaceManager = new WorkspaceManager({
@@ -129,6 +185,7 @@ async function main() {
binaryRegistry,
eventBus,
logger: workspaceLogger,
getServerBaseUrl: () => serverMeta.httpBaseUrl,
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore()
@@ -138,12 +195,18 @@ async function main() {
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,
@@ -155,6 +218,7 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
authManager,
uiStaticDir: options.uiStaticDir,
uiDevServerUrl: options.uiDevServer,
logger,
@@ -192,6 +256,8 @@ async function main() {
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,14 @@ 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"
import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
interface HttpServerDeps {
host: string
@@ -31,6 +37,7 @@ interface HttpServerDeps {
eventBus: EventBus
serverMeta: ServerMeta
instanceStore: InstanceStore
authManager: AuthManager
uiStaticDir: string
uiDevServerUrl?: string
logger: Logger
@@ -42,9 +49,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,8 +69,57 @@ 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()
})
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
app.register(cors, {
origin: true,
origin: (origin, cb) => {
if (!origin) {
cb(null, true)
return
}
let selfOrigin: string | null = null
try {
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin
} catch {
selfOrigin = null
}
if (selfOrigin && origin === selfOrigin) {
cb(null, true)
return
}
if (allowedDevOrigins.has(origin)) {
cb(null, true)
return
}
cb(null, false)
},
credentials: true,
})
@@ -73,38 +133,140 @@ export function createHttpServer(deps: HttpServerDeps) {
},
})
const backgroundProcessManager = new BackgroundProcessManager({
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }),
})
registerAuthRoutes(app, { authManager: deps.authManager })
app.addHook("preHandler", (request, reply, done) => {
const rawUrl = request.raw.url ?? request.url
const pathname = (rawUrl.split("?")[0] ?? "").trim()
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"])
const publicPagePaths = new Set(["/login"])
if (deps.authManager.isTokenBootstrapEnabled()) {
publicPagePaths.add("/auth/token")
}
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
done()
return
}
const session = deps.authManager.getSessionFromRequest(request)
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/")
if (requiresAuthForApi && !session) {
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/)
if (pluginMatch) {
const workspaceId = pluginMatch[1]
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
const provided = Array.isArray(request.headers.authorization)
? request.headers.authorization[0]
: request.headers.authorization
if (expected && provided && provided === expected) {
done()
return
}
}
sendUnauthorized(request, reply)
return
}
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
done()
})
app.get("/", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.redirect("/login")
return
}
if (deps.uiDevServerUrl) {
await proxyToDevServer(request, reply, deps.uiDevServerUrl)
return
}
const uiDir = deps.uiStaticDir
const indexPath = path.join(uiDir, "index.html")
if (uiDir && fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
return
}
reply.code(404).send({ message: "UI bundle missing" })
})
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 })
if (deps.uiDevServerUrl) {
setupDevProxy(app, deps.uiDevServerUrl)
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager)
} else {
setupStaticUi(app, deps.uiStaticDir)
setupStaticUi(app, deps.uiStaticDir, deps.authManager)
}
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()
@@ -117,6 +279,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}`)
@@ -195,8 +360,20 @@ async function proxyWorkspaceRequest(args: {
const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId)
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, {
rewriteRequestHeaders: (_originalRequest, headers) => {
if (instanceAuthHeader) {
headers.authorization = instanceAuthHeader
}
return headers
},
onError: (proxyReply, { error }) => {
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request")
if (!proxyReply.sent) {
@@ -214,7 +391,7 @@ function normalizeInstanceSuffix(pathSuffix: string | undefined) {
return trimmed.length === 0 ? "/" : `/${trimmed}`
}
function setupStaticUi(app: FastifyInstance, uiDir: string) {
function setupStaticUi(app: FastifyInstance, uiDir: string, authManager: AuthManager) {
if (!uiDir) {
app.log.warn("UI static directory not provided; API endpoints only")
return
@@ -240,6 +417,12 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
return
}
const session = authManager.getSessionFromRequest(request)
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
if (fs.existsSync(indexPath)) {
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"))
} else {
@@ -248,7 +431,7 @@ function setupStaticUi(app: FastifyInstance, uiDir: string) {
})
}
function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
function setupDevProxy(app: FastifyInstance, upstreamBase: string, authManager: AuthManager) {
app.log.info({ upstreamBase }, "Proxying UI requests to development server")
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
const url = request.raw.url ?? ""
@@ -256,6 +439,13 @@ function setupDevProxy(app: FastifyInstance, upstreamBase: string) {
reply.code(404).send({ message: "Not Found" })
return
}
const session = authManager.getSessionFromRequest(request)
if (!session && wantsHtml(request)) {
reply.redirect("/login")
return
}
void proxyToDevServer(request, reply, upstreamBase)
})
}

View File

@@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad Login</title>
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: #0b0b0f;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.card {
width: 420px;
max-width: calc(100vw - 32px);
background: #14141c;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 24px;
}
h1 {
font-size: 18px;
margin: 0 0 12px;
}
p {
margin: 0 0 18px;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.4;
}
label {
display: block;
font-size: 12px;
margin: 10px 0 6px;
color: rgba(255, 255, 255, 0.75);
}
input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #0f0f16;
color: #fff;
}
button {
width: 100%;
margin-top: 14px;
padding: 10px 12px;
border-radius: 10px;
border: 0;
background: #4c6fff;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.error {
margin-top: 12px;
color: #ff6b6b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="card">
<h1>Sign in</h1>
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
<label for="username">Username</label>
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
<label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" value="" />
<button id="submit" type="button">Continue</button>
<div id="error" class="error" style="display: none"></div>
</div>
<script>
const $ = (id) => document.getElementById(id)
const errorEl = $("error")
const showError = (msg) => {
errorEl.textContent = msg
errorEl.style.display = "block"
}
const hideError = () => {
errorEl.textContent = ""
errorEl.style.display = "none"
}
async function submit() {
hideError()
const username = $("username").value.trim()
const password = $("password").value
if (!username || !password) {
showError("Username and password are required.")
return
}
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || `Login failed (${res.status})`)
return
}
window.location.href = "/"
} catch (e) {
showError(e && e.message ? e.message : String(e))
}
}
$("submit").addEventListener("click", submit)
$("password").addEventListener("keydown", (e) => {
if (e.key === "Enter") submit()
})
</script>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodeNomad</title>
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
background: #0b0b0f;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.card {
width: 420px;
max-width: calc(100vw - 32px);
background: #14141c;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 24px;
}
h1 {
font-size: 18px;
margin: 0 0 12px;
}
p {
margin: 0;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
line-height: 1.4;
}
.error {
margin-top: 12px;
color: #ff6b6b;
font-size: 13px;
}
</style>
</head>
<body>
<div class="card">
<h1>Connecting…</h1>
<p>Finalizing local authentication.</p>
<div id="error" class="error" style="display: none"></div>
</div>
<script>
const token = (location.hash || "").replace(/^#/, "").trim()
const errorEl = document.getElementById("error")
const showError = (msg) => {
errorEl.textContent = msg
errorEl.style.display = "block"
}
async function run() {
if (!token) {
showError("Missing bootstrap token.")
return
}
try {
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
credentials: "include",
})
if (!res.ok) {
let message = ""
try {
const json = await res.json()
message = json && json.error ? String(json.error) : ""
} catch {
message = ""
}
showError(message || `Token exchange failed (${res.status})`)
return
}
window.location.replace("/")
} catch (e) {
showError(e && e.message ? e.message : String(e))
}
}
run()
</script>
</body>
</html>

View File

@@ -0,0 +1,157 @@
import type { FastifyInstance } from "fastify"
import fs from "fs"
import { z } from "zod"
import type { AuthManager } from "../../auth/manager"
import { isLoopbackAddress } from "../../auth/http-auth"
interface RouteDeps {
authManager: AuthManager
}
const LoginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
})
const TokenSchema = z.object({
token: z.string().min(1),
})
const PasswordSchema = z.object({
password: z.string().min(8),
})
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url)
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url)
let cachedLoginTemplate: string | null = null
let cachedTokenTemplate: string | null = null
function readTemplate(url: URL, cache: string | null): string {
if (cache) return cache
const content = fs.readFileSync(url, "utf-8")
return content
}
function getLoginHtml(defaultUsername: string): string {
if (!cachedLoginTemplate) {
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null)
}
const escapedUsername = escapeHtml(defaultUsername)
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername)
}
function getTokenHtml(): string {
if (!cachedTokenTemplate) {
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null)
}
return cachedTokenTemplate
}
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/login", async (_request, reply) => {
const status = deps.authManager.getStatus()
reply.type("text/html").send(getLoginHtml(status.username))
})
app.get("/auth/token", async (request, reply) => {
if (!deps.authManager.isTokenBootstrapEnabled()) {
reply.code(404).send({ error: "Not found" })
return
}
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404).send({ error: "Not found" })
return
}
reply.type("text/html").send(getTokenHtml())
})
app.get("/api/auth/status", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.send({ authenticated: false })
return
}
reply.send({ authenticated: true, ...deps.authManager.getStatus() })
})
app.post("/api/auth/login", async (request, reply) => {
const body = LoginSchema.parse(request.body ?? {})
const ok = deps.authManager.validateLogin(body.username, body.password)
if (!ok) {
reply.code(401).send({ error: "Invalid credentials" })
return
}
const session = deps.authManager.createSession(body.username)
deps.authManager.setSessionCookie(reply, session.id)
reply.send({ ok: true })
})
app.post("/api/auth/token", async (request, reply) => {
if (!deps.authManager.isTokenBootstrapEnabled()) {
reply.code(404).send({ error: "Not found" })
return
}
if (!isLoopbackAddress(request.socket.remoteAddress)) {
reply.code(404).send({ error: "Not found" })
return
}
const body = TokenSchema.parse(request.body ?? {})
const ok = deps.authManager.consumeBootstrapToken(body.token)
if (!ok) {
reply.code(401).send({ error: "Invalid token" })
return
}
const username = deps.authManager.getStatus().username
const session = deps.authManager.createSession(username)
deps.authManager.setSessionCookie(reply, session.id)
reply.send({ ok: true })
})
app.post("/api/auth/logout", async (_request, reply) => {
deps.authManager.clearSessionCookie(reply)
reply.send({ ok: true })
})
app.post("/api/auth/password", async (request, reply) => {
const session = deps.authManager.getSessionFromRequest(request)
if (!session) {
reply.code(401).send({ error: "Unauthorized" })
return
}
const body = PasswordSchema.parse(request.body ?? {})
try {
const status = deps.authManager.setPassword(body.password)
reply.send({ ok: true, ...status })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
reply.code(409).type("text/plain").send(message)
}
})
}
function escapeHtml(value: string) {
return value.replace(/[&<>"]/g, (char) => {
switch (char) {
case "&":
return "&amp;"
case "<":
return "&lt;"
case ">":
return "&gt;"
case '"':
return "&quot;"
default:
return char
}
})
}

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

@@ -96,8 +96,15 @@ export class InstanceEventBridge {
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
const url = `http://${INSTANCE_HOST}:${port}/event`
const headers: Record<string, string> = { Accept: "text/event-stream" }
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId)
if (authHeader) {
headers["Authorization"] = authHeader
}
const response = await fetch(url, {
headers: { Accept: "text/event-stream" },
headers,
signal,
dispatcher: STREAM_AGENT,
})
@@ -159,6 +166,10 @@ export class InstanceEventBridge {
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")
@@ -166,6 +177,7 @@ export class InstanceEventBridge {
}
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 })
}

View File

@@ -1,5 +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"
@@ -7,8 +8,18 @@ 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"
import {
buildOpencodeBasicAuthHeader,
DEFAULT_OPENCODE_USERNAME,
generateOpencodeServerPassword,
OPENCODE_SERVER_PASSWORD_ENV,
OPENCODE_SERVER_USERNAME_ENV,
} from "./opencode-auth"
const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions {
rootDir: string
@@ -16,6 +27,7 @@ interface WorkspaceManagerOptions {
binaryRegistry: BinaryRegistry
eventBus: EventBus
logger: Logger
getServerBaseUrl: () => string
}
interface WorkspaceRecord extends WorkspaceDescriptor {}
@@ -23,9 +35,12 @@ interface WorkspaceRecord extends WorkspaceDescriptor {}
export class WorkspaceManager {
private readonly workspaces = new Map<string, WorkspaceRecord>()
private readonly runtime: WorkspaceRuntime
private readonly opencodeConfigDir: string
private readonly opencodeAuth = new Map<string, { username: string; password: string; authorization: string }>()
constructor(private readonly options: WorkspaceManagerOptions) {
this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger)
this.opencodeConfigDir = getOpencodeConfigDir()
}
list(): WorkspaceDescriptor[] {
@@ -40,6 +55,10 @@ export class WorkspaceManager {
return this.workspaces.get(id)?.port
}
getInstanceAuthorizationHeader(id: string): string | undefined {
return this.opencodeAuth.get(id)?.authorization
}
listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
@@ -97,10 +116,28 @@ export class WorkspaceManager {
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 opencodeUsername = DEFAULT_OPENCODE_USERNAME
const opencodePassword = generateOpencodeServerPassword()
const authorization = buildOpencodeBasicAuthHeader({ username: opencodeUsername, password: opencodePassword })
if (!authorization) {
throw new Error("Failed to build OpenCode auth header")
}
this.opencodeAuth.set(id, { username: opencodeUsername, password: opencodePassword, authorization })
const environment = {
...userEnvironment,
OPENCODE_CONFIG_DIR: this.opencodeConfigDir,
CODENOMAD_INSTANCE_ID: id,
CODENOMAD_BASE_URL: this.options.getServerBaseUrl(),
[OPENCODE_SERVER_USERNAME_ENV]: opencodeUsername,
[OPENCODE_SERVER_PASSWORD_ENV]: opencodePassword,
}
try {
const { pid, port } = await this.runtime.launch({
const { pid, port, exitPromise, getLastOutput } = await this.runtime.launch({
workspaceId: id,
folder: workspacePath,
binaryPath: resolvedBinaryPath,
@@ -108,6 +145,8 @@ export class WorkspaceManager {
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
})
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
descriptor.pid = pid
descriptor.port = port
descriptor.status = "ready"
@@ -138,6 +177,7 @@ export class WorkspaceManager {
}
this.workspaces.delete(id)
this.opencodeAuth.delete(id)
clearWorkspaceSearchCache(workspace.path)
if (!wasRunning) {
this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id })
@@ -158,6 +198,7 @@ export class WorkspaceManager {
}
}
this.workspaces.clear()
this.opencodeAuth.clear()
this.options.logger.info("All workspaces cleared")
}
@@ -184,13 +225,15 @@ export class WorkspaceManager {
try {
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const resolved = result.stdout
const candidates = result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
.filter((line) => line.length > 0)
.filter((line) => !/^INFO:/i.test(line))
if (resolved) {
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
if (candidates.length > 0) {
const resolved = this.pickBinaryCandidate(candidates)
this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH")
return resolved
}
} else if (result.error) {
@@ -203,6 +246,23 @@ export class WorkspaceManager {
return identifier
}
private pickBinaryCandidate(candidates: string[]): string {
if (process.platform !== "win32") {
return candidates[0] ?? ""
}
const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"]
for (const ext of extensionPreference) {
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext))
if (match) {
return match
}
}
return candidates[0] ?? ""
}
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
@@ -233,10 +293,173 @@ export class WorkspaceManager {
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 headers: Record<string, string> = {}
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
if (authHeader) {
headers["Authorization"] = authHeader
}
const response = await fetch(url, { headers })
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
this.opencodeAuth.delete(workspaceId)
this.options.logger.info({ workspaceId, ...info }, "Workspace process exited")
workspace.pid = undefined

View File

@@ -0,0 +1,22 @@
import crypto from "node:crypto"
export const OPENCODE_SERVER_USERNAME_ENV = "OPENCODE_SERVER_USERNAME" as const
export const OPENCODE_SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD" as const
export const DEFAULT_OPENCODE_USERNAME = "codenomad" as const
export function generateOpencodeServerPassword(): string {
return crypto.randomBytes(32).toString("base64url")
}
export function buildOpencodeBasicAuthHeader(params: { username?: string; password?: string }): string | undefined {
const username = params.username
const password = params.password
if (!username || !password) {
return undefined
}
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
return `Basic ${token}`
}

View File

@@ -5,6 +5,55 @@ import { EventBus } from "../events/bus"
import { LogLevel, WorkspaceLogEntry } from "../api-types"
import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const }
}
const extension = path.extname(binaryPath).toLowerCase()
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
const comspec = process.env.ComSpec || "cmd.exe"
// cmd.exe requires the full command as a single string.
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
return {
command: comspec,
args: ["/d", "/s", "/c", commandLine],
options: { windowsVerbatimArguments: true } as const,
}
}
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
// powershell.exe ships with Windows. (pwsh may not.)
return {
command: "powershell.exe",
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
options: {} as const,
}
}
return { command: binaryPath, args, options: {} as const }
}
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
const redacted: Record<string, string | undefined> = {}
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
redacted[key] = value
continue
}
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "[REDACTED]" : value
}
return redacted
}
interface LaunchOptions {
workspaceId: string
folder: string
@@ -13,7 +62,7 @@ interface LaunchOptions {
onExit?: (info: ProcessExitInfo) => void
}
interface ProcessExitInfo {
export interface ProcessExitInfo {
workspaceId: string
code: number | null
signal: NodeJS.Signals | null
@@ -30,21 +79,54 @@ 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) => {
const spec = buildSpawnSpec(options.binaryPath, args)
const commandLine = [spec.command, ...spec.args].join(" ")
this.logger.info(
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
{
workspaceId: options.workspaceId,
folder: options.folder,
binary: options.binaryPath,
spawnCommand: spec.command,
spawnArgs: spec.args,
commandLine,
env: redactEnvironment(env),
},
"Launching OpenCode process",
)
const child = spawn(options.binaryPath, args, {
const child = spawn(spec.command, spec.args, {
cwd: options.folder,
env,
stdio: ["ignore", "pipe", "pipe"],
...spec.options,
})
const managed: ManagedProcess = { child, requestedStop: false }
@@ -83,11 +165,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)
}
}
@@ -96,6 +189,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)
}
@@ -109,18 +206,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 })
}
}
}
@@ -133,7 +237,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)
}
})

View File

@@ -80,6 +80,79 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix 1.1.2",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix 1.1.2",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
@@ -91,6 +164,30 @@ dependencies = [
"syn 2.0.110",
]
[[package]]
name = "async-signal"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix 1.1.2",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -191,6 +288,19 @@ dependencies = [
"objc2 0.6.3",
]
[[package]]
name = "blocking"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]]
name = "brotli"
version = "8.0.2"
@@ -372,6 +482,7 @@ name = "codenomad-tauri"
version = "0.1.0"
dependencies = [
"anyhow",
"dirs 5.0.1",
"libc",
"once_cell",
"parking_lot",
@@ -381,7 +492,9 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"thiserror 1.0.69",
"url",
"which",
]
@@ -608,13 +721,34 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users 0.4.6",
"windows-sys 0.48.0",
]
[[package]]
@@ -625,7 +759,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
@@ -1335,6 +1469,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex"
version = "0.4.3"
@@ -1637,6 +1777,25 @@ dependencies = [
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itoa"
version = "1.0.15"
@@ -2294,6 +2453,18 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "open"
version = "5.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
dependencies = [
"dunce",
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2364,6 +2535,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2516,6 +2693,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piper"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -2548,6 +2736,20 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
dependencies = [
"cfg-if",
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix 1.1.2",
"windows-sys 0.61.2",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -2804,6 +3006,17 @@ dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -3530,7 +3743,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 6.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -3580,7 +3793,7 @@ checksum = "87d6f8cafe6a75514ce5333f115b7b1866e8e68d9672bf4ca89fc0f35697ea9d"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 6.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@@ -3692,6 +3905,28 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c26b72571d25dee25667940027114e60f569fc3974f8cefbe50c2cbc5fd65e3b"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation 0.3.2",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
"url",
"windows",
"zbus",
]
[[package]]
name = "tauri-runtime"
version = "2.9.1"
@@ -4106,7 +4341,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"libappindicator",
"muda",
"objc2 0.6.3",
@@ -4750,6 +4985,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -4792,6 +5036,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -4849,6 +5108,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -4867,6 +5132,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -4885,6 +5156,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -4915,6 +5192,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -4933,6 +5216,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -4951,6 +5240,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -4969,6 +5264,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -5031,7 +5332,7 @@ dependencies = [
"block2 0.6.2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"dpi",
"dunce",
"gdkx11",
@@ -5117,8 +5418,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",

View File

@@ -1,15 +1,15 @@
{
"name": "@codenomad/tauri-app",
"version": "0.2.7",
"version": "0.7.2",
"private": true,
"scripts": {
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
"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": "npx --yes @tauri-apps/cli@^2.9.4 build"
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"

View File

@@ -166,6 +166,44 @@ function copyServerArtifacts() {
}
}
function stripNodeModuleBins() {
const root = path.join(serverDest, "node_modules")
if (!fs.existsSync(root)) {
return
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = path.join(current, entry.name)
if (entry.name === ".bin") {
fs.rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
if (removed > 0) {
console.log(`[prebuild] removed ${removed} node_modules/.bin directories`)
}
}
function copyUiLoadingAssets() {
const loadingSource = path.join(uiDist, "loading.html")
const assetsSource = path.join(uiDist, "assets")
@@ -192,4 +230,5 @@ ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
copyServerArtifacts()
stripNodeModuleBins()
copyUiLoadingAssets()

View File

@@ -18,3 +18,6 @@ anyhow = "1"
which = "4"
libc = "0.2"
tauri-plugin-dialog = "2"
dirs = "5"
tauri-plugin-opener = "2"
url = "2"

View File

@@ -3,14 +3,14 @@
"identifier": "main-window-native-dialogs",
"description": "Grant the main window access to required core features and native dialog commands.",
"remote": {
"urls": [
"http://127.0.0.1:*",
"http://localhost:*"
]
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
},
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open"
"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

@@ -1 +1 @@
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}
{"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"]}}

View File

@@ -134,6 +134,174 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
},
"deny": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{
"properties": {
"identifier": {
@@ -2209,6 +2377,54 @@
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
},
@@ -2305,6 +2521,23 @@
]
}
]
},
"Application": {
"description": "Opener scope application.",
"anyOf": [
{
"description": "Open in default application.",
"type": "null"
},
{
"description": "If true, allow open with any application.",
"type": "boolean"
},
{
"description": "Allow specific application to open with.",
"type": "string"
}
]
}
}
}

View File

@@ -134,6 +134,174 @@
"description": "Reference a permission or permission set by identifier and extends its scope.",
"type": "object",
"allOf": [
{
"if": {
"properties": {
"identifier": {
"anyOf": [
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
}
}
},
"then": {
"properties": {
"allow": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
},
"deny": {
"items": {
"title": "OpenerScopeEntry",
"description": "Opener scope entry.",
"anyOf": [
{
"type": "object",
"required": [
"url"
],
"properties": {
"app": {
"description": "An application to open this url with, for example: firefox.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"url": {
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
"type": "string"
}
}
},
{
"type": "object",
"required": [
"path"
],
"properties": {
"app": {
"description": "An application to open this path with, for example: xdg-open.",
"allOf": [
{
"$ref": "#/definitions/Application"
}
]
},
"path": {
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
}
}
}
]
}
}
}
},
"properties": {
"identifier": {
"description": "Identifier of the permission or permission set.",
"allOf": [
{
"$ref": "#/definitions/Identifier"
}
]
}
}
},
{
"properties": {
"identifier": {
@@ -2209,6 +2377,54 @@
"type": "string",
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
"const": "opener:default",
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
},
{
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
"type": "string",
"const": "opener:allow-default-urls",
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
},
{
"description": "Enables the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-path",
"markdownDescription": "Enables the open_path command without any pre-configured scope."
},
{
"description": "Enables the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-open-url",
"markdownDescription": "Enables the open_url command without any pre-configured scope."
},
{
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:allow-reveal-item-in-dir",
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "Denies the open_path command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-path",
"markdownDescription": "Denies the open_path command without any pre-configured scope."
},
{
"description": "Denies the open_url command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-open-url",
"markdownDescription": "Denies the open_url command without any pre-configured scope."
},
{
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
}
]
},
@@ -2305,6 +2521,23 @@
]
}
]
},
"Application": {
"description": "Opener scope application.",
"anyOf": [
{
"description": "Open in default application.",
"type": "null"
},
{
"description": "If true, allow open with any application.",
"type": "boolean"
},
{
"description": "Allow specific application to open with.",
"type": "string"
}
]
}
}
}

View File

@@ -1,17 +1,21 @@
use dirs::home_dir;
use parking_lot::Mutex;
use regex::Regex;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::VecDeque;
use std::env;
use std::ffi::OsStr;
use std::io::{BufRead, BufReader};
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
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};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
fn log_line(message: &str) {
println!("[tauri-cli] {message}");
@@ -28,9 +32,15 @@ fn workspace_root() -> Option<PathBuf> {
})
}
const SESSION_COOKIE_NAME: &str = "codenomad_session";
fn navigate_main(app: &AppHandle, url: &str) {
if let Some(win) = app.webview_windows().get("main") {
log_line(&format!("navigating main to {url}"));
let mut display = url.to_string();
if let Some(hash_index) = display.find('#') {
display.replace_range(hash_index + 1.., "[REDACTED]");
}
log_line(&format!("navigating main to {display}"));
if let Ok(parsed) = Url::parse(url) {
let _ = win.navigate(parsed);
} else {
@@ -41,6 +51,145 @@ fn navigate_main(app: &AppHandle, url: &str) {
}
}
fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
let prefix = format!("{name}=");
let cookie_kv = set_cookie.split(';').next()?.trim();
if !cookie_kv.starts_with(&prefix) {
return None;
}
let value = cookie_kv.trim_start_matches(&prefix).trim();
if value.is_empty() {
return None;
}
Some(value.to_string())
}
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
let parsed = Url::parse(base_url)?;
let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port_or_known_default().unwrap_or(80);
// This is only used for local bootstrap; we assume plain HTTP.
let mut stream = TcpStream::connect((host, port))?;
let body = format!("{{\"token\":\"{}\"}}", token);
let request = format!(
"POST /api/auth/token HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.as_bytes().len(),
body
);
stream.write_all(request.as_bytes())?;
stream.flush()?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
let (raw_headers, _rest) = response
.split_once("\r\n\r\n")
.or_else(|| response.split_once("\n\n"))
.unwrap_or((response.as_str(), ""));
let mut lines = raw_headers.lines();
let status_line = lines.next().unwrap_or("");
if !status_line.contains(" 200 ") {
return Ok(None);
}
for line in lines {
// handle case-insensitive header name
if let Some(value) = line.strip_prefix("Set-Cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
return Ok(Some(session_id));
}
} else if let Some(value) = line.strip_prefix("set-cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
return Ok(Some(session_id));
}
}
}
Ok(None)
}
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
let parsed = Url::parse(base_url)?;
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
.domain(domain)
.path("/")
.http_only(true)
.same_site(tauri::webview::cookie::SameSite::Lax)
.build();
if let Some(win) = app.webview_windows().get("main") {
win.set_cookie(cookie)?;
}
Ok(())
}
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 {
@@ -76,6 +225,7 @@ pub struct CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
}
impl CliProcessManager {
@@ -84,6 +234,7 @@ impl CliProcessManager {
status: Arc::new(Mutex::new(CliStatus::default())),
child: Arc::new(Mutex::new(None)),
ready: Arc::new(AtomicBool::new(false)),
bootstrap_token: Arc::new(Mutex::new(None)),
}
}
@@ -91,6 +242,7 @@ impl CliProcessManager {
log_line(&format!("start requested (dev={dev})"));
self.stop()?;
self.ready.store(false, Ordering::SeqCst);
*self.bootstrap_token.lock() = None;
{
let mut status = self.status.lock();
status.state = CliState::Starting;
@@ -104,8 +256,9 @@ impl CliProcessManager {
let status_arc = self.status.clone();
let child_arc = self.child.clone();
let ready_flag = self.ready.clone();
let token_arc = self.bootstrap_token.clone();
thread::spawn(move || {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, dev) {
if let Err(err) = Self::spawn_cli(app.clone(), status_arc.clone(), child_arc, ready_flag, token_arc, dev) {
log_line(&format!("cli spawn failed: {err}"));
let mut locked = status_arc.lock();
locked.state = CliState::Error;
@@ -174,15 +327,17 @@ impl CliProcessManager {
status: Arc<Mutex<CliStatus>>,
child_holder: Arc<Mutex<Option<Child>>>,
ready: Arc<AtomicBool>,
bootstrap_token: Arc<Mutex<Option<String>>>,
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={}",
resolution.runner, resolution.entry
"resolved CLI entry runner={:?} entry={} host={}",
resolution.runner, resolution.entry, host
));
let args = resolution.build_args(dev);
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");
@@ -254,8 +409,10 @@ impl CliProcessManager {
let status_clone = status.clone();
let app_clone = app.clone();
let ready_clone = ready.clone();
let token_clone = bootstrap_token.clone();
thread::spawn(move || {
let stdout = child_clone
.lock()
.as_mut()
@@ -268,10 +425,10 @@ impl CliProcessManager {
.map(BufReader::new);
if let Some(reader) = stdout {
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone);
Self::process_stream(reader, "stdout", &app_clone, &status_clone, &ready_clone, &token_clone);
}
if let Some(reader) = stderr {
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone);
Self::process_stream(reader, "stderr", &app_clone, &status_clone, &ready_clone, &token_clone);
}
});
@@ -280,7 +437,7 @@ impl CliProcessManager {
let ready_clone = ready.clone();
let child_holder_clone = child_holder.clone();
thread::spawn(move || {
let timeout = Duration::from_secs(15);
let timeout = Duration::from_secs(60);
thread::sleep(timeout);
if ready_clone.load(Ordering::SeqCst) {
return;
@@ -343,10 +500,12 @@ impl CliProcessManager {
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
) {
let mut buffer = String::new();
let port_regex = Regex::new(r"CodeNomad Server is ready at http://[^:]+:(\d+)").ok();
let http_regex = Regex::new(r":(\d{2,5})(?!.*:\d)").ok();
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
loop {
buffer.clear();
@@ -355,6 +514,17 @@ impl CliProcessManager {
Ok(_) => {
let line = buffer.trim_end();
if !line.is_empty() {
if line.starts_with(token_prefix) {
let token = line.trim_start_matches(token_prefix).trim();
if !token.is_empty() {
let mut guard = bootstrap_token.lock();
if guard.is_none() {
*guard = Some(token.to_string());
}
}
continue;
}
log_line(&format!("[cli][{}] {}", stream, line));
if ready.load(Ordering::SeqCst) {
@@ -366,7 +536,7 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, port);
Self::mark_ready(app, status, ready, bootstrap_token, port);
continue;
}
@@ -376,13 +546,13 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.and_then(|m| m.as_str().parse::<u16>().ok())
{
Self::mark_ready(app, status, ready, port);
Self::mark_ready(app, status, ready, bootstrap_token, 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);
Self::mark_ready(app, status, ready, bootstrap_token, port as u16);
continue;
}
}
@@ -394,16 +564,46 @@ impl CliProcessManager {
}
}
fn mark_ready(app: &AppHandle, status: &Arc<Mutex<CliStatus>>, ready: &Arc<AtomicBool>, port: u16) {
fn mark_ready(
app: &AppHandle,
status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>,
port: u16,
) {
ready.store(true, Ordering::SeqCst);
let base_url = format!("http://127.0.0.1:{port}");
let mut locked = status.lock();
let url = format!("http://127.0.0.1:{port}");
locked.port = Some(port);
locked.url = Some(url.clone());
locked.url = Some(base_url.clone());
locked.state = CliState::Ready;
locked.error = None;
log_line(&format!("cli ready on {url}"));
navigate_main(app, &url);
log_line(&format!("cli ready on {base_url}"));
let token = bootstrap_token.lock().take();
if let Some(token) = token {
match exchange_bootstrap_token(&base_url, &token) {
Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login"));
} else {
navigate_main(app, &base_url);
}
}
Ok(None) => {
log_line("bootstrap token exchange failed (invalid token)");
navigate_main(app, &format!("{base_url}/login"));
}
Err(err) => {
log_line(&format!("bootstrap token exchange failed: {err}"));
navigate_main(app, &format!("{base_url}/login"));
}
}
} else {
navigate_main(app, &base_url);
}
let _ = app.emit("cli:ready", locked.clone());
Self::emit_status(app, &locked);
}
@@ -480,13 +680,14 @@ impl CliEntry {
))
}
fn build_args(&self, dev: bool) -> Vec<String> {
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
let mut args = vec![
"serve".to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
host.to_string(),
"--port".to_string(),
"0".to_string(),
"--generate-token".to_string(),
];
if dev {
args.push("--ui-dev-server".to_string());
@@ -558,6 +759,18 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
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")));
}
}
}

View File

@@ -4,8 +4,12 @@ mod cli_manager;
use cli_manager::{CliProcessManager, CliStatus};
use serde_json::json;
use tauri::menu::Menu;
use tauri::{AppHandle, Emitter, Manager};
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 {
@@ -17,36 +21,162 @@ 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 = cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok();
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()}),
);
let _ = app_handle.emit("cli:error", json!({"message": err.to_string()}));
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![cli_get_status])
.on_menu_event(|_app_handle, _event| {
// No menu items defined currently
.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 { .. } => {
.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>() {
@@ -55,25 +185,83 @@ fn main() {
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<()> {
// Minimal empty menu for now (Tauri v2 menu API differs from v1 roles).
let menu = Menu::new(app)?;
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

@@ -27,7 +27,8 @@
"fullscreen": false,
"decorations": true,
"theme": "Dark",
"backgroundColor": "#1a1a1a"
"backgroundColor": "#1a1a1a",
"zoomHotkeysEnabled": true
}
],
"security": {

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.7",
"version": "0.7.2",
"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.11",
"@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,4 +1,4 @@
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"
@@ -6,16 +6,21 @@ 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"
@@ -41,12 +46,15 @@ import {
updateSessionModel,
} from "./stores/sessions"
const log = getLogger("actions")
const App: Component = () => {
const { isDark } = useTheme()
const {
preferences,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
setDiffViewMode,
@@ -55,11 +63,41 @@ const App: Component = () => {
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())
@@ -70,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") ||
@@ -88,7 +142,7 @@ const App: Component = () => {
)
}
const clearLaunchError = () => setLaunchErrorBinary(null)
const clearLaunchError = () => setLaunchError(null)
async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) {
@@ -100,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)
}
@@ -135,7 +194,7 @@ const App: Component = () => {
try {
await acknowledgeDisconnectedInstance()
} catch (error) {
console.error("Failed to finalize disconnected instance:", error)
log.error("Failed to finalize disconnected instance", error)
}
}
@@ -153,9 +212,6 @@ const App: Component = () => {
if (!confirmed) return
await stopInstance(instanceId)
if (instances().size === 0) {
setHasInstances(false)
}
}
async function handleNewSession(instanceId: string) {
@@ -163,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)
}
}
@@ -187,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)
}
}
@@ -209,6 +265,7 @@ const App: Component = () => {
preferences,
toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks,
toggleShowTimelineTools,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
@@ -234,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
@@ -243,7 +322,7 @@ 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">
@@ -251,8 +330,8 @@ const App: Component = () => {
<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 break-words">
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
Advanced Settings.
We couldn't start the selected OpenCode binary. Review the error output below or choose a different
binary from Advanced Settings.
</Dialog.Description>
</div>
@@ -261,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>
@@ -284,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>
</>
}
>
@@ -309,6 +414,7 @@ const App: Component = () => {
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
@@ -338,7 +444,9 @@ const App: Component = () => {
</div>
</div>
</Show>
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
<AlertDialog />
<Toaster
@@ -354,4 +462,5 @@ const App: Component = () => {
)
}
export default App

View File

@@ -3,6 +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 { getLogger } from "../lib/logger"
const log = getLogger("session")
interface AgentSelectorProps {
instanceId: string
@@ -49,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)
@@ -110,7 +114,7 @@ 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>

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect } from "solid-js"
import { Component, Show, createEffect, createSignal } from "solid-js"
import { alertDialogState, dismissAlertDialog } from "../stores/alerts"
import type { AlertVariant, AlertDialogState } from "../stores/alerts"
@@ -27,8 +27,9 @@ const variantAccent: Record<AlertVariant, { badgeBg: string; badgeBorder: string
},
}
function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptValue?: string) {
const current = payload ?? alertDialogState()
if (current?.type === "confirm") {
if (confirmed) {
current.onConfirm?.()
@@ -36,7 +37,23 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null) {
current.onCancel?.()
}
current.resolve?.(confirmed)
} else if (confirmed) {
dismissAlertDialog()
return
}
if (current?.type === "prompt") {
if (confirmed) {
current.onConfirm?.()
current.resolvePrompt?.(promptValue ?? "")
} else {
current.onCancel?.()
current.resolvePrompt?.(null)
}
dismissAlertDialog()
return
}
if (confirmed) {
current?.onConfirm?.()
}
dismissAlertDialog()
@@ -60,9 +77,12 @@ const AlertDialog: Component = () => {
const accent = variantAccent[variant]
const title = payload.title || accent.fallbackTitle
const isConfirm = payload.type === "confirm"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK")
const isPrompt = payload.type === "prompt"
const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : isPrompt ? "Run" : "OK")
const cancelLabel = payload.cancelLabel || "Cancel"
const [inputValue, setInputValue] = createSignal(payload.inputDefaultValue ?? "")
return (
<Dialog
open
@@ -98,27 +118,47 @@ const AlertDialog: Component = () => {
</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>
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-xs font-medium text-muted uppercase tracking-wide">
{payload.inputLabel || "Arguments"}
</label>
<input
class="modal-search-input mt-2"
value={inputValue()}
placeholder={payload.inputPlaceholder || ""}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
dismiss(true, payload, inputValue())
}
}}
/>
</div>
</Show>
<div class="mt-6 flex justify-end gap-3">
{(isConfirm || isPrompt) && (
<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, inputValue())}
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>

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), { withCredentials: true } as any)
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,5 +1,6 @@
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"
@@ -7,6 +8,11 @@ import { normalizeDiffText } from "../lib/diff-utils"
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
@@ -107,7 +113,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
>
{(data) => (
<ErrorBoundary fallback={(error) => {
console.warn("Failed to render diff view", error)
log.warn("Failed to render diff view", error)
return <pre class="tool-call-diff-fallback">{props.diffText}</pre>
}}>
<DiffView

View File

@@ -0,0 +1,30 @@
import { Show } from "solid-js"
import { Maximize2, Minimize2 } from "lucide-solid"
interface ExpandButtonProps {
expandState: () => "normal" | "expanded"
onToggleExpand: (nextState: "normal" | "expanded") => void
}
export default function ExpandButton(props: ExpandButtonProps) {
function handleClick() {
const current = props.expandState()
props.onToggleExpand(current === "normal" ? "expanded" : "normal")
}
return (
<button
type="button"
class="prompt-expand-button"
onClick={handleClick}
aria-label="Toggle chat input height"
>
<Show
when={props.expandState() === "normal"}
fallback={<Minimize2 class="h-4 w-4" aria-hidden="true" />}
>
<Maximize2 class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
)
}

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,5 +1,5 @@
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"
@@ -8,12 +8,14 @@ import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/nat
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) => {
@@ -222,13 +224,24 @@ 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-32 w-auto sm:h-48" loading="lazy" />
@@ -241,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">

View File

@@ -1,131 +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 binaryVersion = () => props.instance.binaryVersion || metadata()?.version
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 && instance.binaryVersion) {
nextMetadata.version = instance.binaryVersion
}
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 (
@@ -137,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>
@@ -186,24 +81,24 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</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}>
@@ -219,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">
@@ -314,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>
<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,184 +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 Kbd from "../kbd"
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 = 350
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)}
/>
<div class="sidebar-selector-hints" aria-hidden="true">
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
<Kbd shortcut="cmd+shift+a" />
</span>
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
<Kbd shortcut="cmd+shift+m" />
</span>
</div>
<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,18 +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
// Markdown initialization is now handled globally in App.
// initMarkdown is idempotent but we avoid per-part calls here.
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
@@ -39,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)
}
}
})
@@ -87,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)
}
}
}
}
@@ -101,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
@@ -118,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

@@ -1,34 +1,25 @@
import { For, Index, Match, Show, Switch, createMemo, createSignal, createEffect, onCleanup } from "solid-js"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js"
import { FoldVertical } from "lucide-solid"
import MessageItem from "./message-item"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import ToolCall from "./tool-call"
import Kbd from "./kbd"
import type { MessageInfo, ClientPart } from "../types/message"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message"
import { getSessionInfo, sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { showCommandPalette } from "../stores/command-palette"
import { messageStoreBus } from "../stores/message-v2/bus"
import type { MessageRecord } from "../stores/message-v2/types"
import { buildRecordDisplayData, clearRecordDisplayCacheForInstance } from "../stores/message-v2/record-display-cache"
import { useConfig } from "../stores/preferences"
import { sseManager } from "../lib/sse-manager"
import type { MessageRecord } from "../stores/message-v2/types"
import { messageStoreBus } from "../stores/message-v2/bus"
import { formatTokenTotal } from "../lib/formatters"
import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances"
const SCROLL_SCOPE = "session"
const USER_SCROLL_INTENT_WINDOW_MS = 600
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
const TOOL_ICON = "🔧"
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
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
@@ -91,8 +82,20 @@ interface TaskSessionLocation {
parentId: string | null
}
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string): TaskSessionLocation | null {
if (!sessionId) return null
if (preferredInstanceId) {
const session = sessions().get(preferredInstanceId)?.get(sessionId)
if (session) {
return {
sessionId: session.id,
instanceId: preferredInstanceId,
parentId: session.parentId ?? null,
}
}
}
const allSessions = sessions()
for (const [instanceId, sessionMap] of allSessions) {
const session = sessionMap?.get(sessionId)
@@ -116,10 +119,6 @@ function navigateToTaskSession(location: TaskSessionLocation) {
}
}
function formatTokens(tokens: number): string {
return formatTokenTotal(tokens)
}
interface CachedBlockEntry {
signature: string
block: MessageDisplayBlock
@@ -139,6 +138,10 @@ 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)
@@ -154,7 +157,6 @@ function getSessionRenderCache(instanceId: string, sessionId: string): SessionRe
}
function clearInstanceCaches(instanceId: string) {
clearRecordDisplayCacheForInstance(instanceId)
const prefix = `${instanceId}:`
for (const key of renderCaches.keys()) {
@@ -166,16 +168,6 @@ function clearInstanceCaches(instanceId: string) {
messageStoreBus.onInstanceDestroyed(clearInstanceCaches)
interface MessageStreamV2Props {
instanceId: string
sessionId: string
loading?: boolean
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
registerScrollToBottom?: (fn: () => void) => void
}
interface ContentDisplayItem {
type: "content"
key: string
@@ -213,495 +205,27 @@ type ReasoningDisplayItem = {
defaultExpanded: boolean
}
type MessageBlockItem = ContentDisplayItem | ToolDisplayItem | StepDisplayItem | ReasoningDisplayItem
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[]
}
export default function MessageStreamV2(props: MessageStreamV2Props) {
const { preferences } = useConfig()
const showUsagePreference = () => preferences().showUsageMetrics ?? true
const store = createMemo(() => 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 connectionStatus = () => sseManager.getStatus(props.instanceId)
const handleCommandPaletteClick = () => {
showCommandPalette(props.instanceId)
}
const messageIndexMap = createMemo(() => {
const map = new Map<string, number>()
const ids = messageIds()
ids.forEach((id, index) => map.set(id, index))
return map
})
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 changeToken = createMemo(() => {
// Any change that can affect layout (new message, part update, revert,
// etc.) should bump the session revision. We use this as the primary
// signal for auto-scroll decisions.
return String(sessionRevision())
})
const scrollCache = useScrollCache({
instanceId: () => props.instanceId,
sessionId: () => props.sessionId,
scope: SCROLL_SCOPE,
})
const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
let containerRef: HTMLDivElement | undefined
let lastKnownScrollTop = 0
let lastMeasuredScrollHeight = 0
let pendingScrollFrame: number | null = null
let userScrollIntentUntil = 0
let detachScrollIntentListeners: (() => void) | undefined
let hasRestoredScroll = false
let hasInitialScroll = false
// When the user explicitly clicks "scroll to bottom", we want the
// smooth scroll animation to complete without being immediately
// overridden by the auto-scroll effects that react to new messages.
let suppressAutoScrollOnce = false
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
lastKnownScrollTop = containerRef?.scrollTop ?? 0
lastMeasuredScrollHeight = containerRef?.scrollHeight ?? 0
attachScrollIntentListeners(containerRef)
}
function isNearBottom(element: HTMLDivElement, offset = 48) {
const { scrollTop, scrollHeight, clientHeight } = element
return scrollHeight - (scrollTop + clientHeight) <= offset
}
function isNearTop(element: HTMLDivElement, offset = 48) {
return element.scrollTop <= offset
}
function updateScrollIndicators(element: HTMLDivElement) {
const hasItems = messageIds().length > 0
setShowScrollBottomButton(hasItems && !isNearBottom(element))
setShowScrollTopButton(hasItems && !isNearTop(element))
}
function scrollToBottom(immediate = false) {
if (!containerRef) return
const behavior = immediate ? "auto" : "smooth"
if (!immediate) {
// We initiated this scroll (e.g., via the button). Skip the
// next auto-scroll reaction so the smooth animation isn't
// overridden by changeToken/preference effects.
suppressAutoScrollOnce = true
}
containerRef.scrollTo({ top: containerRef.scrollHeight, behavior })
setAutoScroll(true)
lastMeasuredScrollHeight = containerRef.scrollHeight
lastKnownScrollTop = containerRef.scrollTop
updateScrollIndicators(containerRef)
scheduleScrollPersist()
}
function scrollToBottomAndClamp(immediate = false) {
scrollToBottom(immediate)
if (hasInitialScroll) {
requestAnimationFrame(() => clampScrollAfterShrink())
} else {
hasInitialScroll = true
}
}
function scrollToTop(immediate = false) {
if (!containerRef) return
const behavior = immediate ? "auto" : "smooth"
setAutoScroll(false)
containerRef.scrollTo({ top: 0, behavior })
lastMeasuredScrollHeight = containerRef.scrollHeight
lastKnownScrollTop = containerRef.scrollTop
updateScrollIndicators(containerRef)
scheduleScrollPersist()
}
function handleContentRendered() {
if (!containerRef) return
if (!autoScroll()) return
scrollToBottomAndClamp(true)
}
createEffect(() => {
if (props.registerScrollToBottom) {
props.registerScrollToBottom(() => scrollToBottomAndClamp(true))
}
})
let pendingScrollPersist: number | null = null
function scheduleScrollPersist() {
if (pendingScrollPersist !== null) return
pendingScrollPersist = requestAnimationFrame(() => {
pendingScrollPersist = null
if (!containerRef) return
scrollCache.persist(containerRef, { atBottomOffset: 48 })
})
}
function clampScrollAfterShrink() {
if (!containerRef || !autoScroll()) return
const currentHeight = containerRef.scrollHeight
const clientHeight = containerRef.clientHeight
if (currentHeight < lastMeasuredScrollHeight) {
const maxScrollTop = Math.max(currentHeight - clientHeight, 0)
containerRef.scrollTo({ top: maxScrollTop, behavior: "auto" })
lastKnownScrollTop = containerRef.scrollTop
}
lastMeasuredScrollHeight = currentHeight
}
function handleScroll(event: Event) {
if (!containerRef) return
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
}
const isUserScroll = hasUserScrollIntent()
pendingScrollFrame = requestAnimationFrame(() => {
pendingScrollFrame = null
if (!containerRef) return
const currentTop = containerRef.scrollTop
lastKnownScrollTop = currentTop
lastMeasuredScrollHeight = containerRef.scrollHeight
const atBottom = isNearBottom(containerRef)
if (isUserScroll) {
// If the user scrolls and ends near the bottom, enable auto-scroll.
// If they scroll away from the bottom by more than our threshold,
// disable auto-scroll until they explicitly return.
if (atBottom) {
if (!autoScroll()) setAutoScroll(true)
} else {
if (autoScroll()) setAutoScroll(false)
}
}
updateScrollIndicators(containerRef)
scheduleScrollPersist()
})
}
createEffect(() => {
const target = containerRef
const loading = props.loading
if (!target) return
if (loading) return
if (hasRestoredScroll) return
scrollCache.restore(target, {
onApplied: (snapshot) => {
if (snapshot) {
setAutoScroll(snapshot.atBottom)
} else {
const atBottom = isNearBottom(target)
setAutoScroll(atBottom)
}
lastMeasuredScrollHeight = target.scrollHeight
updateScrollIndicators(target)
},
})
hasRestoredScroll = true
})
let previousToken: string | undefined
createEffect(() => {
const token = changeToken()
const loading = props.loading
if (loading) return
if (!token || token === previousToken) {
return
}
previousToken = token
if (suppressAutoScrollOnce) {
suppressAutoScrollOnce = false
return
}
if (autoScroll()) {
scrollToBottomAndClamp(true)
}
})
createEffect(() => {
preferenceSignature()
if (props.loading) return
if (!autoScroll()) {
return
}
if (suppressAutoScrollOnce) {
suppressAutoScrollOnce = false
return
}
scrollToBottomAndClamp(true)
})
createEffect(() => {
if (messageIds().length === 0) {
setShowScrollTopButton(false)
setShowScrollBottomButton(false)
setAutoScroll(true)
}
})
onCleanup(() => {
if (pendingScrollFrame !== null) {
cancelAnimationFrame(pendingScrollFrame)
pendingScrollFrame = null
}
if (pendingScrollPersist !== null) {
cancelAnimationFrame(pendingScrollPersist)
pendingScrollPersist = null
}
if (detachScrollIntentListeners) {
detachScrollIntentListeners()
detachScrollIntentListeners = undefined
}
if (containerRef) {
scrollCache.persist(containerRef, { atBottomOffset: 48 })
}
})
return (
<div class="message-stream-container">
<div class="connection-status">
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
<span class="font-semibold text-primary">{formatTokens(tokenStats().used)}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
<span class="font-semibold text-primary">
{sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"}
</span>
</div>
</div>
<div class="connection-status-text connection-status-shortcut">
<div class="connection-status-shortcut-action">
<button type="button" class="connection-status-button" onClick={handleCommandPaletteClick} 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={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
class="message-stream"
ref={setContainerRef}
onScroll={handleScroll}
>
<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>
<Index each={messageIds()}>
{(messageId) => (
<MessageBlock
messageId={messageId()}
instanceId={props.instanceId}
sessionId={props.sessionId}
store={store}
messageIndexMap={messageIndexMap}
lastAssistantIndex={lastAssistantIndex}
showThinking={() => preferences().showThinkingBlocks}
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
showUsageMetrics={showUsagePreference}
onRevert={props.onRevert}
onFork={props.onFork}
onContentRendered={handleContentRendered}
/>
)}
</Index>
</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()}
aria-label="Scroll to latest message"
>
<span class="message-scroll-icon" aria-hidden="true">
</span>
</button>
</Show>
</div>
</Show>
</div>
)
}
interface MessageBlockProps {
messageId: string
instanceId: string
sessionId: string
store: () => InstanceMessageStore
messageIndexMap: () => Map<string, number>
messageIndex: number
lastAssistantIndex: () => number
showThinking: () => boolean
thinkingDefaultExpanded: () => boolean
@@ -711,8 +235,7 @@ interface MessageBlockProps {
onContentRendered?: () => void
}
function MessageBlock(props: MessageBlockProps) {
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)
@@ -721,18 +244,14 @@ function MessageBlock(props: MessageBlockProps) {
const current = record()
if (!current) return null
const index = props.messageIndexMap().get(current.id) ?? 0
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 : ""
// Intentionally untracked: messageInfoVersion updates should not trigger
// a full message block rebuild; record revision is the invalidation key.
const info = untrack(messageInfo)
const cacheSignature = [
current.id,
current.revision,
@@ -740,8 +259,6 @@ function MessageBlock(props: MessageBlockProps) {
props.showThinking() ? 1 : 0,
props.thinkingDefaultExpanded() ? 1 : 0,
props.showUsageMetrics() ? 1 : 0,
infoTimestamp,
infoErrorName,
].join("|")
const cachedBlock = sessionCache.messageBlocks.get(current.id)
@@ -827,6 +344,21 @@ function MessageBlock(props: MessageBlockProps) {
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
@@ -908,12 +440,10 @@ function MessageBlock(props: MessageBlockProps) {
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"}>
{(() => {
@@ -922,7 +452,7 @@ function MessageBlock(props: MessageBlockProps) {
const hasToolState =
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId, props.instanceId) : null
const handleGoToTaskSession = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -952,7 +482,7 @@ function MessageBlock(props: MessageBlockProps) {
</div>
<ToolCall
toolCall={toolItem.toolPart}
toolCallId={toolItem.key}
toolCallId={toolItem.toolPart.id}
messageId={toolItem.messageId}
messageVersion={toolItem.messageVersion}
partVersion={toolItem.partVersion}
@@ -960,18 +490,12 @@ function MessageBlock(props: MessageBlockProps) {
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
/>
<StepCard kind="start" part={(item as StepDisplayItem).part} messageInfo={(item as StepDisplayItem).messageInfo} showAgentMeta />
</Match>
<Match when={item.type === "step-finish"}>
<StepCard
@@ -982,6 +506,9 @@ function MessageBlock(props: MessageBlockProps) {
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}
@@ -1010,6 +537,29 @@ interface StepCardProps {
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()
@@ -1108,6 +658,7 @@ function StepCard(props: StepCardProps) {
</div>
)
}
function formatCostValue(value: number) {
if (!value) return "$0.00"
if (value < 0.01) return `$${value.toPrecision(2)}`

View File

@@ -1,8 +1,9 @@
import { For, Show } from "solid-js"
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 {
record: MessageRecord
@@ -15,9 +16,10 @@ interface MessageItemProps {
onFork?: (messageId?: string) => void
showAgentMeta?: boolean
onContentRendered?: () => void
}
export default function MessageItem(props: MessageItemProps) {
}
export default function MessageItem(props: MessageItemProps) {
const [copied, setCopied] = createSignal(false)
const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
@@ -36,7 +38,7 @@ interface MessageItemProps {
}
const messageParts = () => props.parts
const fileAttachments = () =>
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
@@ -143,6 +145,22 @@ interface MessageItemProps {
}
}
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
}
@@ -218,8 +236,30 @@ interface MessageItemProps {
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>
<Show when={!isUser()}>
<button
class="message-action-button"
onClick={handleCopy}
title="Copy message"
aria-label="Copy message"
>
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</button>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</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

@@ -102,6 +102,8 @@ interface MessagePartProps {
>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}

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

@@ -0,0 +1,441 @@
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, ShieldAlert } 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"
toolPartIds?: string[]
}
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[]
toolPartIds: 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,
toolPartIds: isToolSegment ? pending.toolPartIds : undefined,
})
segmentIndex += 1
pending = null
}
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
if (!pending || pending.type !== type) {
flushPending()
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], 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"))
if (typeof toolPart.id === "string" && toolPart.id.length > 0) {
target.toolPartIds.push(toolPart.id)
}
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 hasActivePermission = () => {
if (segment.type !== "tool") return false
const partIds = segment.toolPartIds ?? []
if (partIds.length === 0) return false
for (const partId of partIds) {
const permissionState = store().getPermissionState(segment.messageId, partId)
if (permissionState?.active) return true
}
return false
}
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission())
const shortLabelContent = () => {
if (segment.type === "tool") {
if (hasActivePermission()) {
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
}
return segment.shortLabel ?? getToolIcon("tool")
}
if (segment.type === "compaction") {
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
}
if (segment.type === "user") {
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
}
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
}
return (
<button
ref={(el) => registerButtonRef(segment.id, el)}
type="button"
data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
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

View File

@@ -3,6 +3,9 @@ import { createEffect, createMemo, createSignal } from "solid-js"
import { providers, fetchProviders } from "../stores/sessions"
import { ChevronDown } from "lucide-solid"
import type { Model } from "../types/session"
import { getLogger } from "../lib/logger"
const log = getLogger("session")
interface ModelSelectorProps {
instanceId: string
@@ -25,7 +28,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
createEffect(() => {
if (instanceProviders().length === 0) {
fetchProviders(props.instanceId).catch(console.error)
fetchProviders(props.instanceId).catch((error) => log.error("Failed to fetch providers", error))
}
})

View File

@@ -4,6 +4,9 @@ import { useConfig } from "../stores/preferences"
import { serverApi } from "../lib/api-client"
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
import { getLogger } from "../lib/logger"
const log = getLogger("actions")
interface BinaryOption {
path: string
@@ -83,7 +86,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
setTimeout(() => {
pathsToValidate.forEach((path) => {
validateBinary(path).catch(console.error)
validateBinary(path).catch((error) => log.error("Failed to validate binary", { path, error }))
})
}, 0)
})

View File

@@ -0,0 +1,354 @@
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js"
import type { PermissionRequestLike } from "../types/permission"
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
import {
activeInterruption,
getPermissionQueue,
getQuestionQueue,
getQuestionEnqueuedAtForInstance,
setActivePermissionIdForInstance,
setActiveQuestionIdForInstance,
} from "../stores/instances"
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus"
import ToolCall from "./tool-call"
interface PermissionApprovalModalProps {
instanceId: string
isOpen: boolean
onClose: () => void
}
type ResolvedToolCall = {
messageId: string
sessionId: string
toolPart: Extract<import("../types/message").ClientPart, { type: "tool" }>
messageVersion: number
partVersion: number
}
function resolveToolCallFromPermission(
instanceId: string,
permission: PermissionRequestLike,
): ResolvedToolCall | null {
const sessionId = getPermissionSessionId(permission)
const messageId = getPermissionMessageId(permission)
if (!sessionId || !messageId) return null
const store = messageStoreBus.getInstance(instanceId)
if (!store) return null
const record = store.getMessage(messageId)
if (!record) return null
const metadata = ((permission as any).metadata || {}) as Record<string, unknown>
const directPartId =
(permission as any).partID ??
(permission as any).partId ??
(metadata as any).partID ??
(metadata as any).partId ??
undefined
const callId = getPermissionCallId(permission)
const findToolPart = (partId: string) => {
const partRecord = record.parts?.[partId]
const part = partRecord?.data
if (!part || part.type !== "tool") return null
return {
toolPart: part as ResolvedToolCall["toolPart"],
partVersion: partRecord.revision ?? 0,
}
}
if (typeof directPartId === "string" && directPartId.length > 0) {
const resolved = findToolPart(directPartId)
if (resolved) {
return {
messageId,
sessionId,
toolPart: resolved.toolPart,
messageVersion: record.revision,
partVersion: resolved.partVersion,
}
}
}
if (callId) {
for (const partId of record.partIds) {
const partRecord = record.parts?.[partId]
const part = partRecord?.data as any
if (!part || part.type !== "tool") continue
const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined
if (partCallId === callId && typeof part.id === "string" && part.id.length > 0) {
return {
messageId,
sessionId,
toolPart: part as ResolvedToolCall["toolPart"],
messageVersion: record.revision,
partVersion: partRecord.revision ?? 0,
}
}
}
}
return null
}
function resolveToolCallFromQuestion(instanceId: string, request: QuestionRequest): ResolvedToolCall | null {
const sessionId = getQuestionSessionId(request)
const messageId = getQuestionMessageId(request)
if (!sessionId || !messageId) return null
const store = messageStoreBus.getInstance(instanceId)
if (!store) return null
const record = store.getMessage(messageId)
if (!record) return null
const callId = getQuestionCallId(request)
if (!callId) return null
for (const partId of record.partIds) {
const partRecord = record.parts?.[partId]
const part = partRecord?.data as any
if (!part || part.type !== "tool") continue
const partCallId = part.callID ?? part.callId ?? part.toolCallID ?? part.toolCallId ?? undefined
if (partCallId !== callId) continue
if (typeof part.id !== "string" || part.id.length === 0) continue
return {
messageId,
sessionId,
toolPart: part as ResolvedToolCall["toolPart"],
messageVersion: record.revision,
partVersion: partRecord?.revision ?? 0,
}
}
return null
}
const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props) => {
const [loadingSession, setLoadingSession] = createSignal<string | null>(null)
const permissionQueue = createMemo(() => getPermissionQueue(props.instanceId))
const questionQueue = createMemo(() => getQuestionQueue(props.instanceId))
const active = createMemo(() => activeInterruption().get(props.instanceId) ?? null)
type InterruptionItem =
| { kind: "permission"; id: string; sessionId: string; createdAt: number; payload: PermissionRequestLike }
| { kind: "question"; id: string; sessionId: string; createdAt: number; payload: QuestionRequest }
const orderedQueue = createMemo<InterruptionItem[]>(() => {
const permissions = permissionQueue().map((permission) => ({
kind: "permission" as const,
id: permission.id,
sessionId: getPermissionSessionId(permission) || "",
createdAt: (permission as any)?.time?.created ?? Date.now(),
payload: permission,
}))
const questions = questionQueue().map((question) => ({
kind: "question" as const,
id: question.id,
sessionId: getQuestionSessionId(question) || "",
createdAt: getQuestionEnqueuedAtForInstance(props.instanceId, question.id),
payload: question,
}))
return [...permissions, ...questions].sort((a, b) => a.createdAt - b.createdAt)
})
const hasRequests = createMemo(() => orderedQueue().length > 0)
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault()
props.onClose()
}
}
createEffect(() => {
if (!props.isOpen) return
document.addEventListener("keydown", closeOnEscape)
onCleanup(() => document.removeEventListener("keydown", closeOnEscape))
})
createEffect(() => {
if (!props.isOpen) return
if (orderedQueue().length === 0) {
props.onClose()
}
})
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
props.onClose()
}
}
async function handleLoadSession(sessionId: string) {
if (!sessionId) return
setLoadingSession(sessionId)
try {
await loadMessages(props.instanceId, sessionId)
} finally {
setLoadingSession((current) => (current === sessionId ? null : current))
}
}
function handleGoToSession(sessionId: string) {
if (!sessionId) return
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
const parentId = session?.parentId ?? session?.id
if (parentId) {
ensureSessionParentExpanded(props.instanceId, parentId)
}
setActiveSessionFromList(props.instanceId, sessionId)
props.onClose()
}
return (
<Show when={props.isOpen}>
<div class="permission-center-modal-backdrop" onClick={handleBackdropClick}>
<div class="permission-center-modal" role="dialog" aria-modal="true" aria-labelledby="permission-center-title">
<div class="permission-center-modal-header">
<div class="permission-center-modal-title-row">
<h2 id="permission-center-title" class="permission-center-modal-title">
Requests
</h2>
<Show when={orderedQueue().length > 0}>
<span class="permission-center-modal-count">{orderedQueue().length}</span>
</Show>
</div>
<button type="button" class="permission-center-modal-close" onClick={props.onClose} aria-label="Close">
</button>
</div>
<div class="permission-center-modal-body">
<Show when={hasRequests()} fallback={<div class="permission-center-empty">No pending requests.</div>}>
<div class="permission-center-list" role="list">
<For each={orderedQueue()}>
{(item) => {
const isActive = () => active()?.kind === item.kind && active()?.id === item.id
const sessionId = () => item.sessionId
const resolved = createMemo(() => {
if (item.kind === "permission") {
return resolveToolCallFromPermission(props.instanceId, item.payload)
}
return resolveToolCallFromQuestion(props.instanceId, item.payload)
})
const showFallback = () => !resolved()
const kindLabel = () => (item.kind === "permission" ? "Permission" : "Question")
const primaryTitle = () => {
if (item.kind === "permission") {
return getPermissionDisplayTitle(item.payload)
}
const first = item.payload.questions?.[0]?.question
return typeof first === "string" && first.trim().length > 0 ? first : "Question"
}
const secondaryTitle = () => {
if (item.kind === "permission") {
return getPermissionKind(item.payload)
}
const count = item.payload.questions?.length ?? 0
return count === 1 ? "1 question" : `${count} questions`
}
const handleActivate = () => {
if (item.kind === "permission") {
setActivePermissionIdForInstance(props.instanceId, item.id)
} else {
setActiveQuestionIdForInstance(props.instanceId, item.id)
}
}
return (
<div
class={`permission-center-item${isActive() ? " permission-center-item-active" : ""}`}
role="listitem"
onClick={handleActivate}
>
<div class="permission-center-item-header">
<div class="permission-center-item-heading">
<span class={`permission-center-item-chip permission-center-item-chip-${item.kind}`}>{kindLabel()}</span>
<span class="permission-center-item-kind">{secondaryTitle()}</span>
<Show when={isActive()}>
<span class="permission-center-item-chip">Active</span>
</Show>
</div>
<div class="permission-center-item-actions">
<button
type="button"
class="permission-center-item-action"
onClick={(e) => {
e.stopPropagation()
handleGoToSession(sessionId())
}}
>
Go to Session
</button>
<Show when={showFallback()}>
<button
type="button"
class="permission-center-item-action"
disabled={loadingSession() === sessionId()}
onClick={(e) => {
e.stopPropagation()
handleLoadSession(sessionId())
}}
>
{loadingSession() === sessionId() ? "Loading…" : "Load Session"}
</button>
</Show>
</div>
</div>
<Show
when={resolved()}
fallback={
<div class="permission-center-fallback">
<div class="permission-center-fallback-title">
<code>{primaryTitle()}</code>
</div>
<div class="permission-center-fallback-hint">Load session for more information.</div>
</div>
}
>
{(data) => (
<ToolCall
toolCall={data().toolPart}
toolCallId={data().toolPart.id}
messageId={data().messageId}
messageVersion={data().messageVersion}
partVersion={data().partVersion}
instanceId={props.instanceId}
sessionId={data().sessionId}
/>
)}
</Show>
</div>
)
}}
</For>
</div>
</Show>
</div>
</div>
</div>
</Show>
)
}
export default PermissionApprovalModal

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