commit 8a454b8e63aa90ecd69e43264ec304ac735083db
parent bd2517c597e151e045623c48aec9ef1b1bf34d3a
Author: William Casarin <jb55@jb55.com>
Date: Wed, 25 Feb 2026 14:26:53 -0800
Merge async loading, dave backend improvements, and nostrverse refactors
async:
- Async initial timeline loading to avoid UI stalls
- Load conversations via async ndb.fold
dave:
- Per-session backend selection with backend picker UI and brand icons
- Add codex tool events, token usage, and manual compaction
- Replace dispatched_user_count with DispatchState state machine
- Extract shared backend logic, consolidate session module
- Fix codex replication, error handling, and dispatch spam
- Improve executed tool diff UX
nostrverse:
- Add dedicated relay support for multiplayer sync
- Defer relay subscription until connection is open
- Change default container from room to space
- Break down large functions into focused helpers
negentropy:
- Fix infinite sync loop with dedup and richer results
Diffstat:
20 files changed, 1854 insertions(+), 994 deletions(-)
diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
@@ -1,15 +1,18 @@
{"id":"notedeck-0mh","title":"Remote NIP-50 search","description":"GitHub #1110: Implement remote search using NIP-50 protocol. See https://github.com/damus-io/notedeck/issues/1110","status":"open","priority":2,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:41.013086749-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:41.013086749-08:00","labels":["columns"]}
+{"id":"notedeck-13z","title":"Break down sync_scene() in lib.rs","description":"sync_scene() is 116 lines (lib.rs:554-669) handling multiple responsibilities:\n- Sync room objects to scene graph\n- Read avatar position/yaw from controller\n- Compute avatar offset\n- Lerp avatar yaw\n- Dead reckoning + position smoothing for remote users\n- Sync user avatars to scene\n\nExtract standalone functions:\n- fn update_remote_user_positions(users: \u0026mut [RoomUser], dt: f32, now: f64)\n- fn compute_avatar_transform(user: \u0026RoomUser, avatar_y_offset: f32, smooth_yaw: f32) -\u003e Transform\n\nComplexity: high","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-25T13:56:36.399642717-08:00","created_by":"William Casarin","updated_at":"2026-02-25T14:20:39.380225102-08:00","closed_at":"2026-02-25T14:20:39.380225102-08:00","close_reason":"Broke down sync_scene() into sync_objects_to_scene, lerp_yaw, update_remote_user_positions, sync_users_to_scene","labels":["nostrverse","refactor"]}
{"id":"notedeck-27x","title":"Auto-steal focus should return to original session","description":"In crates/notedeck_dave, when an agent steals focus from another agent to ask a question, I want it to focus back to where it was after the interaction completes.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:38:44.526107651-08:00","created_by":"William Casarin","updated_at":"2026-01-30T22:35:00.825936422-08:00","closed_at":"2026-01-30T22:35:00.825936422-08:00","close_reason":"Auto-steal focus now returns to original session after interaction completes","labels":["dave"]}
{"id":"notedeck-2yj","title":"Profile search","description":"GitHub #1111: Extend search functionality to include profile lookups. See https://github.com/damus-io/notedeck/issues/1111","status":"open","priority":2,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:36.780164431-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:36.780164431-08:00","labels":["columns"]}
{"id":"notedeck-3ns","title":"Approve/deny view text not wrapping - goes off screen","description":"The approve/deny view for tool calls doesn't wrap text properly. Long descriptions go all the way off the screen, making them unreadable.","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:52:24.878602874-08:00","created_by":"William Casarin","updated_at":"2026-02-19T08:32:39.395960931-08:00","closed_at":"2026-02-19T08:32:39.395960931-08:00","close_reason":"Fixed by making handle_new_chat() check ai_mode - in chat mode, sessions are created directly without the directory picker overlay","labels":["dave"]}
{"id":"notedeck-4no","title":"Follow packs show blank profiles","description":"GitHub #1107: Some profiles in follow packs display blank. See https://github.com/damus-io/notedeck/issues/1107","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:46:44.374295002-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:08.222622866-08:00","closed_at":"2026-02-18T16:19:08.222622866-08:00","close_reason":"Closing - not a priority right now","labels":["columns"]}
-{"id":"notedeck-63y","title":"Extract session-detail-application pattern in lib.rs","description":"The same session detail application logic is repeated 3 times in lib.rs:\n\n1. restore_sessions_from_ndb() lines 1613-1650\n2. poll_session_state_events() lines 1806-1867\n3. poll_remote_conversation_events() (inline)\n\nEach applies hostname, custom_title, home_dir, sets up subscriptions, and seeds live threading data.\n\n## Related duplications\n- Live conversation subscription setup: repeated 4x (1630-1650, 1846-1866, 828-849, permission subs)\n- Remote session detection (hostname mismatch): duplicated at 1589-1596 and 1820-1827\n- Note polling boilerplate (poll-\u003etxn-\u003eiterate-\u003eget_note): repeated 3x at 1318-1327, 1670-1693, 1910-1925\n- Session state timestamp comparison: duplicated at 1614-1616 and 1750-1751\n\n## Approach\nExtract standalone functions:\n- apply_loaded_session_config()\n- setup_conversation_subscription()\n- is_session_remote(state, hostname)\n- Note-polling iterator helper","status":"open","priority":2,"issue_type":"chore","owner":"jb55@jb55.com","created_at":"2026-02-24T18:13:12.475093101-08:00","created_by":"William Casarin","updated_at":"2026-02-24T18:13:12.475093101-08:00"}
+{"id":"notedeck-63y","title":"Extract session-detail-application pattern in lib.rs","description":"The same session detail application logic is repeated 3 times in lib.rs:\n\n1. restore_sessions_from_ndb() lines 1613-1650\n2. poll_session_state_events() lines 1806-1867\n3. poll_remote_conversation_events() (inline)\n\nEach applies hostname, custom_title, home_dir, sets up subscriptions, and seeds live threading data.\n\n## Related duplications\n- Live conversation subscription setup: repeated 4x (1630-1650, 1846-1866, 828-849, permission subs)\n- Remote session detection (hostname mismatch): duplicated at 1589-1596 and 1820-1827\n- Note polling boilerplate (poll-\u003etxn-\u003eiterate-\u003eget_note): repeated 3x at 1318-1327, 1670-1693, 1910-1925\n- Session state timestamp comparison: duplicated at 1614-1616 and 1750-1751\n\n## Approach\nExtract standalone functions:\n- apply_loaded_session_config()\n- setup_conversation_subscription()\n- is_session_remote(state, hostname)\n- Note-polling iterator helper","status":"closed","priority":2,"issue_type":"chore","owner":"jb55@jb55.com","created_at":"2026-02-24T18:13:12.475093101-08:00","created_by":"William Casarin","updated_at":"2026-02-25T12:33:35.800606742-08:00","closed_at":"2026-02-25T12:33:35.800619293-08:00"}
+{"id":"notedeck-7kd","title":"Extract build_object_cell() from build_space() in convert.rs","description":"build_space() is 95 lines (convert.rs:111-205). The per-object loop body (lines 148-197) builds a Cell with attributes for each RoomObject.\n\nExtract:\n fn build_object_cell(obj: \u0026RoomObject) -\u003e (Cell, Vec\u003cAttribute\u003e)\n\nThis would reduce build_space() to ~40 lines of orchestration.\n\nComplexity: medium","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-25T13:56:53.056741144-08:00","created_by":"William Casarin","updated_at":"2026-02-25T14:20:39.047258493-08:00","closed_at":"2026-02-25T14:20:39.047258493-08:00","close_reason":"Extracted build_object_cell() and object_type_to_cell() from build_space() in convert.rs","labels":["nostrverse","refactor"]}
{"id":"notedeck-84e","title":"Link previews (OpenGraph)","description":"GitHub #992: Display link previews using OpenGraph metadata. See https://github.com/damus-io/notedeck/issues/992","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:51:46.117752018-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:51:46.117752018-08:00","labels":["columns"]}
{"id":"notedeck-ajn","title":"Contact lists aren't updated periodically","description":"GitHub #575: Contact lists don't refresh periodically as they should. See https://github.com/damus-io/notedeck/issues/575","status":"open","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:49:46.741346469-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:49:46.741346469-08:00","labels":["columns"]}
{"id":"notedeck-ax6","title":"Extract shared backend logic into backend/shared.rs","description":"messages_to_prompt() and get_pending_user_messages() are identical in claude.rs and codex.rs. Permission request handling (~70 lines) is nearly identical across both. Session command spawning, subagent creation/completion, and tool result summary generation are also duplicated.\n\n## Files\n- crates/notedeck_dave/src/backend/claude.rs\n- crates/notedeck_dave/src/backend/codex.rs\n\n## Specific duplications\n- messages_to_prompt(): claude.rs:76-114, codex.rs:1152-1178\n- get_pending_user_messages(): claude.rs:119-131, codex.rs:1181-1193\n- Permission request handling: claude.rs:335-418, codex.rs:715-761\n- Session command spawning: claude.rs:707-718, codex.rs:1260-1271\n- Subagent creation: claude.rs:430-455, codex.rs:499-521\n- Tool result summary: claude.rs:547-551, codex.rs:673-682,796-805,828-843\n\n## Approach\nCreate a new backend/shared.rs module with standalone functions. Update claude.rs and codex.rs to call into the shared module.","notes":"Extracted messages_to_prompt(), get_pending_user_messages(), prepare_prompt(), SessionCommand, and SessionHandle into backend/shared.rs. Both claude.rs and codex.rs now import from the shared module. All 219 tests pass. Remaining duplication (permission handling, subagent utils, tool result summary) can be done in a follow-up.\nSecond commit: extracted send_tool_result() and complete_subagent() to shared.rs. Eliminated 4 repeated tool result construction sites and 2 subagent completion sites. Remaining: permission request handling duplication.","status":"closed","priority":2,"issue_type":"chore","owner":"jb55@jb55.com","created_at":"2026-02-24T18:13:11.701612468-08:00","created_by":"William Casarin","updated_at":"2026-02-24T18:52:54.342308465-08:00","closed_at":"2026-02-24T18:52:54.342308465-08:00","close_reason":"Extracted 5 shared utilities (messages_to_prompt, get_pending_user_messages, prepare_prompt, SessionCommand/SessionHandle, send_tool_result, complete_subagent, should_auto_accept, forward_permission_to_ui) into backend/shared.rs across 3 commits. Net ~100 lines removed from claude.rs/codex.rs."}
{"id":"notedeck-azp","title":"Column requires opening app twice to show new notes","description":"GitHub #780: Columns require opening notedeck twice to display new notes on initial launch. See https://github.com/damus-io/notedeck/issues/780","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:49:39.093911034-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:07:26.357794127-08:00","closed_at":"2026-02-18T16:07:26.357794127-08:00","close_reason":"Closing - not a priority right now","labels":["columns"]}
{"id":"notedeck-bce","title":"Handle ExitPlanMode tool call","description":"Handle ExitPlanMode which simply exits plan mode. Claude-code sends this when it's done its planning phase.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:17.311242243-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:38:19.839039601-08:00","closed_at":"2026-01-30T12:38:19.839039601-08:00","close_reason":"Implemented ExitPlanMode UI with Approve/Reject buttons. When approved, exits plan mode and allows the tool call. UI shows PLAN badge with 'Plan ready for approval' message.","labels":["dave"]}
{"id":"notedeck-c3p","title":"Implement multiline message composer (Signal-style)","description":"Replaced singleline TextEdit with multiline, using Signal-style keybindings: Enter to send, Shift+Enter for newline. Based on existing dave.rs implementation.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T12:32:45.563930191-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:33:07.061369966-08:00","closed_at":"2026-01-30T12:33:07.061369966-08:00","close_reason":"Implemented: Changed TextEdit from singleline to multiline with Signal-style keybindings (Enter=send, Shift+Enter=newline) in convo.rs"}
+{"id":"notedeck-ce1","title":"Unify RoomSubscription and PresenceSubscription","description":"RoomSubscription and PresenceSubscription in subscriptions.rs have identical new() and poll() implementations differing only by the kind constant.\n\nExtract a shared helper or generic:\n- fn create_kind_subscription(ndb: \u0026Ndb, kind: u64) -\u003e Subscription\n- fn poll_subscription(sub: \u0026Subscription, ndb: \u0026Ndb, txn: \u0026Transaction) -\u003e Vec\u003cNote\u003e\n\nOr use a single generic NoteSubscription\u003cconst KIND: u16\u003e struct.\n\nFile: subscriptions.rs lines 18-76\n\nComplexity: low","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-25T13:56:23.105277151-08:00","created_by":"William Casarin","updated_at":"2026-02-25T14:08:20.404039407-08:00","closed_at":"2026-02-25T14:08:20.404039407-08:00","close_reason":"Implemented in commit f84c97686e50","labels":["nostrverse","refactor"]}
{"id":"notedeck-cf0","title":"Preserve edit view after approval/denial","description":"When approving or denying an edit, keep the diff visible instead of making it disappear. Allows reviewing what was changed even after responding.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:41:04.789975491-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:07.895887047-08:00","closed_at":"2026-02-18T16:19:07.895887047-08:00","close_reason":"Closing - not a priority right now","labels":["dave"]}
{"id":"notedeck-dx2","title":"Multi column image reply bug","description":"GitHub #1104: Images in multi-column replies appear in wrong column. See https://github.com/damus-io/notedeck/issues/1104","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:47:15.806023697-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:08.325125975-08:00","closed_at":"2026-02-18T16:19:08.325125975-08:00","close_reason":"Closing - not a priority right now","labels":["columns"]}
{"id":"notedeck-fs8","title":"Ctrl+P not dropping from NeedsInput to Done","description":"Commit c6a96d8dbfef is supposed to enable dropping from NeedsInput to Done with Ctrl+P, but it's not working. The commit message says Ctrl+P should navigate backward within a priority group and drop to the next lower priority level when at the first item (NeedsInput → Error → Done).","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:42:27.63658035-08:00","created_by":"William Casarin","updated_at":"2026-01-30T11:57:51.25992885-08:00","closed_at":"2026-01-30T11:57:51.25992885-08:00","close_reason":"Fixed swapped keybindings in keybindings.rs:82-90 - Ctrl+N now returns FocusQueueNext (higher priority) and Ctrl+P returns FocusQueuePrev (lower priority)","labels":["dave"]}
@@ -17,12 +20,16 @@
{"id":"notedeck-h9s","title":"Note not ingesting when sending locally (offline)","description":"GitHub #1050: Offline notes fail to sync when device reconnects; messages aren't appearing even after network restored. See https://github.com/damus-io/notedeck/issues/1050","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:47:34.188084601-08:00","created_by":"William Casarin","updated_at":"2026-02-17T17:11:00.7604656-08:00","closed_at":"2026-02-17T17:11:00.7604656-08:00","close_reason":"Added local ndb ingestion in post.rs execute(). Notes are now ingested into the local database before sending to relays, so they appear immediately even when offline. Commit: baa1655c55ce","labels":["columns"]}
{"id":"notedeck-hav","title":"Add auto-accept mode for agent tool calls","description":"Add a toggle that automatically approves agent tool calls without requiring manual confirmation. Useful for trusted tasks or batch mode. Could be global or per-agent.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:00.952701242-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:07.675673345-08:00","closed_at":"2026-02-18T16:19:07.675673345-08:00","close_reason":"Closing - not a priority right now","labels":["dave"]}
{"id":"notedeck-i40","title":"Quoted note target changes depending on wide or selected mode","description":"GitHub #1117: Quoted note references inconsistent based on view mode. See https://github.com/damus-io/notedeck/issues/1117","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:45:42.264025111-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:08.112313115-08:00","closed_at":"2026-02-18T16:19:08.112313115-08:00","close_reason":"Closing - not a priority right now","labels":["columns"]}
+{"id":"notedeck-ipm","title":"Break down render_editing_panel() in room_view.rs","description":"render_editing_panel() is 241 lines (room_view.rs:547-787). Combines space properties, object list, inspector, grid snap controls, and scene display.\n\nExtract standalone functions:\n- fn show_object_list(ui, state) -\u003e Option\u003cNostrverseAction\u003e\n- fn show_object_inspector(ui, obj, snap_enabled, snap_deg) -\u003e bool\n- fn show_grid_snap_controls(ui, state)\n- fn show_scene_display(ui, state)\n\nComplexity: high","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-25T13:56:47.480537758-08:00","created_by":"William Casarin","updated_at":"2026-02-25T14:24:37.879441024-08:00","closed_at":"2026-02-25T14:24:37.879441024-08:00","close_reason":"Extracted render_object_list, render_object_inspector, render_grid_snap_controls, render_scene_preview from render_editing_panel","labels":["nostrverse","refactor"]}
{"id":"notedeck-j8c","title":"Chat sidebar should show last message from user or AI","description":"Chat sidebar text should show the user's or AI's last message, not our last message.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:39:11.482262998-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:07.562465153-08:00","closed_at":"2026-02-18T16:19:07.562465153-08:00","close_reason":"Closing - not a priority right now","labels":["dave"]}
{"id":"notedeck-kpa","title":"Zap notification","description":"GitHub #1037: Notify users when zapped, showing zap amount, sender, and zapped content details. See https://github.com/damus-io/notedeck/issues/1037","status":"in_progress","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:51:29.181749931-08:00","created_by":"William Casarin","updated_at":"2026-02-19T08:33:39.25553682-08:00","labels":["columns"]}
-{"id":"notedeck-os4","title":"Consolidate session module duplications","description":"Several duplications exist across the session_*.rs files.\n\n## SessionState construction (verbatim copy)\n- session_loader.rs:292-304 (load_session_states)\n- session_loader.rs:338-350 (latest_valid_session)\nExtract: build_session_state_from_note(note, session_id) -\u003e SessionState\n\n## Truncation functions (identical implementations)\n- session_loader.rs:353-359 truncate()\n- session_jsonl.rs:306-313 truncate_str()\n- session_discovery.rs:62-68 and 74-79 (inline)\nConsolidate into a single shared utility.\n\n## Builder tag patterns in session_events.rs\n- build_permission_request_event() lines 655-671\n- build_permission_response_event() lines 702-721\n- build_session_state_event() lines 760-765\nAll share identical source/t-tag addition logic.\nExtract: add_common_event_tags(), add_permission_base_tags()\n\n## Large functions to break up\n- build_events() (130 lines, 221-350)\n- build_single_event() (94 lines, 419-512)\n- load_session_messages() (153 lines, 92-244)","status":"open","priority":3,"issue_type":"chore","owner":"jb55@jb55.com","created_at":"2026-02-24T18:13:12.82235796-08:00","created_by":"William Casarin","updated_at":"2026-02-24T18:13:12.82235796-08:00"}
+{"id":"notedeck-os4","title":"Consolidate session module duplications","description":"Several duplications exist across the session_*.rs files.\n\n## SessionState construction (verbatim copy)\n- session_loader.rs:292-304 (load_session_states)\n- session_loader.rs:338-350 (latest_valid_session)\nExtract: build_session_state_from_note(note, session_id) -\u003e SessionState\n\n## Truncation functions (identical implementations)\n- session_loader.rs:353-359 truncate()\n- session_jsonl.rs:306-313 truncate_str()\n- session_discovery.rs:62-68 and 74-79 (inline)\nConsolidate into a single shared utility.\n\n## Builder tag patterns in session_events.rs\n- build_permission_request_event() lines 655-671\n- build_permission_response_event() lines 702-721\n- build_session_state_event() lines 760-765\nAll share identical source/t-tag addition logic.\nExtract: add_common_event_tags(), add_permission_base_tags()\n\n## Large functions to break up\n- build_events() (130 lines, 221-350)\n- build_single_event() (94 lines, 419-512)\n- load_session_messages() (153 lines, 92-244)","status":"closed","priority":3,"issue_type":"chore","owner":"jb55@jb55.com","created_at":"2026-02-24T18:13:12.82235796-08:00","created_by":"William Casarin","updated_at":"2026-02-25T12:43:35.691011308-08:00","closed_at":"2026-02-25T12:43:35.691024689-08:00"}
{"id":"notedeck-p1n","title":"NIP-05 validation not working as intended","description":"GitHub #1274: NIP-05 validation feature is malfunctioning. See https://github.com/damus-io/notedeck/issues/1274","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:38:30.276030933-08:00","created_by":"William Casarin","updated_at":"2026-02-18T14:45:59.805875423-08:00","closed_at":"2026-02-18T14:45:59.805875423-08:00","close_reason":"Already fixed","labels":["columns"]}
{"id":"notedeck-pj7","title":"Like button not visible in light theme","description":"GitHub #1246: Like button rendering visibility issue when switching to light theme mode. See https://github.com/damus-io/notedeck/issues/1246","status":"closed","priority":2,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:41:16.601075159-08:00","created_by":"William Casarin","updated_at":"2026-01-30T13:38:50.529278612-08:00","closed_at":"2026-01-30T13:38:50.529278612-08:00","close_reason":"Fixed by applying text color tint unconditionally in like_button()","labels":["columns"]}
+{"id":"notedeck-uby","title":"Extract parse_vec3 helper in nostr_events.rs","description":"parse_presence_position() and parse_presence_velocity() share nearly identical Vec3 parsing logic: split whitespace, parse three f32 values, construct Vec3.\n\nExtract:\n fn parse_vec3(s: \u0026str) -\u003e Option\u003cVec3\u003e\n\nThen both callers become one-liners.\n\nFile: nostr_events.rs lines 107-127\n\nComplexity: low","status":"closed","priority":3,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-25T13:56:28.246407871-08:00","created_by":"William Casarin","updated_at":"2026-02-25T14:08:20.400624672-08:00","closed_at":"2026-02-25T14:08:20.400624672-08:00","close_reason":"Implemented in commit f84c97686e50","labels":["nostrverse","refactor"]}
{"id":"notedeck-war","title":"MacOS crash on PFP → Side menu Accounts","description":"GitHub #1270: Application crashes when navigating to Accounts via profile picture menu due to 'layer_id change panic'. See https://github.com/damus-io/notedeck/issues/1270","status":"closed","priority":1,"issue_type":"bug","owner":"jb55@jb55.com","created_at":"2026-01-30T12:41:09.082890703-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:08.002872446-08:00","closed_at":"2026-02-18T16:19:08.002872446-08:00","close_reason":"Closing - not a priority right now","labels":["columns"]}
{"id":"notedeck-xer","title":"Persist conversation across app restarts","description":"Save and restore conversation state so it survives app restarts.","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-01-30T11:40:11.196068397-08:00","created_by":"William Casarin","updated_at":"2026-02-18T16:19:07.782704625-08:00","closed_at":"2026-02-18T16:19:07.782704625-08:00","close_reason":"Closing - not a priority right now","labels":["dave"]}
+{"id":"notedeck-xh9","title":"Extract self_user()/self_user_mut() helpers on NostrverseState","description":"The pattern `self.state.users.iter().find(|u| u.is_self)` appears 3+ times in lib.rs.\n\nAdd helpers to NostrverseState:\n- `fn self_user(\u0026self) -\u003e Option\u003c\u0026RoomUser\u003e`\n- `fn self_user_mut(\u0026mut self) -\u003e Option\u003c\u0026mut RoomUser\u003e`\n\nLocations: lib.rs lines ~279, ~437-443, ~532-533\n\nComplexity: low","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-25T13:56:16.683125787-08:00","created_by":"William Casarin","updated_at":"2026-02-25T14:08:20.402708213-08:00","closed_at":"2026-02-25T14:08:20.402708213-08:00","close_reason":"Implemented in commit f84c97686e50","labels":["nostrverse","refactor"]}
{"id":"notedeck-z5q","title":"Break up Dave::update() and Dave::process_events()","description":"Dave::update() (~412 lines, lib.rs:2456-2868) and Dave::process_events() (~387 lines, lib.rs:594-981) are the two largest methods in the codebase. Each contains multiple distinct phases that could be standalone functions.\n\n## update() extractions\n- process_negentropy_sync() (~75 lines, 2463-2529)\n- handle_archive_conversion() (~80 lines, 2631-2711)\n- poll_message_load() (~30 lines, 2713-2742)\n- process_session_states() (~45 lines, 2799-2820)\n- publish_relay_events() (~25 lines, 2777-2791)\n\n## process_events() extractions\n- process_session_tokens() (~65 lines, 609-681)\n- handle_tool_call_execution() (~80 lines, 687-716)\n- handle_permission_request_event() (~45 lines, 719-776)\n- handle_stream_completion() (~55 lines, 923-969)\n\n## Also large in lib.rs\n- poll_remote_conversation_events() (~219 lines, 1886-2105)\n- poll_session_state_events() (~212 lines, 1665-1877)\n- restore_sessions_from_ndb() (~113 lines, 1546-1659)\n- poll_remote_permission_responses() (~103 lines, 1300-1403)\n\n## Approach\nExtract into standalone functions that take explicit parameters rather than \u0026mut self where possible, improving testability and making data flow explicit.","status":"closed","priority":2,"issue_type":"chore","owner":"jb55@jb55.com","created_at":"2026-02-24T18:13:12.129104216-08:00","created_by":"William Casarin","updated_at":"2026-02-25T12:22:33.668093418-08:00","closed_at":"2026-02-25T12:22:33.668095118-08:00"}
+{"id":"notedeck-ze8","title":"Break down show_room_view() in room_view.rs","description":"show_room_view() is 340 lines (room_view.rs:173-512) — the largest function in the crate. It mixes input handling, camera control, and rendering.\n\nExtract standalone functions:\n- fn handle_drag_input(ui, response, rect, state, r) -\u003e Option\u003cNostrverseAction\u003e\n- fn handle_click_input(response, rect, state, r) -\u003e Option\u003cNostrverseAction\u003e\n- fn handle_keyboard_input(ui, state) -\u003e Option\u003cNostrverseAction\u003e\n- fn handle_camera_input(ui, response, r)\n\nComplexity: high","status":"closed","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-25T13:56:42.280838595-08:00","created_by":"William Casarin","updated_at":"2026-02-25T14:24:37.554704026-08:00","closed_at":"2026-02-25T14:24:37.554704026-08:00","close_reason":"Extracted handle_drag_start, compute_initial_drag, apply_drag_update, handle_keyboard_input from show_room_view","labels":["nostrverse","refactor"]}
{"id":"notedeck-zg7","title":"Quote notification","description":"GitHub #1041: Implement notifications when users' posts are quoted by others. See https://github.com/damus-io/notedeck/issues/1041","status":"open","priority":3,"issue_type":"feature","owner":"jb55@jb55.com","created_at":"2026-01-30T12:50:58.370192754-08:00","created_by":"William Casarin","updated_at":"2026-01-30T12:50:58.370192754-08:00","labels":["columns"]}
diff --git a/Cargo.lock b/Cargo.lock
@@ -4724,13 +4724,17 @@ version = "0.7.1"
dependencies = [
"egui",
"egui-wgpu",
+ "ehttp",
"enostr",
"glam",
"nostrdb",
"notedeck",
+ "poll-promise",
"protoverse",
"renderbud",
+ "sha2 0.10.9",
"tracing",
+ "uuid",
]
[[package]]
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -233,6 +233,11 @@ async fn session_actor(
}
mode_ctx.request_repaint();
}
+ SessionCommand::Compact { response_tx: compact_tx, .. } => {
+ let _ = compact_tx.send(DaveApiResponse::Failed(
+ "Cannot compact during active turn".to_string(),
+ ));
+ }
SessionCommand::Shutdown => {
tracing::debug!("Session actor {} shutting down during query", session_id);
// Drop stream and disconnect - break to exit loop first
@@ -499,6 +504,16 @@ async fn session_actor(
}
ctx.request_repaint();
}
+ SessionCommand::Compact { response_tx, .. } => {
+ // Claude compact is normally routed via compact_session() which
+ // sends /compact as a Query. If a Compact command arrives directly,
+ // just drop the tx — the caller will see it disconnected.
+ tracing::debug!(
+ "Session {} received Compact command (not expected for Claude)",
+ session_id
+ );
+ drop(response_tx);
+ }
SessionCommand::Shutdown => {
tracing::debug!("Session actor {} shutting down", session_id);
break;
@@ -621,6 +636,29 @@ impl AiBackend for ClaudeBackend {
);
}
}
+
+ fn compact_session(
+ &self,
+ session_id: String,
+ ctx: egui::Context,
+ ) -> Option<mpsc::Receiver<DaveApiResponse>> {
+ let handle = self.sessions.get(&session_id)?;
+ let command_tx = handle.command_tx.clone();
+ let (response_tx, response_rx) = mpsc::channel();
+ tokio::spawn(async move {
+ if let Err(err) = command_tx
+ .send(SessionCommand::Query {
+ prompt: "/compact".to_string(),
+ response_tx,
+ ctx,
+ })
+ .await
+ {
+ tracing::warn!("Failed to send compact query to claude session: {}", err);
+ }
+ });
+ Some(response_rx)
+ }
}
#[cfg(test)]
diff --git a/crates/notedeck_dave/src/backend/codex.rs b/crates/notedeck_dave/src/backend/codex.rs
@@ -7,6 +7,7 @@ use crate::backend::traits::AiBackend;
use crate::file_update::{FileUpdate, FileUpdateType};
use crate::messages::{
CompactionInfo, DaveApiResponse, PermissionResponse, SessionInfo, SubagentInfo, SubagentStatus,
+ UsageInfo,
};
use crate::tools::Tool;
use crate::Message;
@@ -156,6 +157,7 @@ async fn session_actor_loop<W: AsyncWrite + Unpin, R: AsyncBufRead + Unpin>(
let mut request_counter: u64 = 10; // start after init IDs
let mut current_turn_id: Option<String> = None;
let mut sent_session_info = false;
+ let mut turn_count: u32 = 0;
while let Some(cmd) = command_rx.recv().await {
match cmd {
@@ -177,6 +179,7 @@ async fn session_actor_loop<W: AsyncWrite + Unpin, R: AsyncBufRead + Unpin>(
}
// Send turn/start
+ turn_count += 1;
request_counter += 1;
let turn_req_id = request_counter;
if let Err(err) =
@@ -261,6 +264,12 @@ async fn session_actor_loop<W: AsyncWrite + Unpin, R: AsyncBufRead + Unpin>(
mode_ctx.request_repaint();
pending_approval = Some((rpc_id, rx));
}
+ SessionCommand::Compact { response_tx: compact_tx, .. } => {
+ let _ = compact_tx.send(DaveApiResponse::Failed(
+ "Cannot compact during active turn".to_string(),
+ ));
+ pending_approval = Some((rpc_id, rx));
+ }
}
}
@@ -300,6 +309,11 @@ async fn session_actor_loop<W: AsyncWrite + Unpin, R: AsyncBufRead + Unpin>(
);
mode_ctx.request_repaint();
}
+ SessionCommand::Compact { response_tx: compact_tx, .. } => {
+ let _ = compact_tx.send(DaveApiResponse::Failed(
+ "Cannot compact during active turn".to_string(),
+ ));
+ }
SessionCommand::Shutdown => {
tracing::debug!("Session {} shutting down during query", session_id);
return;
@@ -323,6 +337,7 @@ async fn session_actor_loop<W: AsyncWrite + Unpin, R: AsyncBufRead + Unpin>(
&response_tx,
&ctx,
&mut subagent_stack,
+ &turn_count,
) {
HandleResult::Continue => {}
HandleResult::TurnDone => {
@@ -374,6 +389,120 @@ async fn session_actor_loop<W: AsyncWrite + Unpin, R: AsyncBufRead + Unpin>(
);
ctx.request_repaint();
}
+ SessionCommand::Compact { response_tx, ctx } => {
+ request_counter += 1;
+ let compact_req_id = request_counter;
+
+ // Send thread/compact/start RPC
+ if let Err(err) = send_thread_compact(&mut writer, compact_req_id, &thread_id).await
+ {
+ tracing::error!(
+ "Session {} thread/compact/start failed: {}",
+ session_id,
+ err
+ );
+ let _ = response_tx.send(DaveApiResponse::Failed(err));
+ ctx.request_repaint();
+ continue;
+ }
+
+ // Read the RPC response (empty {})
+ match read_response_for_id(&mut reader, compact_req_id).await {
+ Ok(msg) => {
+ if let Some(err) = msg.error {
+ tracing::error!(
+ "Session {} thread/compact/start error: {}",
+ session_id,
+ err.message
+ );
+ let _ = response_tx.send(DaveApiResponse::Failed(err.message));
+ ctx.request_repaint();
+ continue;
+ }
+ }
+ Err(err) => {
+ tracing::error!(
+ "Session {} failed reading compact response: {}",
+ session_id,
+ err
+ );
+ let _ = response_tx.send(DaveApiResponse::Failed(err));
+ ctx.request_repaint();
+ continue;
+ }
+ }
+
+ // Compact accepted — stream notifications until item/completed
+ let _ = response_tx.send(DaveApiResponse::CompactionStarted);
+ ctx.request_repaint();
+
+ loop {
+ tokio::select! {
+ biased;
+
+ Some(cmd) = command_rx.recv() => {
+ match cmd {
+ SessionCommand::Shutdown => {
+ tracing::debug!("Session {} shutting down during compact", session_id);
+ return;
+ }
+ _ => {
+ // Ignore other commands during compaction
+ }
+ }
+ }
+
+ line_result = reader.next_line() => {
+ match line_result {
+ Ok(Some(line)) => {
+ let msg: RpcMessage = match serde_json::from_str(&line) {
+ Ok(m) => m,
+ Err(err) => {
+ tracing::warn!("Codex parse error during compact: {}", err);
+ continue;
+ }
+ };
+
+ // Look for item/completed with contextCompaction
+ if msg.method.as_deref() == Some("item/completed") {
+ if let Some(ref params) = msg.params {
+ let item_type = params.get("type")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ if item_type == "contextCompaction" {
+ let pre_tokens = params.get("preTokens")
+ .and_then(|v| v.as_u64())
+ .unwrap_or(0);
+ let _ = response_tx.send(DaveApiResponse::CompactionComplete(
+ CompactionInfo { pre_tokens },
+ ));
+ ctx.request_repaint();
+ break;
+ }
+ }
+ }
+ }
+ Ok(None) => {
+ tracing::error!("Session {} codex process exited during compact", session_id);
+ let _ = response_tx.send(DaveApiResponse::Failed(
+ "Codex process exited during compaction".to_string(),
+ ));
+ break;
+ }
+ Err(err) => {
+ tracing::error!("Session {} read error during compact: {}", session_id, err);
+ let _ = response_tx.send(DaveApiResponse::Failed(err.to_string()));
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Drop response channel to signal completion
+ drop(response_tx);
+ tracing::debug!("Compaction complete for session {}", session_id);
+ }
SessionCommand::Shutdown => {
tracing::debug!("Session {} shutting down", session_id);
break;
@@ -450,6 +579,7 @@ fn handle_codex_message(
response_tx: &mpsc::Sender<DaveApiResponse>,
ctx: &egui::Context,
subagent_stack: &mut Vec<String>,
+ turn_count: &u32,
) -> HandleResult {
let method = match &msg.method {
Some(m) => m.as_str(),
@@ -479,22 +609,57 @@ fn handle_codex_message(
"item/started" => {
if let Some(params) = msg.params {
if let Ok(started) = serde_json::from_value::<ItemStartedParams>(params) {
- if started.item_type == "collabAgentToolCall" {
- let item_id = started
- .item_id
- .unwrap_or_else(|| Uuid::new_v4().to_string());
- subagent_stack.push(item_id.clone());
- let info = SubagentInfo {
- task_id: item_id,
- description: started.name.unwrap_or_else(|| "agent".to_string()),
- subagent_type: "codex-agent".to_string(),
- status: SubagentStatus::Running,
- output: String::new(),
- max_output_size: 4000,
- tool_results: Vec::new(),
- };
- let _ = response_tx.send(DaveApiResponse::SubagentSpawned(info));
- ctx.request_repaint();
+ match started.item_type.as_str() {
+ "collabAgentToolCall" => {
+ let item_id = started
+ .item_id
+ .unwrap_or_else(|| Uuid::new_v4().to_string());
+ subagent_stack.push(item_id.clone());
+ let info = SubagentInfo {
+ task_id: item_id,
+ description: started.name.unwrap_or_else(|| "agent".to_string()),
+ subagent_type: "codex-agent".to_string(),
+ status: SubagentStatus::Running,
+ output: String::new(),
+ max_output_size: 4000,
+ tool_results: Vec::new(),
+ };
+ let _ = response_tx.send(DaveApiResponse::SubagentSpawned(info));
+ ctx.request_repaint();
+ }
+ "commandExecution" => {
+ let cmd = started.command.unwrap_or_default();
+ let tool_input = serde_json::json!({ "command": cmd });
+ let result_value = serde_json::json!({});
+ shared::send_tool_result(
+ "Bash",
+ &tool_input,
+ &result_value,
+ None,
+ subagent_stack,
+ response_tx,
+ ctx,
+ );
+ }
+ "fileChange" => {
+ let path = started.file_path.unwrap_or_default();
+ let tool_input = serde_json::json!({ "file_path": path });
+ let result_value = serde_json::json!({});
+ shared::send_tool_result(
+ "Edit",
+ &tool_input,
+ &result_value,
+ None,
+ subagent_stack,
+ response_tx,
+ ctx,
+ );
+ }
+ "contextCompaction" => {
+ let _ = response_tx.send(DaveApiResponse::CompactionStarted);
+ ctx.request_repaint();
+ }
+ _ => {}
}
}
}
@@ -600,6 +765,21 @@ fn handle_codex_message(
}
}
+ "thread/tokenUsage/updated" => {
+ if let Some(params) = msg.params {
+ if let Ok(usage) = serde_json::from_value::<TokenUsageParams>(params) {
+ let info = UsageInfo {
+ input_tokens: usage.token_usage.total.input_tokens as u64,
+ output_tokens: usage.token_usage.total.output_tokens as u64,
+ num_turns: *turn_count,
+ cost_usd: None,
+ };
+ let _ = response_tx.send(DaveApiResponse::QueryComplete(info));
+ ctx.request_repaint();
+ }
+ }
+ }
+
"turn/completed" => {
if let Some(params) = msg.params {
if let Ok(completed) = serde_json::from_value::<TurnCompletedParams>(params) {
@@ -1024,6 +1204,25 @@ async fn send_turn_interrupt<W: AsyncWrite + Unpin>(
.map_err(|e| format!("Failed to send turn/interrupt: {}", e))
}
+/// Send `thread/compact/start`.
+async fn send_thread_compact<W: AsyncWrite + Unpin>(
+ writer: &mut tokio::io::BufWriter<W>,
+ req_id: u64,
+ thread_id: &str,
+) -> Result<(), String> {
+ let req = RpcRequest {
+ id: Some(req_id),
+ method: "thread/compact/start",
+ params: ThreadCompactParams {
+ thread_id: thread_id.to_string(),
+ },
+ };
+
+ send_request(writer, &req)
+ .await
+ .map_err(|e| format!("Failed to send thread/compact/start: {}", e))
+}
+
/// Read lines until we find a response matching the given request id.
/// Non-matching messages (notifications) are logged and skipped.
async fn read_response_for_id<R: AsyncBufRead + Unpin>(
@@ -1063,11 +1262,12 @@ async fn drain_commands_with_error(
error: &str,
) {
while let Some(cmd) = command_rx.recv().await {
- if let SessionCommand::Query {
- ref response_tx, ..
- } = cmd
- {
- let _ = response_tx.send(DaveApiResponse::Failed(error.to_string()));
+ match &cmd {
+ SessionCommand::Query { response_tx, .. }
+ | SessionCommand::Compact { response_tx, .. } => {
+ let _ = response_tx.send(DaveApiResponse::Failed(error.to_string()));
+ }
+ _ => {}
}
if matches!(cmd, SessionCommand::Shutdown) {
break;
@@ -1197,6 +1397,25 @@ impl AiBackend for CodexBackend {
});
}
}
+
+ fn compact_session(
+ &self,
+ session_id: String,
+ ctx: egui::Context,
+ ) -> Option<mpsc::Receiver<DaveApiResponse>> {
+ let handle = self.sessions.get(&session_id)?;
+ let command_tx = handle.command_tx.clone();
+ let (response_tx, response_rx) = mpsc::channel();
+ tokio::spawn(async move {
+ if let Err(err) = command_tx
+ .send(SessionCommand::Compact { response_tx, ctx })
+ .await
+ {
+ tracing::warn!("Failed to send compact to codex session: {}", err);
+ }
+ });
+ Some(response_rx)
+ }
}
#[cfg(test)]
@@ -1324,7 +1543,7 @@ mod tests {
let msg = notification("item/agentMessage/delta", json!({ "delta": "Hello world" }));
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
assert!(matches!(result, HandleResult::Continue));
let response = rx.try_recv().unwrap();
@@ -1341,7 +1560,7 @@ mod tests {
let mut subagents = Vec::new();
let msg = notification("turn/completed", json!({ "status": "completed" }));
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
assert!(matches!(result, HandleResult::TurnDone));
}
@@ -1355,7 +1574,7 @@ mod tests {
"turn/completed",
json!({ "status": "failed", "error": "rate limit exceeded" }),
);
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
assert!(matches!(result, HandleResult::TurnDone));
let response = rx.try_recv().unwrap();
@@ -1379,7 +1598,7 @@ mod tests {
error: None,
params: None,
};
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
assert!(matches!(result, HandleResult::Continue));
assert!(rx.try_recv().is_err()); // nothing sent
}
@@ -1391,7 +1610,7 @@ mod tests {
let mut subagents = Vec::new();
let msg = notification("some/future/event", json!({}));
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
assert!(matches!(result, HandleResult::Continue));
assert!(rx.try_recv().is_err());
}
@@ -1406,7 +1625,7 @@ mod tests {
"codex/event/error",
json!({ "message": "context window exceeded" }),
);
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
assert!(matches!(result, HandleResult::Continue));
let response = rx.try_recv().unwrap();
@@ -1423,7 +1642,7 @@ mod tests {
let mut subagents = Vec::new();
let msg = notification("error", json!({ "message": "something broke" }));
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
assert!(matches!(result, HandleResult::Continue));
let response = rx.try_recv().unwrap();
@@ -1440,7 +1659,7 @@ mod tests {
let mut subagents = Vec::new();
let msg = notification("codex/event/error", json!({}));
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
assert!(matches!(result, HandleResult::Continue));
let response = rx.try_recv().unwrap();
@@ -1469,7 +1688,7 @@ mod tests {
"conversationId": "thread-1"
}),
);
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
assert!(matches!(result, HandleResult::Continue));
let response = rx.try_recv().unwrap();
@@ -1499,7 +1718,7 @@ mod tests {
"turnId": "1"
}),
);
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
assert!(matches!(result, HandleResult::Continue));
let response = rx.try_recv().unwrap();
@@ -1523,7 +1742,7 @@ mod tests {
"name": "research agent"
}),
);
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
assert!(matches!(result, HandleResult::Continue));
assert_eq!(subagents.len(), 1);
assert_eq!(subagents[0], "agent-1");
@@ -1553,7 +1772,7 @@ mod tests {
"item/commandExecution/requestApproval",
json!({ "command": "git status" }),
);
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
match result {
HandleResult::AutoAccepted(id) => assert_eq!(id, 99),
other => panic!(
@@ -1577,7 +1796,7 @@ mod tests {
"item/commandExecution/requestApproval",
json!({ "command": "rm -rf /" }),
);
- let result = handle_codex_message(msg, &tx, &ctx, &mut subagents);
+ let result = handle_codex_message(msg, &tx, &ctx, &mut subagents, &0);
match result {
HandleResult::NeedsApproval { rpc_id, .. } => assert_eq!(rpc_id, 100),
other => panic!(
diff --git a/crates/notedeck_dave/src/backend/codex_protocol.rs b/crates/notedeck_dave/src/backend/codex_protocol.rs
@@ -83,6 +83,13 @@ pub struct ThreadResumeParams {
pub thread_id: String,
}
+/// `thread/compact/start` params
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ThreadCompactParams {
+ pub thread_id: String,
+}
+
/// `turn/start` params
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -255,3 +262,23 @@ pub struct TurnCompletedParams {
pub turn_id: Option<String>,
pub error: Option<String>,
}
+
+/// `thread/tokenUsage/updated` params
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TokenUsageParams {
+ pub token_usage: TokenUsage,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TokenUsage {
+ pub total: TokenBreakdown,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TokenBreakdown {
+ pub input_tokens: i64,
+ pub output_tokens: i64,
+}
diff --git a/crates/notedeck_dave/src/backend/shared.rs b/crates/notedeck_dave/src/backend/shared.rs
@@ -29,6 +29,11 @@ pub(crate) enum SessionCommand {
mode: PermissionMode,
ctx: egui::Context,
},
+ /// Trigger manual context compaction
+ Compact {
+ response_tx: mpsc::Sender<DaveApiResponse>,
+ ctx: egui::Context,
+ },
Shutdown,
}
diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs
@@ -97,4 +97,15 @@ pub trait AiBackend: Send + Sync {
/// Set the permission mode for a session.
/// Plan mode makes Claude plan actions without executing them.
fn set_permission_mode(&self, session_id: String, mode: PermissionMode, ctx: egui::Context);
+
+ /// Trigger manual context compaction for a session.
+ /// Returns a receiver for CompactionStarted/CompactionComplete events.
+ /// Default implementation does nothing (backends that don't support it).
+ fn compact_session(
+ &self,
+ _session_id: String,
+ _ctx: egui::Context,
+ ) -> Option<mpsc::Receiver<DaveApiResponse>> {
+ None
+ }
}
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -668,6 +668,19 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
}
+ // Backend produced real content — transition dispatch
+ // state so redispatch knows the backend consumed our
+ // messages (AwaitingResponse → Streaming).
+ if !matches!(
+ res,
+ DaveApiResponse::SessionInfo(_)
+ | DaveApiResponse::CompactionStarted
+ | DaveApiResponse::CompactionComplete(_)
+ | DaveApiResponse::QueryComplete(_)
+ ) {
+ session.dispatch_state.backend_responded();
+ }
+
match res {
DaveApiResponse::Failed(ref err) => {
session.chat.push(Message::Error(err.to_string()));
@@ -2213,6 +2226,19 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
self.auto_steal_focus = new_state;
None
}
+ UiActionResult::Compact => {
+ if let Some(session) = self.session_manager.get_active() {
+ let session_id = session.id.to_string();
+ if let Some(rx) = get_backend(&self.backends, bt)
+ .compact_session(session_id, ui.ctx().clone())
+ {
+ if let Some(session) = self.session_manager.get_active_mut() {
+ session.incoming_tokens = Some(rx);
+ }
+ }
+ }
+ None
+ }
UiActionResult::Handled => None,
}
}
@@ -2256,10 +2282,11 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
return;
}
- // If already streaming, queue the message in chat without dispatching.
+ // If already dispatched (waiting for or receiving response), queue
+ // the message in chat without dispatching.
// needs_redispatch_after_stream_end() will dispatch it when the
// current turn finishes.
- if session.is_streaming() {
+ if session.is_dispatched() {
tracing::info!("message queued, will dispatch after current turn");
return;
}
@@ -2287,15 +2314,10 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
return;
}
- // Count trailing user messages being dispatched so append_token
- // knows how many to skip when inserting the assistant response.
- let trailing_user_count = session
- .chat
- .iter()
- .rev()
- .take_while(|m| matches!(m, Message::User(_)))
- .count();
- session.dispatched_user_count = trailing_user_count;
+ // Record how many trailing user messages we're dispatching.
+ // DispatchState tracks this for append_token insert position,
+ // UI queued indicator, and redispatch-after-stream-end logic.
+ session.mark_dispatched();
let user_id = calculate_user_id(app_ctx.accounts.get_selected_account().keypair());
let session_id = format!("dave-session-{}", session.id);
@@ -2865,8 +2887,22 @@ fn handle_stream_end(
session.task_handle = None;
- // If chat ends with a user message, there's an unanswered remote message
- // that arrived while we were streaming. Queue it for dispatch.
+ // If the backend returned nothing (dispatch_state never left
+ // AwaitingResponse), show an error so the user isn't left staring
+ // at silence.
+ if matches!(
+ session.dispatch_state,
+ session::DispatchState::AwaitingResponse { .. }
+ ) && session.last_assistant_text().is_none()
+ {
+ tracing::warn!("Session {}: backend returned empty response", session_id);
+ session
+ .chat
+ .push(Message::Error("No response from backend".into()));
+ }
+
+ // Check redispatch BEFORE resetting dispatch_state — the check
+ // reads the state to distinguish empty responses from new messages.
if session.needs_redispatch_after_stream_end() {
tracing::info!(
"Session {}: redispatching queued user message after stream end",
@@ -2875,6 +2911,8 @@ fn handle_stream_end(
needs_send.insert(session_id);
}
+ session.dispatch_state.stream_ended();
+
// After compact & approve: compaction must have completed
// (ReadyToProceed) before we send "Proceed".
if session.take_compact_and_proceed() {
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -311,6 +311,51 @@ impl AgenticSessionData {
}
}
+/// Tracks the lifecycle of a dispatch to the AI backend.
+///
+/// Transitions:
+/// - `Idle → AwaitingResponse` when `send_user_message_for()` dispatches
+/// - `AwaitingResponse → Streaming` when the backend produces content
+/// - `Streaming | AwaitingResponse → Idle` at stream end
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub enum DispatchState {
+ /// No active dispatch.
+ #[default]
+ Idle,
+ /// Dispatched `count` trailing user messages; backend hasn't
+ /// produced visible content yet.
+ AwaitingResponse { count: usize },
+ /// Backend is actively producing content for this dispatch.
+ Streaming { dispatched_count: usize },
+}
+
+impl DispatchState {
+ /// Number of user messages that were dispatched in the current batch.
+ /// Used by `append_token` for insert position and UI for queued indicator.
+ pub fn dispatched_count(&self) -> usize {
+ match self {
+ DispatchState::Idle => 0,
+ DispatchState::AwaitingResponse { count } => *count,
+ DispatchState::Streaming { dispatched_count } => *dispatched_count,
+ }
+ }
+
+ /// Transition: backend produced content.
+ /// `AwaitingResponse → Streaming`; other states unchanged.
+ pub fn backend_responded(&mut self) {
+ if let DispatchState::AwaitingResponse { count } = *self {
+ *self = DispatchState::Streaming {
+ dispatched_count: count,
+ };
+ }
+ }
+
+ /// Transition: stream ended. Resets to `Idle`.
+ pub fn stream_ended(&mut self) {
+ *self = DispatchState::Idle;
+ }
+}
+
/// A single chat session with Dave
pub struct ChatSession {
pub id: SessionId,
@@ -320,10 +365,8 @@ pub struct ChatSession {
/// Handle to the background task processing this session's AI requests.
/// Aborted on drop to clean up the subprocess.
pub task_handle: Option<tokio::task::JoinHandle<()>>,
- /// Number of trailing user messages that were dispatched in the current
- /// stream. Used by `append_token` to insert the assistant response
- /// after all dispatched messages but before any newly queued ones.
- pub dispatched_user_count: usize,
+ /// Tracks the dispatch lifecycle for redispatch and insert-position logic.
+ pub dispatch_state: DispatchState,
/// Cached status for the agent (derived from session state)
cached_status: AgentStatus,
/// Set when cached_status changes, cleared after publishing state event
@@ -368,7 +411,7 @@ impl ChatSession {
input: String::new(),
incoming_tokens: None,
task_handle: None,
- dispatched_user_count: 0,
+ dispatch_state: DispatchState::Idle,
cached_status: AgentStatus::Idle,
state_dirty: false,
focus_requested: false,
@@ -821,6 +864,13 @@ impl ChatSession {
self.incoming_tokens.is_some()
}
+ /// Whether a dispatch is active (message sent to backend, waiting for
+ /// or receiving response). This is more reliable than `is_streaming()`
+ /// because it covers the window between dispatch and first token arrival.
+ pub fn is_dispatched(&self) -> bool {
+ !matches!(self.dispatch_state, DispatchState::Idle)
+ }
+
/// Append a streaming token to the current assistant message.
///
/// If the last message is an Assistant, append there. Otherwise
@@ -834,6 +884,9 @@ impl ChatSession {
/// call → more text, the post-tool tokens must go into a NEW
/// Assistant so the tool call appears between the two text blocks.
pub fn append_token(&mut self, token: &str) {
+ // Content arrived — transition AwaitingResponse → Streaming.
+ self.dispatch_state.backend_responded();
+
// Fast path: last message is the active assistant response
if let Some(Message::Assistant(msg)) = self.chat.last_mut() {
msg.push_token(token);
@@ -872,7 +925,7 @@ impl ChatSession {
// Skip past the dispatched user messages (default 1 for
// single dispatch, more for batch redispatch)
- let skip = self.dispatched_user_count.max(1);
+ let skip = self.dispatch_state.dispatched_count().max(1);
let insert_pos = (trailing_start + skip).min(self.chat.len());
self.chat.insert(insert_pos, Message::Assistant(msg));
}
@@ -916,17 +969,47 @@ impl ChatSession {
}
/// Whether a newly arrived remote user message should be dispatched to
- /// the backend right now. Returns false if the session is already
- /// streaming — the message is already in chat and will be picked up
+ /// the backend right now. Returns false if a dispatch is already
+ /// active — the message is already in chat and will be picked up
/// when the current stream finishes.
pub fn should_dispatch_remote_message(&self) -> bool {
- !self.is_streaming() && self.has_pending_user_message()
+ !self.is_dispatched() && self.has_pending_user_message()
+ }
+
+ /// Mark the current trailing user messages as dispatched to the backend.
+ /// Call this when starting a new stream for this session.
+ pub fn mark_dispatched(&mut self) {
+ let count = self.trailing_user_count();
+ self.dispatch_state = DispatchState::AwaitingResponse { count };
+ }
+
+ /// Count trailing user messages at the end of the chat.
+ pub fn trailing_user_count(&self) -> usize {
+ self.chat
+ .iter()
+ .rev()
+ .take_while(|m| matches!(m, Message::User(_)))
+ .count()
}
/// Whether the session needs a re-dispatch after a stream ends.
/// This catches user messages that arrived while we were streaming.
+ ///
+ /// Uses `dispatch_state` to distinguish genuinely new messages from
+ /// messages that were already dispatched:
+ ///
+ /// - `Streaming`: backend responded, so any trailing user messages
+ /// are genuinely new (queued during the response).
+ /// - `AwaitingResponse`: backend returned empty. Only redispatch if
+ /// NEW messages arrived beyond what was dispatched (prevents the
+ /// infinite loop on empty responses).
+ /// - `Idle`: nothing to redispatch.
pub fn needs_redispatch_after_stream_end(&self) -> bool {
- !self.is_streaming() && self.has_pending_user_message()
+ match self.dispatch_state {
+ DispatchState::Streaming { .. } => self.has_pending_user_message(),
+ DispatchState::AwaitingResponse { count } => self.trailing_user_count() > count,
+ DispatchState::Idle => false,
+ }
}
/// If "Compact & Approve" has reached ReadyToProceed, consume the state,
@@ -980,9 +1063,8 @@ mod tests {
let mut session = test_session();
session.chat.push(Message::User("hello".into()));
- // Start streaming
- let (_tx, rx) = mpsc::channel::<DaveApiResponse>();
- session.incoming_tokens = Some(rx);
+ // Dispatch and start streaming
+ let _tx = make_streaming(&mut session);
// New user message arrives while streaming
session.chat.push(Message::User("another".into()));
@@ -994,16 +1076,14 @@ mod tests {
let mut session = test_session();
session.chat.push(Message::User("msg1".into()));
- // Start streaming
- let (tx, rx) = mpsc::channel::<DaveApiResponse>();
- session.incoming_tokens = Some(rx);
+ // Dispatch and start streaming
+ let tx = make_streaming(&mut session);
- // Assistant responds, then more user messages arrive
- session
- .chat
- .push(Message::Assistant(AssistantMessage::from_text(
- "response".into(),
- )));
+ // Assistant responds via append_token (transitions to Streaming)
+ session.append_token("response");
+ session.finalize_last_assistant();
+
+ // New user message arrives while stream is still open
session.chat.push(Message::User("msg2".into()));
// Stream ends
@@ -1018,14 +1098,12 @@ mod tests {
let mut session = test_session();
session.chat.push(Message::User("hello".into()));
- let (tx, rx) = mpsc::channel::<DaveApiResponse>();
- session.incoming_tokens = Some(rx);
+ // Dispatch and start streaming
+ let tx = make_streaming(&mut session);
- session
- .chat
- .push(Message::Assistant(AssistantMessage::from_text(
- "done".into(),
- )));
+ // Backend responds
+ session.append_token("done");
+ session.finalize_last_assistant();
drop(tx);
session.incoming_tokens = None;
@@ -1044,9 +1122,8 @@ mod tests {
session.chat.push(Message::User("msg1".into()));
assert!(session.should_dispatch_remote_message());
- // Backend starts streaming
- let (tx, rx) = mpsc::channel::<DaveApiResponse>();
- session.incoming_tokens = Some(rx);
+ // Dispatch and start streaming
+ let tx = make_streaming(&mut session);
// Messages arrive one per frame while streaming
session.chat.push(Message::User("msg2".into()));
@@ -1055,11 +1132,11 @@ mod tests {
session.chat.push(Message::User("msg3".into()));
assert!(!session.should_dispatch_remote_message());
- // Stream ends
+ // Stream ends (backend didn't produce content — e.g. connection dropped)
drop(tx);
session.incoming_tokens = None;
- // Should redispatch — there are unanswered user messages
+ // Should redispatch — new messages arrived beyond what was dispatched
assert!(session.needs_redispatch_after_stream_end());
}
@@ -1090,8 +1167,9 @@ mod tests {
fn tokens_after_queued_message_dont_bury_it() {
let mut session = test_session();
- // User sends initial message, assistant starts responding
+ // User sends initial message, dispatched and streaming starts
session.chat.push(Message::User("hello".into()));
+ let _tx = make_streaming(&mut session);
session.append_token("Sure, ");
session.append_token("I can ");
@@ -1122,6 +1200,7 @@ mod tests {
let mut session = test_session();
session.chat.push(Message::User("first".into()));
+ let _tx = make_streaming(&mut session);
session.append_token("response");
// Queue two messages
@@ -1202,7 +1281,7 @@ mod tests {
// User sends a new message, dispatched to Claude (single dispatch)
session.chat.push(Message::User("follow up".into()));
- session.dispatched_user_count = 1;
+ session.mark_dispatched();
// User queues another message BEFORE any tokens arrive
session.chat.push(Message::User("queued msg".into()));
@@ -1317,8 +1396,11 @@ mod tests {
// ---- status tests ----
- /// Helper to put a session into "streaming" state
+ /// Helper to put a session into "streaming" state.
+ /// Also calls `mark_dispatched()` to mirror what `send_user_message_for()`
+ /// does in real code — the trailing user messages are marked as dispatched.
fn make_streaming(session: &mut ChatSession) -> mpsc::Sender<DaveApiResponse> {
+ session.mark_dispatched();
let (tx, rx) = mpsc::channel::<DaveApiResponse>();
session.incoming_tokens = Some(rx);
tx
@@ -1367,10 +1449,9 @@ mod tests {
// Step 1: User sends first message, it gets dispatched (single)
session.chat.push(Message::User("hello".into()));
- session.dispatched_user_count = 1;
assert!(session.should_dispatch_remote_message());
- // Backend starts streaming
+ // Backend starts streaming (mark_dispatched called by make_streaming)
let tx = make_streaming(&mut session);
assert!(session.is_streaming());
assert!(!session.should_dispatch_remote_message());
@@ -1415,7 +1496,6 @@ mod tests {
assert_eq!(prompt, "also\ndo this\nand this");
// Step 5: Backend dispatches with the batch prompt (3 messages)
- session.dispatched_user_count = 3;
let _tx2 = make_streaming(&mut session);
// New tokens arrive — should create a new assistant after ALL
@@ -1455,7 +1535,6 @@ mod tests {
// Turn 1: single dispatch
session.chat.push(Message::User("first".into()));
- session.dispatched_user_count = 1;
let tx = make_streaming(&mut session);
session.append_token("response 1");
session.chat.push(Message::User("queued A".into()));
@@ -1466,7 +1545,6 @@ mod tests {
assert!(session.needs_redispatch_after_stream_end());
// Turn 2: batch redispatch handles both queued messages
- session.dispatched_user_count = 2;
let tx2 = make_streaming(&mut session);
session.append_token("response 2");
session.finalize_last_assistant();
@@ -1487,7 +1565,6 @@ mod tests {
let mut session = test_session();
session.chat.push(Message::User("hello".into()));
- session.dispatched_user_count = 1;
let tx = make_streaming(&mut session);
// Error arrives (no tokens were sent)
@@ -1505,6 +1582,30 @@ mod tests {
);
}
+ /// When the backend returns immediately with no content (e.g. a
+ /// skill command it can't handle), the dispatched user message is
+ /// still the last in chat. Without the trailing-count guard this
+ /// would trigger an infinite redispatch loop.
+ #[test]
+ fn empty_response_prevents_redispatch_loop() {
+ let mut session = test_session();
+
+ session
+ .chat
+ .push(Message::User("/refactor something".into()));
+ let tx = make_streaming(&mut session);
+
+ // Backend returns immediately — no tokens, no tools, nothing
+ session.finalize_last_assistant();
+ drop(tx);
+ session.incoming_tokens = None;
+
+ assert!(
+ !session.needs_redispatch_after_stream_end(),
+ "should not redispatch already-dispatched messages with empty response"
+ );
+ }
+
/// Verify chat ordering when queued messages arrive before any
/// tokens, and after tokens, across a full batch lifecycle.
#[test]
@@ -1518,7 +1619,6 @@ mod tests {
// User sends new message (single dispatch)
session.chat.push(Message::User("question".into()));
- session.dispatched_user_count = 1;
let tx = make_streaming(&mut session);
// Queued BEFORE first token
@@ -1573,7 +1673,7 @@ mod tests {
fn find_queued_indices(
chat: &[Message],
is_working: bool,
- dispatched_user_count: usize,
+ dispatch_state: DispatchState,
) -> Vec<usize> {
if !is_working {
return vec![];
@@ -1590,7 +1690,7 @@ mod tests {
}
Some(i) => {
let first_trailing = i + 1;
- let skip = dispatched_user_count.max(1);
+ let skip = dispatch_state.dispatched_count().max(1);
let queued_start = first_trailing + skip;
if queued_start < chat.len() {
Some(queued_start)
@@ -1624,8 +1724,12 @@ mod tests {
session.chat.push(Message::User("queued 1".into()));
session.chat.push(Message::User("queued 2".into()));
- // dispatched_user_count=1: single dispatch
- let queued = find_queued_indices(&session.chat, true, 1);
+ // Single dispatch
+ let queued = find_queued_indices(
+ &session.chat,
+ true,
+ DispatchState::AwaitingResponse { count: 1 },
+ );
let queued_texts: Vec<&str> = queued
.iter()
.map(|&i| match &session.chat[i] {
@@ -1650,9 +1754,13 @@ mod tests {
session.chat.push(Message::User("queued 1".into()));
session.chat.push(Message::User("queued 2".into()));
- // dispatched_user_count doesn't matter here — streaming
- // assistant branch doesn't use it
- let queued = find_queued_indices(&session.chat, true, 1);
+ // Dispatch state doesn't matter here — streaming assistant
+ // branch doesn't use the dispatched count
+ let queued = find_queued_indices(
+ &session.chat,
+ true,
+ DispatchState::AwaitingResponse { count: 1 },
+ );
let queued_texts: Vec<&str> = queued
.iter()
.map(|&i| match &session.chat[i] {
@@ -1674,7 +1782,7 @@ mod tests {
session.chat.push(Message::User("msg 1".into()));
session.chat.push(Message::User("msg 2".into()));
- let queued = find_queued_indices(&session.chat, false, 0);
+ let queued = find_queued_indices(&session.chat, false, DispatchState::Idle);
assert!(
queued.is_empty(),
"nothing should be queued when not working"
@@ -1692,7 +1800,11 @@ mod tests {
)));
session.chat.push(Message::User("only one".into()));
- let queued = find_queued_indices(&session.chat, true, 1);
+ let queued = find_queued_indices(
+ &session.chat,
+ true,
+ DispatchState::AwaitingResponse { count: 1 },
+ );
assert!(
queued.is_empty(),
"single dispatched message should not be queued"
@@ -1720,7 +1832,11 @@ mod tests {
session.append_token("Found it.");
session.chat.push(Message::User("queued".into()));
- let queued = find_queued_indices(&session.chat, true, 1);
+ let queued = find_queued_indices(
+ &session.chat,
+ true,
+ DispatchState::AwaitingResponse { count: 1 },
+ );
let queued_texts: Vec<&str> = queued
.iter()
.map(|&i| match &session.chat[i] {
@@ -1746,7 +1862,11 @@ mod tests {
session.chat.push(Message::User("c".into()));
// All 3 were batch-dispatched
- let queued = find_queued_indices(&session.chat, true, 3);
+ let queued = find_queued_indices(
+ &session.chat,
+ true,
+ DispatchState::AwaitingResponse { count: 3 },
+ );
assert!(
queued.is_empty(),
"all 3 messages were dispatched — none should show queued"
@@ -1769,7 +1889,11 @@ mod tests {
session.chat.push(Message::User("new queued".into()));
// 3 were dispatched, 1 new arrival
- let queued = find_queued_indices(&session.chat, true, 3);
+ let queued = find_queued_indices(
+ &session.chat,
+ true,
+ DispatchState::AwaitingResponse { count: 3 },
+ );
let queued_texts: Vec<&str> = queued
.iter()
.map(|&i| match &session.chat[i] {
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -68,9 +68,8 @@ pub struct DaveUi<'a> {
usage: Option<&'a crate::messages::UsageInfo>,
/// Context window size for the current model
context_window: u64,
- /// Number of trailing user messages dispatched in the current stream.
- /// Used by the queued indicator to skip dispatched messages.
- dispatched_user_count: usize,
+ /// Dispatch lifecycle state, used for queued indicator logic.
+ dispatch_state: crate::session::DispatchState,
/// Which backend this session uses
backend_type: BackendType,
}
@@ -154,6 +153,8 @@ pub enum DaveAction {
TogglePlanMode,
/// Toggle auto-steal focus mode (clicked AUTO badge)
ToggleAutoSteal,
+ /// Trigger manual context compaction
+ Compact,
}
impl<'a> DaveUi<'a> {
@@ -185,7 +186,7 @@ impl<'a> DaveUi<'a> {
status_dot_color: None,
usage: None,
context_window: crate::messages::context_window_for_model(None),
- dispatched_user_count: 0,
+ dispatch_state: crate::session::DispatchState::default(),
backend_type: BackendType::Remote,
}
}
@@ -225,8 +226,8 @@ impl<'a> DaveUi<'a> {
self
}
- pub fn dispatched_user_count(mut self, count: usize) -> Self {
- self.dispatched_user_count = count;
+ pub fn dispatch_state(mut self, state: crate::session::DispatchState) -> Self {
+ self.dispatch_state = state;
self
}
@@ -438,7 +439,7 @@ impl<'a> DaveUi<'a> {
// When streaming, append_token inserts an Assistant between the
// dispatched User and any queued Users, so all trailing Users
// after that Assistant are queued. Before the first token arrives
- // there's no Assistant yet, so we skip `dispatched_user_count`
+ // there's no Assistant yet, so we skip the dispatched count
// trailing Users (they were all sent in the prompt).
let queued_from = if self.flags.contains(DaveUiFlags::IsWorking) {
let last_non_user = self
@@ -459,7 +460,7 @@ impl<'a> DaveUi<'a> {
// No streaming assistant yet — skip past the dispatched
// user messages (1 for single dispatch, N for batch)
let first_trailing = i + 1;
- let skip = self.dispatched_user_count.max(1);
+ let skip = self.dispatch_state.dispatched_count().max(1);
let queued_start = first_trailing + skip;
if queued_start < self.chat.len() {
Some(queued_start)
@@ -1572,6 +1573,17 @@ fn toggle_badges_ui(
action = Some(DaveAction::TogglePlanMode);
}
+ // COMPACT badge
+ let compact_badge =
+ super::badge::StatusBadge::new("COMPACT").variant(super::badge::BadgeVariant::Default);
+ if compact_badge
+ .show(ui)
+ .on_hover_text("Click to compact context")
+ .clicked()
+ {
+ action = Some(DaveAction::Compact);
+ }
+
action
}
diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -65,7 +65,7 @@ fn build_dave_ui<'a>(
.plan_mode_active(plan_mode_active)
.auto_steal_focus(auto_steal_focus)
.is_remote(is_remote)
- .dispatched_user_count(session.dispatched_user_count)
+ .dispatch_state(session.dispatch_state)
.details(&session.details)
.backend_type(session.backend_type);
@@ -769,6 +769,8 @@ pub enum UiActionResult {
PublishPermissionResponse(update::PermissionPublish),
/// Toggle auto-steal focus mode (needs state from DaveApp)
ToggleAutoSteal,
+ /// Trigger manual context compaction
+ Compact,
}
/// Handle a UI action from DaveUi.
@@ -877,5 +879,6 @@ pub fn handle_ui_action(
UiActionResult::PublishPermissionResponse,
)
}
+ DaveAction::Compact => UiActionResult::Compact,
}
}
diff --git a/crates/notedeck_nostrverse/Cargo.toml b/crates/notedeck_nostrverse/Cargo.toml
@@ -13,3 +13,7 @@ nostrdb = { workspace = true }
protoverse = { path = "../protoverse" }
renderbud = { workspace = true }
tracing = { workspace = true }
+uuid = { workspace = true }
+ehttp = { workspace = true }
+sha2 = { workspace = true }
+poll-promise = { workspace = true }
diff --git a/crates/notedeck_nostrverse/src/convert.rs b/crates/notedeck_nostrverse/src/convert.rs
@@ -1,37 +1,20 @@
-//! Convert protoverse Space AST to renderer room state.
+//! Convert protoverse Space AST to renderer space state.
-use crate::room_state::{ObjectLocation, Room, RoomObject, RoomObjectType, RoomShape};
+use crate::room_state::{ObjectLocation, RoomObject, RoomObjectType, SpaceInfo};
use glam::{Quat, Vec3};
-use protoverse::{Attribute, Cell, CellId, CellType, Location, ObjectType, Shape, Space};
+use protoverse::{Attribute, Cell, CellId, CellType, Location, ObjectType, Space};
-/// Convert a parsed protoverse Space into a Room and its objects.
-pub fn convert_space(space: &Space) -> (Room, Vec<RoomObject>) {
- let room = extract_room(space, space.root);
+/// Convert a parsed protoverse Space into a SpaceInfo and its objects.
+pub fn convert_space(space: &Space) -> (SpaceInfo, Vec<RoomObject>) {
+ let info = extract_space_info(space, space.root);
let mut objects = Vec::new();
collect_objects(space, space.root, &mut objects);
- (room, objects)
+ (info, objects)
}
-fn extract_room(space: &Space, id: CellId) -> Room {
- let name = space.name(id).unwrap_or("Untitled Room").to_string();
-
- let shape = match space.shape(id) {
- Some(Shape::Rectangle) | Some(Shape::Square) => RoomShape::Rectangle,
- Some(Shape::Circle) => RoomShape::Circle,
- None => RoomShape::Rectangle,
- };
-
- let width = space.width(id).unwrap_or(20.0) as f32;
- let height = space.height(id).unwrap_or(15.0) as f32;
- let depth = space.depth(id).unwrap_or(10.0) as f32;
-
- Room {
- name,
- shape,
- width,
- height,
- depth,
- }
+fn extract_space_info(space: &Space, id: CellId) -> SpaceInfo {
+ let name = space.name(id).unwrap_or("Untitled Space").to_string();
+ SpaceInfo { name }
}
fn location_from_protoverse(loc: &Location) -> ObjectLocation {
@@ -122,36 +105,27 @@ fn collect_objects(space: &Space, id: CellId, objects: &mut Vec<RoomObject>) {
}
}
-/// Build a protoverse Space from Room and objects (reverse of convert_space).
+/// Build a protoverse Space from SpaceInfo and objects (reverse of convert_space).
///
-/// Produces: (room (name ...) (shape ...) (width ...) (height ...) (depth ...)
-/// (group <objects...>))
-pub fn build_space(room: &Room, objects: &[RoomObject]) -> Space {
+/// Produces: (space (name ...) (group <objects...>))
+pub fn build_space(info: &SpaceInfo, objects: &[RoomObject]) -> Space {
let mut cells = Vec::new();
let mut attributes = Vec::new();
let mut child_ids = Vec::new();
- // Room attributes
- let room_attr_start = attributes.len() as u32;
- attributes.push(Attribute::Name(room.name.clone()));
- attributes.push(Attribute::Shape(match room.shape {
- RoomShape::Rectangle => Shape::Rectangle,
- RoomShape::Circle => Shape::Circle,
- RoomShape::Custom => Shape::Rectangle,
- }));
- attributes.push(Attribute::Width(room.width as f64));
- attributes.push(Attribute::Height(room.height as f64));
- attributes.push(Attribute::Depth(room.depth as f64));
- let room_attr_count = (attributes.len() as u32 - room_attr_start) as u16;
-
- // Room cell (index 0), child = group at index 1
- let room_child_start = child_ids.len() as u32;
+ // Space attributes (just name)
+ let space_attr_start = attributes.len() as u32;
+ attributes.push(Attribute::Name(info.name.clone()));
+ let space_attr_count = (attributes.len() as u32 - space_attr_start) as u16;
+
+ // Space cell (index 0), child = group at index 1
+ let space_child_start = child_ids.len() as u32;
child_ids.push(CellId(1));
cells.push(Cell {
- cell_type: CellType::Room,
- first_attr: room_attr_start,
- attr_count: room_attr_count,
- first_child: room_child_start,
+ cell_type: CellType::Space,
+ first_attr: space_attr_start,
+ attr_count: space_attr_count,
+ first_child: space_child_start,
child_count: 1,
parent: None,
});
@@ -172,54 +146,7 @@ pub fn build_space(room: &Room, objects: &[RoomObject]) -> Space {
// Object cells (indices 2..)
for obj in objects {
- let obj_attr_start = attributes.len() as u32;
- attributes.push(Attribute::Id(obj.id.clone()));
- attributes.push(Attribute::Name(obj.name.clone()));
- if let Some(url) = &obj.model_url {
- attributes.push(Attribute::ModelUrl(url.clone()));
- }
- if let Some(loc) = &obj.location {
- attributes.push(Attribute::Location(location_to_protoverse(loc)));
- }
- // When the object has a resolved location base, save the offset
- // from the base so that position remains relative to the location.
- let pos = match obj.location_base {
- Some(base) => obj.position - base,
- None => obj.position,
- };
- attributes.push(Attribute::Position(
- pos.x as f64,
- pos.y as f64,
- pos.z as f64,
- ));
- // Only emit rotation when non-identity to keep output clean
- if obj.rotation.angle_between(Quat::IDENTITY) > 1e-4 {
- let (y, x, z) = obj.rotation.to_euler(glam::EulerRot::YXZ);
- attributes.push(Attribute::Rotation(
- x.to_degrees() as f64,
- y.to_degrees() as f64,
- z.to_degrees() as f64,
- ));
- }
- let obj_attr_count = (attributes.len() as u32 - obj_attr_start) as u16;
-
- let obj_type = CellType::Object(match &obj.object_type {
- RoomObjectType::Table => ObjectType::Table,
- RoomObjectType::Chair => ObjectType::Chair,
- RoomObjectType::Door => ObjectType::Door,
- RoomObjectType::Light => ObjectType::Light,
- RoomObjectType::Prop => ObjectType::Custom("prop".to_string()),
- RoomObjectType::Custom(s) => ObjectType::Custom(s.clone()),
- });
-
- cells.push(Cell {
- cell_type: obj_type,
- first_attr: obj_attr_start,
- attr_count: obj_attr_count,
- first_child: child_ids.len() as u32,
- child_count: 0,
- parent: Some(CellId(1)),
- });
+ build_object_cell(obj, &mut cells, &mut attributes, &child_ids);
}
Space {
@@ -230,6 +157,67 @@ pub fn build_space(room: &Room, objects: &[RoomObject]) -> Space {
}
}
+fn object_type_to_cell(obj_type: &RoomObjectType) -> CellType {
+ CellType::Object(match obj_type {
+ RoomObjectType::Table => ObjectType::Table,
+ RoomObjectType::Chair => ObjectType::Chair,
+ RoomObjectType::Door => ObjectType::Door,
+ RoomObjectType::Light => ObjectType::Light,
+ RoomObjectType::Prop => ObjectType::Custom("prop".to_string()),
+ RoomObjectType::Custom(s) => ObjectType::Custom(s.clone()),
+ })
+}
+
+/// Build a single object Cell with its attributes and append to the Space vectors.
+fn build_object_cell(
+ obj: &RoomObject,
+ cells: &mut Vec<Cell>,
+ attributes: &mut Vec<Attribute>,
+ child_ids: &[CellId],
+) {
+ let obj_attr_start = attributes.len() as u32;
+
+ attributes.push(Attribute::Id(obj.id.clone()));
+ attributes.push(Attribute::Name(obj.name.clone()));
+ if let Some(url) = &obj.model_url {
+ attributes.push(Attribute::ModelUrl(url.clone()));
+ }
+ if let Some(loc) = &obj.location {
+ attributes.push(Attribute::Location(location_to_protoverse(loc)));
+ }
+
+ // When the object has a resolved location base, save the offset
+ // from the base so that position remains relative to the location.
+ let pos = match obj.location_base {
+ Some(base) => obj.position - base,
+ None => obj.position,
+ };
+ attributes.push(Attribute::Position(
+ pos.x as f64,
+ pos.y as f64,
+ pos.z as f64,
+ ));
+
+ // Only emit rotation when non-identity to keep output clean
+ if obj.rotation.angle_between(Quat::IDENTITY) > 1e-4 {
+ let (y, x, z) = obj.rotation.to_euler(glam::EulerRot::YXZ);
+ attributes.push(Attribute::Rotation(
+ x.to_degrees() as f64,
+ y.to_degrees() as f64,
+ z.to_degrees() as f64,
+ ));
+ }
+
+ cells.push(Cell {
+ cell_type: object_type_to_cell(&obj.object_type),
+ first_attr: obj_attr_start,
+ attr_count: (attributes.len() as u32 - obj_attr_start) as u16,
+ first_child: child_ids.len() as u32,
+ child_count: 0,
+ parent: Some(CellId(1)),
+ });
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -237,6 +225,7 @@ mod tests {
#[test]
fn test_convert_simple_room() {
+ // Still accepts (room ...) for backward compatibility
let space = parse(
r#"(room (name "Test Room") (shape rectangle) (width 10) (height 5) (depth 8)
(group
@@ -245,13 +234,9 @@ mod tests {
)
.unwrap();
- let (room, objects) = convert_space(&space);
+ let (info, objects) = convert_space(&space);
- assert_eq!(room.name, "Test Room");
- assert_eq!(room.shape, RoomShape::Rectangle);
- assert_eq!(room.width, 10.0);
- assert_eq!(room.height, 5.0);
- assert_eq!(room.depth, 8.0);
+ assert_eq!(info.name, "Test Room");
assert_eq!(objects.len(), 2);
@@ -269,7 +254,7 @@ mod tests {
#[test]
fn test_convert_with_model_url() {
let space = parse(
- r#"(room (name "Gallery")
+ r#"(space (name "Gallery")
(group
(table (id t1) (name "Display Table")
(model-url "/models/table.glb")
@@ -285,7 +270,7 @@ mod tests {
#[test]
fn test_convert_custom_object() {
let space = parse(
- r#"(room (name "Test")
+ r#"(space (name "Test")
(group
(prop (id p1) (name "Water Bottle"))))"#,
)
@@ -299,12 +284,8 @@ mod tests {
#[test]
fn test_build_space_roundtrip() {
- let room = Room {
- name: "My Room".to_string(),
- shape: RoomShape::Rectangle,
- width: 15.0,
- height: 10.0,
- depth: 12.0,
+ let info = SpaceInfo {
+ name: "My Space".to_string(),
};
let objects = vec![
RoomObject::new(
@@ -318,19 +299,16 @@ mod tests {
.with_object_type(RoomObjectType::Light),
];
- let space = build_space(&room, &objects);
+ let space = build_space(&info, &objects);
// Serialize and re-parse
let serialized = protoverse::serialize(&space);
let reparsed = parse(&serialized).unwrap();
// Convert back
- let (room2, objects2) = convert_space(&reparsed);
+ let (info2, objects2) = convert_space(&reparsed);
- assert_eq!(room2.name, "My Room");
- assert_eq!(room2.width, 15.0);
- assert_eq!(room2.height, 10.0);
- assert_eq!(room2.depth, 12.0);
+ assert_eq!(info2.name, "My Space");
assert_eq!(objects2.len(), 2);
assert_eq!(objects2[0].id, "desk");
@@ -346,20 +324,17 @@ mod tests {
#[test]
fn test_convert_defaults() {
- let space = parse("(room)").unwrap();
- let (room, objects) = convert_space(&space);
+ let space = parse("(space)").unwrap();
+ let (info, objects) = convert_space(&space);
- assert_eq!(room.name, "Untitled Room");
- assert_eq!(room.width, 20.0);
- assert_eq!(room.height, 15.0);
- assert_eq!(room.depth, 10.0);
+ assert_eq!(info.name, "Untitled Space");
assert!(objects.is_empty());
}
#[test]
fn test_convert_location_top_of() {
let space = parse(
- r#"(room (group
+ r#"(space (group
(table (id obj1) (name "Table") (position 0 0 0))
(prop (id obj2) (name "Bottle") (location top-of obj1))))"#,
)
@@ -376,12 +351,8 @@ mod tests {
#[test]
fn test_build_space_always_emits_position() {
- let room = Room {
+ let info = SpaceInfo {
name: "Test".to_string(),
- shape: RoomShape::Rectangle,
- width: 10.0,
- height: 10.0,
- depth: 10.0,
};
let objects = vec![RoomObject::new(
"a".to_string(),
@@ -389,7 +360,7 @@ mod tests {
Vec3::ZERO,
)];
- let space = build_space(&room, &objects);
+ let space = build_space(&info, &objects);
let serialized = protoverse::serialize(&space);
// Position should appear even for Vec3::ZERO
@@ -398,12 +369,8 @@ mod tests {
#[test]
fn test_build_space_location_roundtrip() {
- let room = Room {
+ let info = SpaceInfo {
name: "Test".to_string(),
- shape: RoomShape::Rectangle,
- width: 10.0,
- height: 10.0,
- depth: 10.0,
};
let objects = vec![
RoomObject::new("obj1".to_string(), "Table".to_string(), Vec3::ZERO)
@@ -416,7 +383,7 @@ mod tests {
.with_location(ObjectLocation::TopOf("obj1".to_string())),
];
- let space = build_space(&room, &objects);
+ let space = build_space(&info, &objects);
let serialized = protoverse::serialize(&space);
let reparsed = parse(&serialized).unwrap();
let (_, objects2) = convert_space(&reparsed);
diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs
@@ -1,12 +1,13 @@
-//! Nostrverse: Virtual rooms as Nostr events
+//! Nostrverse: Virtual spaces as Nostr events
//!
//! This app implements spatial views for nostrverse - a protocol where
-//! rooms and objects are Nostr events (kinds 37555, 37556, 10555).
+//! spaces and objects are Nostr events (kinds 37555, 37556, 10555).
//!
-//! Rooms are rendered as 3D scenes using renderbud's PBR pipeline,
+//! Spaces are rendered as 3D scenes using renderbud's PBR pipeline,
//! embedded in egui via wgpu paint callbacks.
mod convert;
+mod model_cache;
mod nostr_events;
mod presence;
mod room_state;
@@ -14,13 +15,13 @@ mod room_view;
mod subscriptions;
pub use room_state::{
- NostrverseAction, NostrverseState, Room, RoomObject, RoomObjectType, RoomRef, RoomShape,
- RoomUser,
+ NostrverseAction, NostrverseState, RoomObject, RoomObjectType, RoomUser, SpaceInfo, SpaceRef,
};
pub use room_view::{NostrverseResponse, render_editing_panel, show_room_view};
use enostr::Pubkey;
use glam::Vec3;
+use nostrdb::Filter;
use notedeck::{AppContext, AppResponse};
use renderbud::Transform;
@@ -47,8 +48,8 @@ const MAX_EXTRAPOLATION_TIME: f64 = 3.0;
/// Maximum extrapolation distance from last known position
const MAX_EXTRAPOLATION_DISTANCE: f32 = 10.0;
-/// Demo room in protoverse .space format
-const DEMO_SPACE: &str = r#"(room (name "Demo Room") (shape rectangle) (width 20) (height 15) (depth 10)
+/// Demo space in protoverse .space format
+const DEMO_SPACE: &str = r#"(space (name "Demo Space")
(group
(table (id obj1) (name "Ironwood Table")
(model-url "/home/jb55/var/models/ironwood/ironwood.glb")
@@ -67,9 +68,9 @@ pub mod kinds {
pub const PRESENCE: u16 = 10555;
}
-/// Nostrverse app - a 3D spatial canvas for virtual rooms
+/// Nostrverse app - a 3D spatial canvas for virtual spaces
pub struct NostrverseApp {
- /// Current room state
+ /// Current space state
state: NostrverseState,
/// 3D renderer (None if wgpu unavailable)
renderer: Option<renderbud::egui::EguiRenderer>,
@@ -81,7 +82,7 @@ pub struct NostrverseApp {
initialized: bool,
/// Cached avatar model AABB for ground placement
avatar_bounds: Option<renderbud::Aabb>,
- /// Local nostrdb subscription for room events
+ /// Local nostrdb subscription for space events
room_sub: Option<subscriptions::RoomSubscription>,
/// Presence publisher (throttled heartbeats)
presence_pub: presence::PresencePublisher,
@@ -89,25 +90,37 @@ pub struct NostrverseApp {
presence_expiry: presence::PresenceExpiry,
/// Local nostrdb subscription for presence events
presence_sub: Option<subscriptions::PresenceSubscription>,
- /// Cached room naddr string (avoids format! per frame)
- room_naddr: String,
+ /// Cached space naddr string (avoids format! per frame)
+ space_naddr: String,
/// Event ID of the last save we made (to skip our own echo in polls)
last_save_id: Option<[u8; 32]>,
/// Monotonic time tracker (seconds since app start)
start_time: std::time::Instant,
+ /// Model download/cache manager (initialized lazily in initialize())
+ model_cache: Option<model_cache::ModelCache>,
+ /// Dedicated relay URL for multiplayer sync (from NOSTRVERSE_RELAY env)
+ relay_url: Option<String>,
+ /// Pending relay subscription ID — Some means we still need to send REQ
+ pending_relay_sub: Option<String>,
}
impl NostrverseApp {
- /// Create a new nostrverse app with a room reference
- pub fn new(room_ref: RoomRef, render_state: Option<&egui_wgpu::RenderState>) -> Self {
+ const DEFAULT_RELAY: &str = "ws://relay.jb55.com";
+
+ /// Create a new nostrverse app with a space reference
+ pub fn new(space_ref: SpaceRef, render_state: Option<&egui_wgpu::RenderState>) -> Self {
let renderer = render_state.map(|rs| renderbud::egui::EguiRenderer::new(rs, (800, 600)));
let device = render_state.map(|rs| rs.device.clone());
let queue = render_state.map(|rs| rs.queue.clone());
- let room_naddr = room_ref.to_naddr();
+ let relay_url = Some(
+ std::env::var("NOSTRVERSE_RELAY").unwrap_or_else(|_| Self::DEFAULT_RELAY.to_string()),
+ );
+
+ let space_naddr = space_ref.to_naddr();
Self {
- state: NostrverseState::new(room_ref),
+ state: NostrverseState::new(space_ref),
renderer,
device,
queue,
@@ -117,16 +130,51 @@ impl NostrverseApp {
presence_pub: presence::PresencePublisher::new(),
presence_expiry: presence::PresenceExpiry::new(),
presence_sub: None,
- room_naddr,
+ space_naddr,
last_save_id: None,
start_time: std::time::Instant::now(),
+ model_cache: None,
+ relay_url,
+ pending_relay_sub: None,
}
}
- /// Create with a demo room
+ /// Create with a demo space
pub fn demo(render_state: Option<&egui_wgpu::RenderState>) -> Self {
- let room_ref = RoomRef::new("demo-room".to_string(), demo_pubkey());
- Self::new(room_ref, render_state)
+ let space_ref = SpaceRef::new("demo-room".to_string(), demo_pubkey());
+ Self::new(space_ref, render_state)
+ }
+
+ /// Send a client message to the dedicated relay, if configured.
+ fn send_to_relay(&self, pool: &mut enostr::RelayPool, msg: &enostr::ClientMessage) {
+ if let Some(relay_url) = &self.relay_url {
+ pool.send_to(msg, relay_url);
+ }
+ }
+
+ /// Send the relay subscription once the relay is connected.
+ fn maybe_send_relay_sub(&mut self, pool: &mut enostr::RelayPool) {
+ let (Some(sub_id), Some(relay_url)) = (&self.pending_relay_sub, &self.relay_url) else {
+ return;
+ };
+
+ let connected = pool
+ .relays
+ .iter()
+ .any(|r| r.url() == relay_url && matches!(r.status(), enostr::RelayStatus::Connected));
+
+ if !connected {
+ return;
+ }
+
+ let room_filter = Filter::new().kinds([kinds::ROOM as u64]).build();
+ let presence_filter = Filter::new().kinds([kinds::PRESENCE as u64]).build();
+
+ let req = enostr::ClientMessage::req(sub_id.clone(), vec![room_filter, presence_filter]);
+ pool.send_to(&req, relay_url);
+
+ tracing::info!("Sent nostrverse subscription to {}", relay_url);
+ self.pending_relay_sub = None;
}
/// Load a glTF model and return its handle
@@ -144,22 +192,40 @@ impl NostrverseApp {
}
}
- /// Initialize: ingest demo room into local nostrdb and subscribe.
- fn initialize(&mut self, ctx: &mut AppContext<'_>) {
+ /// Initialize: ingest demo space into local nostrdb and subscribe.
+ fn initialize(&mut self, ctx: &mut AppContext<'_>, egui_ctx: &egui::Context) {
if self.initialized {
return;
}
- // Subscribe to room and presence events in local nostrdb
+ // Initialize model cache
+ let cache_dir = ctx.path.path(notedeck::DataPathType::Cache).join("models");
+ self.model_cache = Some(model_cache::ModelCache::new(cache_dir));
+
+ // Subscribe to space and presence events in local nostrdb
self.room_sub = Some(subscriptions::RoomSubscription::new(ctx.ndb));
self.presence_sub = Some(subscriptions::PresenceSubscription::new(ctx.ndb));
- // Try to load an existing room from nostrdb first
+ // Add dedicated relay to pool (subscription sent on connect in maybe_send_relay_sub)
+ if let Some(relay_url) = &self.relay_url {
+ let egui_ctx = egui_ctx.clone();
+ if let Err(e) = ctx
+ .pool
+ .add_url(relay_url.clone(), move || egui_ctx.request_repaint())
+ {
+ tracing::error!("Failed to add nostrverse relay {}: {}", relay_url, e);
+ } else {
+ tracing::info!("Added nostrverse relay: {}", relay_url);
+ self.pending_relay_sub = Some(format!("nostrverse-{}", uuid::Uuid::new_v4()));
+ }
+ }
+
+ // Try to load an existing space from nostrdb first
let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn");
- self.load_room_from_ndb(ctx.ndb, &txn);
+ self.load_space_from_ndb(ctx.ndb, &txn);
- // Only ingest the demo room if no saved room was found
- if self.state.room.is_none() {
+ // Only ingest the demo space if no saved space was found
+ if self.state.space.is_none() {
let space = match protoverse::parse(DEMO_SPACE) {
Ok(s) => s,
Err(e) => {
@@ -169,11 +235,13 @@ impl NostrverseApp {
};
if let Some(kp) = ctx.accounts.selected_filled() {
- let builder = nostr_events::build_room_event(&space, &self.state.room_ref.id);
- nostr_events::ingest_event(builder, ctx.ndb, kp);
+ let builder = nostr_events::build_space_event(&space, &self.state.space_ref.id);
+ if let Some((msg, _id)) = nostr_events::ingest_event(builder, ctx.ndb, kp) {
+ self.send_to_relay(ctx.pool, &msg);
+ }
}
// room_sub (set up above) will pick up the ingested event
- // on the next poll_room_updates() frame.
+ // on the next poll_space_updates() frame.
}
// Add self user
@@ -206,9 +274,7 @@ impl NostrverseApp {
if let Some(renderer) = &self.renderer {
let self_pos = self
.state
- .users
- .iter()
- .find(|u| u.is_self)
+ .self_user()
.map(|u| u.position)
.unwrap_or(Vec3::ZERO);
let mut r = renderer.renderer.lock().unwrap();
@@ -218,12 +284,12 @@ impl NostrverseApp {
self.initialized = true;
}
- /// Apply a parsed Space to the room state: convert, load models, update state.
+ /// Apply a parsed Space to the state: convert, load models, update state.
/// Preserves renderer scene handles for objects that still exist by ID,
/// and removes orphaned scene objects from the renderer.
fn apply_space(&mut self, space: &protoverse::Space) {
- let (room, mut objects) = convert::convert_space(space);
- self.state.room = Some(room);
+ let (info, mut objects) = convert::convert_space(space);
+ self.state.space = Some(info);
// Transfer scene/model handles from existing objects with matching IDs
for new_obj in &mut objects {
@@ -250,119 +316,143 @@ impl NostrverseApp {
self.state.dirty = false;
}
- /// Load room state from a nostrdb query result.
- fn load_room_from_ndb(&mut self, ndb: &nostrdb::Ndb, txn: &nostrdb::Transaction) {
+ /// Load space state from a nostrdb query result.
+ fn load_space_from_ndb(&mut self, ndb: &nostrdb::Ndb, txn: &nostrdb::Transaction) {
let notes = subscriptions::RoomSubscription::query_existing(ndb, txn);
for note in ¬es {
- let Some(room_id) = nostr_events::get_room_id(note) else {
+ let Some(space_id) = nostr_events::get_space_id(note) else {
continue;
};
- if room_id != self.state.room_ref.id {
+ if space_id != self.state.space_ref.id {
continue;
}
- let Some(space) = nostr_events::parse_room_event(note) else {
- tracing::warn!("Failed to parse room event content");
+ let Some(space) = nostr_events::parse_space_event(note) else {
+ tracing::warn!("Failed to parse space event content");
continue;
};
self.apply_space(&space);
- tracing::info!("Loaded room '{}' from nostrdb", room_id);
+ tracing::info!("Loaded space '{}' from nostrdb", space_id);
return;
}
}
- /// Save current room state: build Space, serialize, ingest as new nostr event.
- fn save_room(&mut self, ctx: &mut AppContext<'_>) {
- let Some(room) = &self.state.room else {
- tracing::warn!("save_room: no room to save");
+ /// Save current space state: build Space, serialize, ingest as new nostr event.
+ fn save_space(&mut self, ctx: &mut AppContext<'_>) {
+ let Some(info) = &self.state.space else {
+ tracing::warn!("save_space: no space to save");
return;
};
let Some(kp) = ctx.accounts.selected_filled() else {
- tracing::warn!("save_room: no keypair available");
+ tracing::warn!("save_space: no keypair available");
return;
};
- let space = convert::build_space(room, &self.state.objects);
- let builder = nostr_events::build_room_event(&space, &self.state.room_ref.id);
- self.last_save_id = nostr_events::ingest_event(builder, ctx.ndb, kp);
- tracing::info!("Saved room '{}'", self.state.room_ref.id);
+ let space = convert::build_space(info, &self.state.objects);
+ let builder = nostr_events::build_space_event(&space, &self.state.space_ref.id);
+ if let Some((msg, id)) = nostr_events::ingest_event(builder, ctx.ndb, kp) {
+ self.last_save_id = Some(id);
+ self.send_to_relay(ctx.pool, &msg);
+ }
+ tracing::info!("Saved space '{}'", self.state.space_ref.id);
}
/// Load 3D models for objects, then resolve any semantic locations
/// (e.g. "top-of obj1") to concrete positions using AABB bounds.
- fn load_object_models(&self, objects: &mut [RoomObject]) {
+ ///
+ /// For remote URLs (http/https), the model cache handles async download
+ /// and disk caching. Models that aren't yet downloaded will be loaded
+ /// on a future frame via `poll_model_downloads`.
+ fn load_object_models(&mut self, objects: &mut [RoomObject]) {
let renderer = self.renderer.as_ref();
let model_bounds_fn = |m: Option<renderbud::Model>| -> Option<renderbud::Aabb> {
let r = renderer?.renderer.lock().unwrap();
r.model_bounds(m?)
};
- // Phase 1: Load all models and cache their AABB bounds
+ // Phase 1: Load all models and cache their AABB bounds.
+ // Remote URLs may return None (download in progress); those objects
+ // will get their model_handle assigned later via poll_model_downloads.
let mut bounds_by_id: std::collections::HashMap<String, renderbud::Aabb> =
std::collections::HashMap::new();
for obj in objects.iter_mut() {
- if let Some(url) = &obj.model_url {
- let model = self.load_model(url);
- if let Some(bounds) = model_bounds_fn(model) {
+ // Skip if already loaded
+ if obj.model_handle.is_some() {
+ if let Some(bounds) = model_bounds_fn(obj.model_handle) {
bounds_by_id.insert(obj.id.clone(), bounds);
}
- obj.model_handle = model;
+ continue;
+ }
+
+ if let Some(url) = obj.model_url.clone() {
+ let local_path = if let Some(cache) = &mut self.model_cache {
+ cache.request(&url)
+ } else {
+ Some(std::path::PathBuf::from(&url))
+ };
+
+ if let Some(path) = local_path {
+ let model = self.load_model(path.to_str().unwrap_or(&url));
+ if let Some(bounds) = model_bounds_fn(model) {
+ bounds_by_id.insert(obj.id.clone(), bounds);
+ }
+ obj.model_handle = model;
+ if let Some(cache) = &mut self.model_cache {
+ cache.mark_loaded(&url);
+ }
+ }
}
}
- // Phase 2: Resolve semantic locations to local offsets from parent.
- // For parented objects (TopOf, Near), the position becomes local to the parent node.
- // The location_base stores the bounds-derived offset so the editor can show user offset.
- let mut resolved: Vec<(usize, Vec3, Vec3)> = Vec::new();
+ resolve_locations(objects, &bounds_by_id);
+ }
- for (i, obj) in objects.iter().enumerate() {
- let Some(loc) = &obj.location else {
+ /// Poll for completed model downloads, load into GPU, and re-resolve
+ /// semantic locations so dependent objects are positioned correctly.
+ fn poll_model_downloads(&mut self) {
+ let Some(cache) = &mut self.model_cache else {
+ return;
+ };
+
+ let ready = cache.poll();
+ if ready.is_empty() {
+ return;
+ }
+
+ let mut any_loaded = false;
+ for (url, path) in ready {
+ let path_str = path.to_string_lossy();
+ let model = self.load_model(&path_str);
+
+ if model.is_none() {
+ tracing::warn!("Failed to load cached model at {}", path_str);
continue;
- };
+ }
- let local_base = match loc {
- room_state::ObjectLocation::TopOf(target_id) => {
- let target_top = bounds_by_id.get(target_id).map(|b| b.max.y).unwrap_or(0.0);
- let self_half_h = bounds_by_id
- .get(&obj.id)
- .map(|b| (b.max.y - b.min.y) * 0.5)
- .unwrap_or(0.0);
- Some(Vec3::new(0.0, target_top + self_half_h, 0.0))
+ for obj in &mut self.state.objects {
+ if obj.model_url.as_deref() == Some(&url) && obj.model_handle.is_none() {
+ obj.model_handle = model;
+ obj.scene_object_id = None;
+ any_loaded = true;
}
- room_state::ObjectLocation::Near(target_id) => {
- let offset = bounds_by_id
- .get(target_id)
- .map(|b| b.max.x - b.min.x)
- .unwrap_or(1.0);
- Some(Vec3::new(offset, 0.0, 0.0))
- }
- room_state::ObjectLocation::Floor => {
- let self_half_h = bounds_by_id
- .get(&obj.id)
- .map(|b| (b.max.y - b.min.y) * 0.5)
- .unwrap_or(0.0);
- Some(Vec3::new(0.0, self_half_h, 0.0))
- }
- _ => None,
- };
+ }
- if let Some(base) = local_base {
- resolved.push((i, base, base + obj.position));
+ if let Some(cache) = &mut self.model_cache {
+ cache.mark_loaded(&url);
}
}
- for (i, base, pos) in resolved {
- objects[i].location_base = Some(base);
- objects[i].position = pos;
+ if any_loaded {
+ resolve_object_locations(self.renderer.as_ref(), &mut self.state.objects);
}
}
- /// Poll the room subscription for updates.
- /// Skips applying updates while the room has unsaved local edits.
- fn poll_room_updates(&mut self, ndb: &nostrdb::Ndb) {
+ /// Poll the space subscription for updates.
+ /// Skips applying updates while the space has unsaved local edits.
+ fn poll_space_updates(&mut self, ndb: &nostrdb::Ndb) {
if self.state.dirty {
return;
}
@@ -381,19 +471,19 @@ impl NostrverseApp {
continue;
}
- let Some(room_id) = nostr_events::get_room_id(note) else {
+ let Some(space_id) = nostr_events::get_space_id(note) else {
continue;
};
- if room_id != self.state.room_ref.id {
+ if space_id != self.state.space_ref.id {
continue;
}
- let Some(space) = nostr_events::parse_room_event(note) else {
+ let Some(space) = nostr_events::parse_space_event(note) else {
continue;
};
self.apply_space(&space);
- tracing::info!("Room '{}' updated from nostrdb", room_id);
+ tracing::info!("Space '{}' updated from nostrdb", space_id);
}
}
@@ -405,14 +495,16 @@ impl NostrverseApp {
if let Some(kp) = ctx.accounts.selected_filled() {
let self_pos = self
.state
- .users
- .iter()
- .find(|u| u.is_self)
+ .self_user()
.map(|u| u.position)
.unwrap_or(Vec3::ZERO);
- self.presence_pub
- .maybe_publish(ctx.ndb, kp, &self.room_naddr, self_pos, now);
+ if let Some(msg) =
+ self.presence_pub
+ .maybe_publish(ctx.ndb, kp, &self.space_naddr, self_pos, now)
+ {
+ self.send_to_relay(ctx.pool, &msg);
+ }
}
// Poll for remote presence events
@@ -421,7 +513,7 @@ impl NostrverseApp {
let changed = presence::poll_presence(
sub,
ctx.ndb,
- &self.room_naddr,
+ &self.space_naddr,
&self_pubkey,
&mut self.state.users,
now,
@@ -429,12 +521,7 @@ impl NostrverseApp {
// Assign avatar model to new users
if changed {
- let avatar_model = self
- .state
- .users
- .iter()
- .find(|u| u.is_self)
- .and_then(|u| u.model_handle);
+ let avatar_model = self.state.self_user().and_then(|u| u.model_handle);
if let Some(model) = avatar_model {
for user in &mut self.state.users {
if user.model_handle.is_none() {
@@ -454,122 +541,47 @@ impl NostrverseApp {
}
}
- /// Sync room objects and user avatars to the renderbud scene
+ /// Sync space objects and user avatars to the renderbud scene
fn sync_scene(&mut self) {
let Some(renderer) = &self.renderer else {
return;
};
let mut r = renderer.renderer.lock().unwrap();
- // Build map of object string ID -> scene ObjectId for parenting lookups
- let mut id_to_scene: std::collections::HashMap<String, renderbud::ObjectId> = self
- .state
- .objects
- .iter()
- .filter_map(|obj| Some((obj.id.clone(), obj.scene_object_id?)))
- .collect();
-
- // Sync room objects to the scene graph
- for obj in &mut self.state.objects {
- let transform = Transform {
- translation: obj.position,
- rotation: obj.rotation,
- scale: obj.scale,
- };
-
- if let Some(scene_id) = obj.scene_object_id {
- r.update_object_transform(scene_id, transform);
- } else if let Some(model) = obj.model_handle {
- // Find parent scene node for objects with location references
- let parent_scene_id = obj.location.as_ref().and_then(|loc| match loc {
- room_state::ObjectLocation::TopOf(target_id)
- | room_state::ObjectLocation::Near(target_id) => {
- id_to_scene.get(target_id).copied()
- }
- _ => None,
- });
-
- let scene_id = if let Some(parent_id) = parent_scene_id {
- r.place_object_with_parent(model, transform, parent_id)
- } else {
- r.place_object(model, transform)
- };
+ sync_objects_to_scene(&mut self.state.objects, &mut r);
- obj.scene_object_id = Some(scene_id);
- id_to_scene.insert(obj.id.clone(), scene_id);
- }
- }
-
- // Read avatar position/yaw from the third-person controller
- let avatar_pos = r.avatar_position();
- let avatar_yaw = r.avatar_yaw();
-
- // Update self-user's position from the controller
- if let Some(pos) = avatar_pos
- && let Some(self_user) = self.state.users.iter_mut().find(|u| u.is_self)
+ // Update self-user's position from the camera controller
+ if let Some(pos) = r.avatar_position()
+ && let Some(self_user) = self.state.self_user_mut()
{
self_user.position = pos;
self_user.display_position = pos;
}
- // Sync all user avatars to the scene
- let avatar_half_h = self
- .avatar_bounds
- .map(|b| (b.max.y - b.min.y) * 0.5)
- .unwrap_or(0.0);
- let avatar_y_offset = avatar_half_h * AVATAR_SCALE;
- let now = self.start_time.elapsed().as_secs_f64();
+ // Smoothly lerp avatar yaw toward controller target
let dt = 1.0 / 60.0_f32;
-
- // Smoothly lerp avatar yaw toward target
- if let Some(target_yaw) = avatar_yaw {
- let current = self.state.smooth_avatar_yaw;
- let mut diff = target_yaw - current;
- diff = (diff + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU)
- - std::f32::consts::PI;
- let t = (AVATAR_YAW_LERP_SPEED * dt).min(1.0);
- self.state.smooth_avatar_yaw = current + diff * t;
+ if let Some(target_yaw) = r.avatar_yaw() {
+ self.state.smooth_avatar_yaw = lerp_yaw(
+ self.state.smooth_avatar_yaw,
+ target_yaw,
+ AVATAR_YAW_LERP_SPEED * dt,
+ );
}
- for user in &mut self.state.users {
- // Dead reckoning for remote users
- if !user.is_self {
- let time_since_update = (now - user.update_time).min(MAX_EXTRAPOLATION_TIME) as f32;
- let extrapolated = user.position + user.velocity * time_since_update;
-
- // Clamp extrapolation distance to prevent runaway drift
- let offset = extrapolated - user.position;
- let target = if offset.length() > MAX_EXTRAPOLATION_DISTANCE {
- user.position + offset.normalize() * MAX_EXTRAPOLATION_DISTANCE
- } else {
- extrapolated
- };
-
- // Smooth lerp display_position toward the extrapolated target
- let t = (AVATAR_POS_LERP_SPEED * dt).min(1.0);
- user.display_position = user.display_position.lerp(target, t);
- }
-
- let render_pos = user.display_position;
- let yaw = if user.is_self {
- self.state.smooth_avatar_yaw
- } else {
- 0.0
- };
-
- let transform = Transform {
- translation: render_pos + Vec3::new(0.0, avatar_y_offset, 0.0),
- rotation: glam::Quat::from_rotation_y(yaw),
- scale: Vec3::splat(AVATAR_SCALE),
- };
-
- if let Some(scene_id) = user.scene_object_id {
- r.update_object_transform(scene_id, transform);
- } else if let Some(model) = user.model_handle {
- let scene_id = r.place_object(model, transform);
- user.scene_object_id = Some(scene_id);
- }
- }
+ let now = self.start_time.elapsed().as_secs_f64();
+ let avatar_y_offset = self
+ .avatar_bounds
+ .map(|b| (b.max.y - b.min.y) * 0.5)
+ .unwrap_or(0.0)
+ * AVATAR_SCALE;
+
+ update_remote_user_positions(&mut self.state.users, dt, now);
+ sync_users_to_scene(
+ &mut self.state.users,
+ self.state.smooth_avatar_yaw,
+ avatar_y_offset,
+ &mut r,
+ );
}
/// Get the current state
@@ -586,10 +598,17 @@ impl NostrverseApp {
impl notedeck::App for NostrverseApp {
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse {
// Initialize on first frame
- self.initialize(ctx);
+ let egui_ctx = ui.ctx().clone();
+ self.initialize(ctx, &egui_ctx);
+
+ // Send relay subscription once connected
+ self.maybe_send_relay_sub(ctx.pool);
- // Poll for room event updates
- self.poll_room_updates(ctx.ndb);
+ // Poll for space event updates
+ self.poll_space_updates(ctx.ndb);
+
+ // Poll for completed model downloads
+ self.poll_model_downloads();
// Presence: publish, poll, expire
self.tick_presence(ctx);
@@ -668,11 +687,23 @@ impl NostrverseApp {
}
self.state.selected_object = selected;
}
- NostrverseAction::SaveRoom => {
- self.save_room(ctx);
+ NostrverseAction::SaveSpace => {
+ self.save_space(ctx);
self.state.dirty = false;
}
- NostrverseAction::AddObject(obj) => {
+ NostrverseAction::AddObject(mut obj) => {
+ // Try to load model immediately (handles local + cached remote)
+ if let Some(url) = obj.model_url.clone() {
+ let local_path = self.model_cache.as_mut().and_then(|c| c.request(&url));
+ if let Some(path) = local_path {
+ obj.model_handle = self.load_model(path.to_str().unwrap_or(&url));
+ if obj.model_handle.is_some()
+ && let Some(cache) = &mut self.model_cache
+ {
+ cache.mark_loaded(&url);
+ }
+ }
+ }
self.state.objects.push(obj);
self.state.dirty = true;
}
@@ -711,3 +742,172 @@ impl NostrverseApp {
}
}
}
+
+/// Sync room objects to the renderbud scene graph.
+/// Updates transforms for existing objects and places new ones.
+fn sync_objects_to_scene(objects: &mut [RoomObject], r: &mut renderbud::Renderer) {
+ let mut id_to_scene: std::collections::HashMap<String, renderbud::ObjectId> = objects
+ .iter()
+ .filter_map(|obj| Some((obj.id.clone(), obj.scene_object_id?)))
+ .collect();
+
+ for obj in objects.iter_mut() {
+ let transform = Transform {
+ translation: obj.position,
+ rotation: obj.rotation,
+ scale: obj.scale,
+ };
+
+ if let Some(scene_id) = obj.scene_object_id {
+ r.update_object_transform(scene_id, transform);
+ } else if let Some(model) = obj.model_handle {
+ let parent_scene_id = obj.location.as_ref().and_then(|loc| match loc {
+ room_state::ObjectLocation::TopOf(target_id)
+ | room_state::ObjectLocation::Near(target_id) => {
+ id_to_scene.get(target_id).copied()
+ }
+ _ => None,
+ });
+
+ let scene_id = if let Some(parent_id) = parent_scene_id {
+ r.place_object_with_parent(model, transform, parent_id)
+ } else {
+ r.place_object(model, transform)
+ };
+
+ obj.scene_object_id = Some(scene_id);
+ id_to_scene.insert(obj.id.clone(), scene_id);
+ }
+ }
+}
+
+/// Smoothly interpolate between two yaw angles, wrapping around TAU.
+fn lerp_yaw(current: f32, target: f32, speed: f32) -> f32 {
+ let mut diff = target - current;
+ diff = (diff + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI;
+ current + diff * speed.min(1.0)
+}
+
+/// Apply dead reckoning to remote users, smoothing their display positions.
+fn update_remote_user_positions(users: &mut [RoomUser], dt: f32, now: f64) {
+ for user in users.iter_mut() {
+ if user.is_self {
+ continue;
+ }
+ let time_since_update = (now - user.update_time).min(MAX_EXTRAPOLATION_TIME) as f32;
+ let extrapolated = user.position + user.velocity * time_since_update;
+
+ let offset = extrapolated - user.position;
+ let target = if offset.length() > MAX_EXTRAPOLATION_DISTANCE {
+ user.position + offset.normalize() * MAX_EXTRAPOLATION_DISTANCE
+ } else {
+ extrapolated
+ };
+
+ let t = (AVATAR_POS_LERP_SPEED * dt).min(1.0);
+ user.display_position = user.display_position.lerp(target, t);
+ }
+}
+
+/// Sync user avatars to the renderbud scene with proper transforms.
+fn sync_users_to_scene(
+ users: &mut [RoomUser],
+ smooth_yaw: f32,
+ avatar_y_offset: f32,
+ r: &mut renderbud::Renderer,
+) {
+ for user in users.iter_mut() {
+ let yaw = if user.is_self { smooth_yaw } else { 0.0 };
+
+ let transform = Transform {
+ translation: user.display_position + Vec3::new(0.0, avatar_y_offset, 0.0),
+ rotation: glam::Quat::from_rotation_y(yaw),
+ scale: Vec3::splat(AVATAR_SCALE),
+ };
+
+ if let Some(scene_id) = user.scene_object_id {
+ r.update_object_transform(scene_id, transform);
+ } else if let Some(model) = user.model_handle {
+ user.scene_object_id = Some(r.place_object(model, transform));
+ }
+ }
+}
+
+/// Resolve semantic locations (top-of, near, floor) to concrete positions
+/// using the provided AABB bounds map.
+fn resolve_locations(
+ objects: &mut [RoomObject],
+ bounds_by_id: &std::collections::HashMap<String, renderbud::Aabb>,
+) {
+ let mut resolved: Vec<(usize, Vec3, Vec3)> = Vec::new();
+
+ for (i, obj) in objects.iter().enumerate() {
+ let Some(loc) = &obj.location else {
+ continue;
+ };
+
+ let local_base = match loc {
+ room_state::ObjectLocation::TopOf(target_id) => {
+ let target_top = bounds_by_id.get(target_id).map(|b| b.max.y).unwrap_or(0.0);
+ let self_half_h = bounds_by_id
+ .get(&obj.id)
+ .map(|b| (b.max.y - b.min.y) * 0.5)
+ .unwrap_or(0.0);
+ Some(Vec3::new(0.0, target_top + self_half_h, 0.0))
+ }
+ room_state::ObjectLocation::Near(target_id) => {
+ let offset = bounds_by_id
+ .get(target_id)
+ .map(|b| b.max.x - b.min.x)
+ .unwrap_or(1.0);
+ Some(Vec3::new(offset, 0.0, 0.0))
+ }
+ room_state::ObjectLocation::Floor => {
+ let self_half_h = bounds_by_id
+ .get(&obj.id)
+ .map(|b| (b.max.y - b.min.y) * 0.5)
+ .unwrap_or(0.0);
+ Some(Vec3::new(0.0, self_half_h, 0.0))
+ }
+ _ => None,
+ };
+
+ if let Some(base) = local_base {
+ resolved.push((i, base, base + obj.position));
+ }
+ }
+
+ for (i, base, pos) in resolved {
+ objects[i].location_base = Some(base);
+ objects[i].position = pos;
+ }
+}
+
+/// Collect AABB bounds for all objects that have a loaded model.
+fn collect_bounds(
+ renderer: Option<&renderbud::egui::EguiRenderer>,
+ objects: &[RoomObject],
+) -> std::collections::HashMap<String, renderbud::Aabb> {
+ let mut bounds = std::collections::HashMap::new();
+ let Some(renderer) = renderer else {
+ return bounds;
+ };
+ let r = renderer.renderer.lock().unwrap();
+ for obj in objects {
+ if let Some(model) = obj.model_handle
+ && let Some(b) = r.model_bounds(model)
+ {
+ bounds.insert(obj.id.clone(), b);
+ }
+ }
+ bounds
+}
+
+/// Re-resolve semantic locations (top-of, near, floor) using current model bounds.
+fn resolve_object_locations(
+ renderer: Option<&renderbud::egui::EguiRenderer>,
+ objects: &mut [RoomObject],
+) {
+ let bounds_by_id = collect_bounds(renderer, objects);
+ resolve_locations(objects, &bounds_by_id);
+}
diff --git a/crates/notedeck_nostrverse/src/model_cache.rs b/crates/notedeck_nostrverse/src/model_cache.rs
@@ -0,0 +1,165 @@
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use poll_promise::Promise;
+use sha2::{Digest, Sha256};
+
+/// Status of a model fetch operation.
+enum ModelFetchStatus {
+ /// HTTP download in progress.
+ Downloading(Promise<Result<PathBuf, String>>),
+ /// Downloaded to disk, ready for GPU load on next poll.
+ ReadyToLoad(PathBuf),
+ /// Model handle assigned; terminal state.
+ Loaded,
+ /// Download or load failed; terminal state.
+ Failed,
+}
+
+/// Manages async downloading and disk caching of remote 3D models.
+///
+/// Local file paths are passed through unchanged.
+/// HTTP/HTTPS URLs are downloaded via `ehttp`, cached to disk under
+/// a sha256-hashed filename, and then loaded from the cache path.
+pub struct ModelCache {
+ cache_dir: PathBuf,
+ fetches: HashMap<String, ModelFetchStatus>,
+}
+
+impl ModelCache {
+ pub fn new(cache_dir: PathBuf) -> Self {
+ let _ = std::fs::create_dir_all(&cache_dir);
+ Self {
+ cache_dir,
+ fetches: HashMap::new(),
+ }
+ }
+
+ /// Returns true if `url` is an HTTP or HTTPS URL.
+ fn is_remote(url: &str) -> bool {
+ url.starts_with("http://") || url.starts_with("https://")
+ }
+
+ /// Compute on-disk cache path: `<cache_dir>/<sha256(url)>.<ext>`.
+ fn cache_path(&self, url: &str) -> PathBuf {
+ let mut hasher = Sha256::new();
+ hasher.update(url.as_bytes());
+ let hash = format!("{:x}", hasher.finalize());
+
+ let ext = std::path::Path::new(url)
+ .extension()
+ .and_then(|e| e.to_str())
+ .unwrap_or("glb");
+
+ self.cache_dir.join(format!("{hash}.{ext}"))
+ }
+
+ /// Request a model by URL.
+ ///
+ /// - Local paths: returns `Some(PathBuf)` immediately.
+ /// - Cached remote URLs: returns `Some(PathBuf)` from disk cache.
+ /// - Uncached remote URLs: initiates async download, returns `None`.
+ /// The download result will be available via [`poll`] on a later frame.
+ pub fn request(&mut self, url: &str) -> Option<PathBuf> {
+ if !Self::is_remote(url) {
+ return Some(PathBuf::from(url));
+ }
+
+ if let Some(status) = self.fetches.get(url) {
+ return match status {
+ ModelFetchStatus::ReadyToLoad(path) => Some(path.clone()),
+ ModelFetchStatus::Loaded
+ | ModelFetchStatus::Failed
+ | ModelFetchStatus::Downloading(_) => None,
+ };
+ }
+
+ // Check disk cache
+ let cached = self.cache_path(url);
+ if cached.exists() {
+ tracing::info!("Model cache hit: {}", url);
+ self.fetches.insert(
+ url.to_owned(),
+ ModelFetchStatus::ReadyToLoad(cached.clone()),
+ );
+ return Some(cached);
+ }
+
+ // Start async download
+ tracing::info!("Downloading model: {}", url);
+ let (sender, promise) = Promise::new();
+ let target_path = cached;
+ let request = ehttp::Request::get(url);
+
+ let url_owned = url.to_owned();
+ ehttp::fetch(request, move |response: Result<ehttp::Response, String>| {
+ let result = (|| -> Result<PathBuf, String> {
+ let resp = response.map_err(|e| format!("HTTP error: {e}"))?;
+ if !resp.ok {
+ return Err(format!("HTTP {}: {}", resp.status, resp.status_text));
+ }
+ if resp.bytes.is_empty() {
+ return Err("Empty response body".to_string());
+ }
+
+ if let Some(parent) = target_path.parent() {
+ std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
+ }
+
+ // Atomic write: .tmp then rename
+ let tmp_path = target_path.with_extension("tmp");
+ std::fs::write(&tmp_path, &resp.bytes).map_err(|e| format!("write: {e}"))?;
+ std::fs::rename(&tmp_path, &target_path).map_err(|e| format!("rename: {e}"))?;
+
+ tracing::info!("Cached {} bytes for {}", resp.bytes.len(), url_owned);
+ Ok(target_path)
+ })();
+ sender.send(result);
+ });
+
+ self.fetches
+ .insert(url.to_owned(), ModelFetchStatus::Downloading(promise));
+ None
+ }
+
+ /// Poll in-flight downloads. Returns URLs whose files are now ready to load.
+ pub fn poll(&mut self) -> Vec<(String, PathBuf)> {
+ let mut ready = Vec::new();
+ let keys: Vec<String> = self.fetches.keys().cloned().collect();
+
+ for url in keys {
+ let needs_transition = {
+ let status = self.fetches.get_mut(&url).unwrap();
+ if let ModelFetchStatus::Downloading(promise) = status {
+ promise.ready().is_some()
+ } else {
+ false
+ }
+ };
+
+ if needs_transition
+ && let Some(ModelFetchStatus::Downloading(promise)) = self.fetches.remove(&url)
+ {
+ match promise.block_and_take() {
+ Ok(path) => {
+ ready.push((url.clone(), path.clone()));
+ self.fetches
+ .insert(url, ModelFetchStatus::ReadyToLoad(path));
+ }
+ Err(e) => {
+ tracing::warn!("Model download failed for {}: {}", url, e);
+ self.fetches.insert(url, ModelFetchStatus::Failed);
+ }
+ }
+ }
+ }
+
+ ready
+ }
+
+ /// Mark a URL as fully loaded (model handle assigned).
+ pub fn mark_loaded(&mut self, url: &str) {
+ self.fetches
+ .insert(url.to_owned(), ModelFetchStatus::Loaded);
+ }
+}
diff --git a/crates/notedeck_nostrverse/src/nostr_events.rs b/crates/notedeck_nostrverse/src/nostr_events.rs
@@ -1,6 +1,6 @@
-//! Nostr event creation and parsing for nostrverse rooms.
+//! Nostr event creation and parsing for nostrverse spaces.
//!
-//! Room events (kind 37555) are NIP-33 parameterized replaceable events
+//! Space events (kind 37555) are NIP-33 parameterized replaceable events
//! where the content is a protoverse `.space` s-expression.
use enostr::FilledKeypair;
@@ -9,21 +9,21 @@ use protoverse::Space;
use crate::kinds;
-/// Build a room event (kind 37555) from a protoverse Space.
+/// Build a space event (kind 37555) from a protoverse Space.
///
-/// Tags: ["d", room_id], ["name", room_name], ["summary", text_description]
+/// Tags: ["d", space_id], ["name", space_name], ["summary", text_description]
/// Content: serialized .space s-expression
-pub fn build_room_event<'a>(space: &Space, room_id: &str) -> NoteBuilder<'a> {
+pub fn build_space_event<'a>(space: &Space, space_id: &str) -> NoteBuilder<'a> {
let content = protoverse::serialize(space);
let summary = protoverse::describe(space);
- let name = space.name(space.root).unwrap_or("Untitled Room");
+ let name = space.name(space.root).unwrap_or("Untitled Space");
NoteBuilder::new()
.kind(kinds::ROOM as u32)
.content(&content)
.start_tag()
.tag_str("d")
- .tag_str(room_id)
+ .tag_str(space_id)
.start_tag()
.tag_str("name")
.tag_str(name)
@@ -32,8 +32,8 @@ pub fn build_room_event<'a>(space: &Space, room_id: &str) -> NoteBuilder<'a> {
.tag_str(&summary)
}
-/// Parse a room event's content into a protoverse Space.
-pub fn parse_room_event(note: &Note<'_>) -> Option<Space> {
+/// Parse a space event's content into a protoverse Space.
+pub fn parse_space_event(note: &Note<'_>) -> Option<Space> {
let content = note.content();
if content.is_empty() {
return None;
@@ -41,8 +41,8 @@ pub fn parse_room_event(note: &Note<'_>) -> Option<Space> {
protoverse::parse(content).ok()
}
-/// Extract the "d" tag (room identifier) from a note.
-pub fn get_room_id<'a>(note: &'a Note<'a>) -> Option<&'a str> {
+/// Extract the "d" tag (space identifier) from a note.
+pub fn get_space_id<'a>(note: &'a Note<'a>) -> Option<&'a str> {
get_tag_value(note, "d")
}
@@ -103,37 +103,41 @@ pub fn build_presence_event<'a>(
.tag_str(&exp_str)
}
-/// Parse a presence event's position tag into a Vec3.
-pub fn parse_presence_position(note: &Note<'_>) -> Option<glam::Vec3> {
- let pos_str = get_tag_value(note, "position")?;
- let mut parts = pos_str.split_whitespace();
+/// Parse a whitespace-separated "x y z" string into a Vec3.
+fn parse_vec3(s: &str) -> Option<glam::Vec3> {
+ let mut parts = s.split_whitespace();
let x: f32 = parts.next()?.parse().ok()?;
let y: f32 = parts.next()?.parse().ok()?;
let z: f32 = parts.next()?.parse().ok()?;
Some(glam::Vec3::new(x, y, z))
}
+/// Parse a presence event's position tag into a Vec3.
+pub fn parse_presence_position(note: &Note<'_>) -> Option<glam::Vec3> {
+ parse_vec3(get_tag_value(note, "position")?)
+}
+
/// Parse a presence event's velocity tag into a Vec3.
/// Returns Vec3::ZERO if no velocity tag (backward compatible with old events).
pub fn parse_presence_velocity(note: &Note<'_>) -> glam::Vec3 {
- let Some(vel_str) = get_tag_value(note, "velocity") else {
- return glam::Vec3::ZERO;
- };
- let mut parts = vel_str.split_whitespace();
- let x: f32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
- let y: f32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
- let z: f32 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0.0);
- glam::Vec3::new(x, y, z)
+ get_tag_value(note, "velocity")
+ .and_then(parse_vec3)
+ .unwrap_or(glam::Vec3::ZERO)
}
-/// Extract the "a" tag (room naddr) from a presence note.
-pub fn get_presence_room<'a>(note: &'a Note<'a>) -> Option<&'a str> {
+/// Extract the "a" tag (space naddr) from a presence note.
+pub fn get_presence_space<'a>(note: &'a Note<'a>) -> Option<&'a str> {
get_tag_value(note, "a")
}
-/// Sign and ingest a nostr event into the local nostrdb only (no relay publishing).
-/// Returns the 32-byte event ID on success.
-pub fn ingest_event(builder: NoteBuilder<'_>, ndb: &Ndb, kp: FilledKeypair) -> Option<[u8; 32]> {
+/// Sign and ingest a nostr event into the local nostrdb.
+/// Returns the ClientMessage (for optional relay publishing) and
+/// the 32-byte event ID on success.
+pub fn ingest_event(
+ builder: NoteBuilder<'_>,
+ ndb: &Ndb,
+ kp: FilledKeypair,
+) -> Option<(enostr::ClientMessage, [u8; 32])> {
let note = builder
.sign(&kp.secret_key.secret_bytes())
.build()
@@ -141,7 +145,7 @@ pub fn ingest_event(builder: NoteBuilder<'_>, ndb: &Ndb, kp: FilledKeypair) -> O
let id = *note.id();
- let Ok(event) = &enostr::ClientMessage::event(¬e) else {
+ let Ok(event) = enostr::ClientMessage::event(¬e) else {
tracing::error!("ingest_event: failed to build client message");
return None;
};
@@ -152,7 +156,8 @@ pub fn ingest_event(builder: NoteBuilder<'_>, ndb: &Ndb, kp: FilledKeypair) -> O
};
let _ = ndb.process_event_with(&json, nostrdb::IngestMetadata::new().client(true));
- Some(id)
+
+ Some((event, id))
}
#[cfg(test)]
@@ -160,20 +165,20 @@ mod tests {
use super::*;
#[test]
- fn test_build_room_event() {
+ fn test_build_space_event() {
let space = protoverse::parse(
- r#"(room (name "Test Room") (shape rectangle) (width 10) (depth 8)
+ r#"(space (name "Test Space")
(group (table (id desk) (name "My Desk"))))"#,
)
.unwrap();
- let mut builder = build_room_event(&space, "my-room");
+ let mut builder = build_space_event(&space, "my-space");
let note = builder.build().expect("build note");
// Content should be the serialized space
let content = note.content();
- assert!(content.contains("room"));
- assert!(content.contains("Test Room"));
+ assert!(content.contains("space"));
+ assert!(content.contains("Test Space"));
// Should have d, name, summary tags
let mut has_d = false;
@@ -186,11 +191,11 @@ mod tests {
}
match tag.get_str(0) {
Some("d") => {
- assert_eq!(tag.get_str(1), Some("my-room"));
+ assert_eq!(tag.get_str(1), Some("my-space"));
has_d = true;
}
Some("name") => {
- assert_eq!(tag.get_str(1), Some("Test Room"));
+ assert_eq!(tag.get_str(1), Some("Test Space"));
has_name = true;
}
Some("summary") => {
@@ -206,29 +211,29 @@ mod tests {
}
#[test]
- fn test_parse_room_event_roundtrip() {
- let original = r#"(room (name "Test Room") (shape rectangle) (width 10) (depth 8)
+ fn test_parse_space_event_roundtrip() {
+ let original = r#"(space (name "Test Space")
(group (table (id desk) (name "My Desk"))))"#;
let space = protoverse::parse(original).unwrap();
- let mut builder = build_room_event(&space, "test-room");
+ let mut builder = build_space_event(&space, "test-space");
let note = builder.build().expect("build note");
// Parse the event content back into a Space
- let parsed = parse_room_event(¬e).expect("parse room event");
- assert_eq!(parsed.name(parsed.root), Some("Test Room"));
+ let parsed = parse_space_event(¬e).expect("parse space event");
+ assert_eq!(parsed.name(parsed.root), Some("Test Space"));
// Should have same structure
assert_eq!(space.cells.len(), parsed.cells.len());
}
#[test]
- fn test_get_room_id() {
- let space = protoverse::parse("(room (name \"X\"))").unwrap();
- let mut builder = build_room_event(&space, "my-id");
+ fn test_get_space_id() {
+ let space = protoverse::parse("(space (name \"X\"))").unwrap();
+ let mut builder = build_space_event(&space, "my-id");
let note = builder.build().expect("build note");
- assert_eq!(get_room_id(¬e), Some("my-id"));
+ assert_eq!(get_space_id(¬e), Some("my-id"));
}
#[test]
@@ -239,7 +244,7 @@ mod tests {
let note = builder.build().expect("build note");
assert_eq!(note.content(), "");
- assert_eq!(get_presence_room(¬e), Some("37555:abc123:my-room"));
+ assert_eq!(get_presence_space(¬e), Some("37555:abc123:my-room"));
let parsed_pos = parse_presence_position(¬e).expect("parse position");
assert!((parsed_pos.x - 1.5).abs() < 0.01);
diff --git a/crates/notedeck_nostrverse/src/presence.rs b/crates/notedeck_nostrverse/src/presence.rs
@@ -124,7 +124,8 @@ impl PresencePublisher {
self.published_once = true;
}
- /// Maybe publish a presence heartbeat. Returns true if published.
+ /// Maybe publish a presence heartbeat.
+ /// Returns the ClientMessage if published (for optional relay forwarding).
pub fn maybe_publish(
&mut self,
ndb: &Ndb,
@@ -132,7 +133,7 @@ impl PresencePublisher {
room_naddr: &str,
position: Vec3,
now: f64,
- ) -> bool {
+ ) -> Option<enostr::ClientMessage> {
let velocity = self.compute_velocity(position, now);
// Always update position sample for velocity computation
@@ -140,14 +141,14 @@ impl PresencePublisher {
self.prev_position_time = now;
if !self.should_publish(position, velocity, now) {
- return false;
+ return None;
}
let builder = nostr_events::build_presence_event(room_naddr, position, velocity);
- nostr_events::ingest_event(builder, ndb, kp);
+ let result = nostr_events::ingest_event(builder, ndb, kp);
self.record_publish(position, velocity, now);
- true
+ result.map(|(msg, _id)| msg)
}
}
@@ -167,11 +168,11 @@ pub fn poll_presence(
let mut changed = false;
for note in ¬es {
- // Filter to our room
- let Some(event_room) = nostr_events::get_presence_room(note) else {
+ // Filter to our space
+ let Some(event_space) = nostr_events::get_presence_space(note) else {
continue;
};
- if event_room != room_naddr {
+ if event_space != room_naddr {
continue;
}
diff --git a/crates/notedeck_nostrverse/src/room_state.rs b/crates/notedeck_nostrverse/src/room_state.rs
@@ -1,4 +1,4 @@
-//! Room state management for nostrverse views
+//! Space state management for nostrverse views
use enostr::Pubkey;
use glam::{Quat, Vec3};
@@ -11,8 +11,8 @@ pub enum NostrverseAction {
MoveObject { id: String, position: Vec3 },
/// Object was selected
SelectObject(Option<String>),
- /// Room or object was edited, needs re-ingest
- SaveRoom,
+ /// Space or object was edited, needs re-ingest
+ SaveSpace,
/// A new object was added
AddObject(RoomObject),
/// An object was removed
@@ -23,16 +23,16 @@ pub enum NostrverseAction {
RotateObject { id: String, rotation: Quat },
}
-/// Reference to a nostrverse room
+/// Reference to a nostrverse space
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
-pub struct RoomRef {
- /// Room identifier (d-tag)
+pub struct SpaceRef {
+ /// Space identifier (d-tag)
pub id: String,
- /// Room owner pubkey
+ /// Space owner pubkey
pub pubkey: Pubkey,
}
-impl RoomRef {
+impl SpaceRef {
pub fn new(id: String, pubkey: Pubkey) -> Self {
Self { id, pubkey }
}
@@ -43,37 +43,20 @@ impl RoomRef {
}
}
-/// Parsed room data from event
+/// Parsed space data from event
#[derive(Clone, Debug)]
-pub struct Room {
+pub struct SpaceInfo {
pub name: String,
- pub shape: RoomShape,
- pub width: f32,
- pub height: f32,
- pub depth: f32,
}
-impl Default for Room {
+impl Default for SpaceInfo {
fn default() -> Self {
Self {
- name: "Untitled Room".to_string(),
- shape: RoomShape::Rectangle,
- width: 20.0,
- height: 15.0,
- depth: 10.0,
+ name: "Untitled Space".to_string(),
}
}
}
-/// Room shape types
-#[derive(Clone, Debug, Default, PartialEq, Eq)]
-pub enum RoomShape {
- #[default]
- Rectangle,
- Circle,
- Custom,
-}
-
/// Spatial location relative to the room or another object.
/// Mirrors protoverse::Location for decoupling.
#[derive(Clone, Debug, PartialEq)]
@@ -237,13 +220,13 @@ pub struct DragState {
/// State for a nostrverse view
pub struct NostrverseState {
- /// Reference to the room being viewed
- pub room_ref: RoomRef,
- /// Parsed room data (if loaded)
- pub room: Option<Room>,
- /// Objects in the room
+ /// Reference to the space being viewed
+ pub space_ref: SpaceRef,
+ /// Parsed space data (if loaded)
+ pub space: Option<SpaceInfo>,
+ /// Objects in the space
pub objects: Vec<RoomObject>,
- /// Users currently in the room
+ /// Users currently in the space
pub users: Vec<RoomUser>,
/// Currently selected object ID
pub selected_object: Option<String>,
@@ -251,7 +234,7 @@ pub struct NostrverseState {
pub edit_mode: bool,
/// Smoothed avatar yaw for lerped rotation
pub smooth_avatar_yaw: f32,
- /// Room has unsaved edits
+ /// Space has unsaved edits
pub dirty: bool,
/// Active drag state for viewport object manipulation
pub drag_state: Option<DragState>,
@@ -270,10 +253,10 @@ pub struct NostrverseState {
}
impl NostrverseState {
- pub fn new(room_ref: RoomRef) -> Self {
+ pub fn new(space_ref: SpaceRef) -> Self {
Self {
- room_ref,
- room: None,
+ space_ref,
+ space: None,
objects: Vec::new(),
users: Vec::new(),
selected_object: None,
@@ -308,4 +291,14 @@ impl NostrverseState {
pub fn get_object_mut(&mut self, id: &str) -> Option<&mut RoomObject> {
self.objects.iter_mut().find(|o| o.id == id)
}
+
+ /// Get the local user
+ pub fn self_user(&self) -> Option<&RoomUser> {
+ self.users.iter().find(|u| u.is_self)
+ }
+
+ /// Get the local user mutably
+ pub fn self_user_mut(&mut self) -> Option<&mut RoomUser> {
+ self.users.iter_mut().find(|u| u.is_self)
+ }
}
diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs
@@ -1,11 +1,11 @@
-//! Room 3D rendering and editing UI for nostrverse via renderbud
+//! Space 3D rendering and editing UI for nostrverse via renderbud
use egui::{Color32, Pos2, Rect, Response, Sense, Ui};
use glam::{Quat, Vec3};
use super::convert;
use super::room_state::{
- DragMode, DragState, NostrverseAction, NostrverseState, ObjectLocation, RoomObject, RoomShape,
+ DragMode, DragState, NostrverseAction, NostrverseState, ObjectLocation, RoomObject,
};
/// Radians of Y rotation per pixel of horizontal drag
@@ -169,6 +169,223 @@ fn compute_drag_update(
}
}
+/// Try to start an object drag. Returns the action (selection) if an object was picked.
+fn handle_drag_start(
+ state: &mut NostrverseState,
+ vp_x: f32,
+ vp_y: f32,
+ r: &mut renderbud::Renderer,
+) -> Option<NostrverseAction> {
+ let scene_id = r.pick(vp_x, vp_y)?;
+ let obj = state
+ .objects
+ .iter()
+ .find(|o| o.scene_object_id == Some(scene_id))?;
+
+ // Always select on drag start
+ r.set_selected(Some(scene_id));
+ state.selected_object = Some(obj.id.clone());
+
+ // In rotate mode, mark this as a rotation drag (don't start a position drag)
+ let drag_info = if state.rotate_mode {
+ state.rotate_drag = true;
+ None
+ } else {
+ compute_initial_drag(obj, state, vp_x, vp_y, r)
+ };
+
+ if let Some((mode, grab_offset, plane_y)) = drag_info {
+ state.drag_state = Some(DragState {
+ object_id: obj.id.clone(),
+ grab_offset,
+ plane_y,
+ mode,
+ });
+ }
+ None
+}
+
+/// Compute the initial drag mode and grab offset for an object.
+fn compute_initial_drag(
+ obj: &RoomObject,
+ state: &NostrverseState,
+ vp_x: f32,
+ vp_y: f32,
+ r: &renderbud::Renderer,
+) -> Option<(DragMode, Vec3, f32)> {
+ match &obj.location {
+ Some(ObjectLocation::TopOf(parent_id)) | Some(ObjectLocation::Near(parent_id)) => {
+ let parent = state.objects.iter().find(|o| o.id == *parent_id)?;
+ let parent_scene_id = parent.scene_object_id?;
+ let parent_aabb = r.model_bounds(parent.model_handle?)?;
+ let parent_world = r.world_matrix(parent_scene_id)?;
+
+ let child_half_h = obj
+ .model_handle
+ .and_then(|m| r.model_bounds(m))
+ .map(|b| (b.max.y - b.min.y) * 0.5)
+ .unwrap_or(0.0);
+ let local_y = if matches!(&obj.location, Some(ObjectLocation::TopOf(_))) {
+ parent_aabb.max.y + child_half_h
+ } else {
+ 0.0
+ };
+ let obj_world = parent_world.transform_point3(obj.position);
+ let plane_y = obj_world.y;
+ let hit = r
+ .unproject_to_plane(vp_x, vp_y, plane_y)
+ .unwrap_or(obj_world);
+ let local_hit = parent_world.inverse().transform_point3(hit);
+ let grab_offset = obj.position - local_hit;
+ Some((
+ DragMode::Parented {
+ parent_id: parent_id.clone(),
+ parent_scene_id,
+ parent_aabb,
+ local_y,
+ },
+ grab_offset,
+ plane_y,
+ ))
+ }
+ None | Some(ObjectLocation::Floor) => {
+ let plane_y = obj.position.y;
+ let hit = r
+ .unproject_to_plane(vp_x, vp_y, plane_y)
+ .unwrap_or(obj.position);
+ let grab_offset = obj.position - hit;
+ Some((DragMode::Free, grab_offset, plane_y))
+ }
+ _ => None, // Center/Ceiling/Custom: not draggable
+ }
+}
+
+/// Apply a computed drag update to state and renderer.
+fn apply_drag_update(
+ update: DragUpdate,
+ state: &mut NostrverseState,
+ r: &mut renderbud::Renderer,
+) -> Option<NostrverseAction> {
+ match update {
+ DragUpdate::Move { id, position } => Some(NostrverseAction::MoveObject { id, position }),
+ DragUpdate::Breakaway {
+ id,
+ world_pos,
+ new_grab_offset,
+ new_plane_y,
+ } => {
+ if let Some(obj) = state.objects.iter_mut().find(|o| o.id == id) {
+ if let Some(sid) = obj.scene_object_id {
+ r.set_parent(sid, None);
+ }
+ obj.position = world_pos;
+ obj.location = None;
+ obj.location_base = None;
+ state.dirty = true;
+ }
+ state.drag_state = Some(DragState {
+ object_id: id,
+ grab_offset: new_grab_offset,
+ plane_y: new_plane_y,
+ mode: DragMode::Free,
+ });
+ None
+ }
+ DragUpdate::SnapToParent {
+ id,
+ parent_id,
+ parent_scene_id,
+ parent_aabb,
+ local_pos,
+ local_y,
+ plane_y,
+ new_grab_offset,
+ } => {
+ if let Some(obj) = state.objects.iter_mut().find(|o| o.id == id) {
+ if let Some(sid) = obj.scene_object_id {
+ r.set_parent(sid, Some(parent_scene_id));
+ }
+ obj.position = local_pos;
+ obj.location = Some(ObjectLocation::TopOf(parent_id.clone()));
+ obj.location_base = Some(Vec3::new(0.0, local_y, 0.0));
+ state.dirty = true;
+ }
+ state.drag_state = Some(DragState {
+ object_id: id,
+ grab_offset: new_grab_offset,
+ plane_y,
+ mode: DragMode::Parented {
+ parent_id,
+ parent_scene_id,
+ parent_aabb,
+ local_y,
+ },
+ });
+ None
+ }
+ }
+}
+
+/// Handle keyboard shortcuts and WASD movement. Returns an action if triggered.
+fn handle_keyboard_input(
+ ui: &Ui,
+ state: &mut NostrverseState,
+ r: &mut renderbud::Renderer,
+) -> Option<NostrverseAction> {
+ let mut action = None;
+
+ // G key: toggle grid snap
+ if ui.input(|i| i.key_pressed(egui::Key::G)) {
+ state.grid_snap_enabled = !state.grid_snap_enabled;
+ }
+
+ // R key: toggle rotate mode
+ if ui.input(|i| i.key_pressed(egui::Key::R)) {
+ state.rotate_mode = !state.rotate_mode;
+ }
+
+ // Ctrl+D: duplicate selected object
+ if ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::D))
+ && let Some(id) = state.selected_object.clone()
+ {
+ action = Some(NostrverseAction::DuplicateObject(id));
+ }
+
+ // WASD + QE movement: always available
+ let dt = ui.input(|i| i.stable_dt);
+ let mut forward = 0.0_f32;
+ let mut right = 0.0_f32;
+ let mut up = 0.0_f32;
+
+ ui.input(|i| {
+ if i.key_down(egui::Key::W) {
+ forward -= 1.0;
+ }
+ if i.key_down(egui::Key::S) {
+ forward += 1.0;
+ }
+ if i.key_down(egui::Key::D) {
+ right += 1.0;
+ }
+ if i.key_down(egui::Key::A) {
+ right -= 1.0;
+ }
+ if i.key_down(egui::Key::E) || i.key_down(egui::Key::Space) {
+ up += 1.0;
+ }
+ if i.key_down(egui::Key::Q) {
+ up -= 1.0;
+ }
+ });
+
+ if forward != 0.0 || right != 0.0 || up != 0.0 {
+ r.process_movement(forward, right, up, dt);
+ ui.ctx().request_repaint();
+ }
+
+ action
+}
+
/// Render the nostrverse room view with 3D scene
pub fn show_room_view(
ui: &mut Ui,
@@ -193,86 +410,8 @@ pub fn show_room_view(
&& let Some(pos) = response.interact_pointer_pos()
{
let vp = pos - rect.min.to_vec2();
- if let Some(scene_id) = r.pick(vp.x, vp.y)
- && let Some(obj) = state
- .objects
- .iter()
- .find(|o| o.scene_object_id == Some(scene_id))
- {
- // Always select on drag start
- r.set_selected(Some(scene_id));
- state.selected_object = Some(obj.id.clone());
-
- // In rotate mode, mark this as a rotation drag
- // (don't start a position drag)
- let drag_info = if state.rotate_mode {
- state.rotate_drag = true;
- None
- } else {
- match &obj.location {
- Some(ObjectLocation::TopOf(parent_id))
- | Some(ObjectLocation::Near(parent_id)) => {
- let parent_obj = state.objects.iter().find(|o| o.id == *parent_id);
- if let Some(parent) = parent_obj
- && let Some(parent_scene_id) = parent.scene_object_id
- && let Some(parent_model) = parent.model_handle
- && let Some(parent_aabb) = r.model_bounds(parent_model)
- && let Some(parent_world) = r.world_matrix(parent_scene_id)
- {
- let child_half_h = obj
- .model_handle
- .and_then(|m| r.model_bounds(m))
- .map(|b| (b.max.y - b.min.y) * 0.5)
- .unwrap_or(0.0);
- let local_y = if matches!(
- &obj.location,
- Some(ObjectLocation::TopOf(_))
- ) {
- parent_aabb.max.y + child_half_h
- } else {
- 0.0
- };
- let obj_world = parent_world.transform_point3(obj.position);
- let plane_y = obj_world.y;
- let hit = r
- .unproject_to_plane(vp.x, vp.y, plane_y)
- .unwrap_or(obj_world);
- let local_hit = parent_world.inverse().transform_point3(hit);
- let grab_offset = obj.position - local_hit;
- Some((
- DragMode::Parented {
- parent_id: parent_id.clone(),
- parent_scene_id,
- parent_aabb,
- local_y,
- },
- grab_offset,
- plane_y,
- ))
- } else {
- None
- }
- }
- None | Some(ObjectLocation::Floor) => {
- let plane_y = obj.position.y;
- let hit = r
- .unproject_to_plane(vp.x, vp.y, plane_y)
- .unwrap_or(obj.position);
- let grab_offset = obj.position - hit;
- Some((DragMode::Free, grab_offset, plane_y))
- }
- _ => None, // Center/Ceiling/Custom: not draggable
- }
- };
-
- if let Some((mode, grab_offset, plane_y)) = drag_info {
- state.drag_state = Some(DragState {
- object_id: obj.id.clone(),
- grab_offset,
- plane_y,
- mode,
- });
- }
+ if let Some(a) = handle_drag_start(state, vp.x, vp.y, &mut r) {
+ action = Some(a);
}
}
@@ -286,7 +425,6 @@ pub fn show_room_view(
let delta_x = response.drag_delta().x;
let angle = delta_x * ROTATE_SENSITIVITY;
let new_rotation = Quat::from_rotation_y(angle) * obj.rotation;
- // Snap to angle increments when grid snap is enabled
let new_rotation = if state.grid_snap_enabled {
let (_, y, _) = new_rotation.to_euler(glam::EulerRot::YXZ);
let snap_rad = state.rotation_snap.to_radians();
@@ -304,9 +442,7 @@ pub fn show_room_view(
if let Some(pos) = response.interact_pointer_pos() {
let vp = pos - rect.min.to_vec2();
let grid = state.grid_snap_enabled.then_some(state.grid_snap);
- // Borrow of state.drag_state is scoped to this call
let update = compute_drag_update(drag, vp.x, vp.y, grid, &r);
- // Borrow released — free to mutate state
// For free drags, check if we should snap to a parent
let update = if let Some(DragUpdate::Move {
ref id,
@@ -342,64 +478,10 @@ pub fn show_room_view(
update
};
- match update {
- Some(DragUpdate::Move { id, position }) => {
- action = Some(NostrverseAction::MoveObject { id, position });
- }
- Some(DragUpdate::Breakaway {
- id,
- world_pos,
- new_grab_offset,
- new_plane_y,
- }) => {
- if let Some(obj) = state.objects.iter_mut().find(|o| o.id == id) {
- if let Some(sid) = obj.scene_object_id {
- r.set_parent(sid, None);
- }
- obj.position = world_pos;
- obj.location = None;
- obj.location_base = None;
- state.dirty = true;
- }
- state.drag_state = Some(DragState {
- object_id: id,
- grab_offset: new_grab_offset,
- plane_y: new_plane_y,
- mode: DragMode::Free,
- });
- }
- Some(DragUpdate::SnapToParent {
- id,
- parent_id,
- parent_scene_id,
- parent_aabb,
- local_pos,
- local_y,
- plane_y,
- new_grab_offset,
- }) => {
- if let Some(obj) = state.objects.iter_mut().find(|o| o.id == id) {
- if let Some(sid) = obj.scene_object_id {
- r.set_parent(sid, Some(parent_scene_id));
- }
- obj.position = local_pos;
- obj.location = Some(ObjectLocation::TopOf(parent_id.clone()));
- obj.location_base = Some(Vec3::new(0.0, local_y, 0.0));
- state.dirty = true;
- }
- state.drag_state = Some(DragState {
- object_id: id,
- grab_offset: new_grab_offset,
- plane_y,
- mode: DragMode::Parented {
- parent_id,
- parent_scene_id,
- parent_aabb,
- local_y,
- },
- });
- }
- None => {}
+ if let Some(update) = update
+ && let Some(a) = apply_drag_update(update, state, &mut r)
+ {
+ action = Some(a);
}
}
ui.ctx().request_repaint();
@@ -448,53 +530,8 @@ pub fn show_room_view(
}
}
- // G key: toggle grid snap
- if ui.input(|i| i.key_pressed(egui::Key::G)) {
- state.grid_snap_enabled = !state.grid_snap_enabled;
- }
-
- // R key: toggle rotate mode
- if ui.input(|i| i.key_pressed(egui::Key::R)) {
- state.rotate_mode = !state.rotate_mode;
- }
-
- // Ctrl+D: duplicate selected object
- if ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::D))
- && let Some(id) = state.selected_object.clone()
- {
- action = Some(NostrverseAction::DuplicateObject(id));
- }
-
- // WASD + QE movement: always available
- let dt = ui.input(|i| i.stable_dt);
- let mut forward = 0.0_f32;
- let mut right = 0.0_f32;
- let mut up = 0.0_f32;
-
- ui.input(|i| {
- if i.key_down(egui::Key::W) {
- forward -= 1.0;
- }
- if i.key_down(egui::Key::S) {
- forward += 1.0;
- }
- if i.key_down(egui::Key::D) {
- right += 1.0;
- }
- if i.key_down(egui::Key::A) {
- right -= 1.0;
- }
- if i.key_down(egui::Key::E) || i.key_down(egui::Key::Space) {
- up += 1.0;
- }
- if i.key_down(egui::Key::Q) {
- up -= 1.0;
- }
- });
-
- if forward != 0.0 || right != 0.0 || up != 0.0 {
- r.process_movement(forward, right, up, dt);
- ui.ctx().request_repaint();
+ if let Some(a) = handle_keyboard_input(ui, state, &mut r) {
+ action = Some(a);
}
}
@@ -512,13 +549,13 @@ pub fn show_room_view(
}
fn draw_info_overlay(painter: &egui::Painter, state: &NostrverseState, rect: Rect) {
- let room_name = state
- .room
+ let space_name = state
+ .space
.as_ref()
- .map(|r| r.name.as_str())
+ .map(|s| s.name.as_str())
.unwrap_or("Loading...");
- let mut info_text = format!("{} | Objects: {}", room_name, state.objects.len());
+ let mut info_text = format!("{} | Objects: {}", space_name, state.objects.len());
if state.rotate_mode {
info_text.push_str(" | Rotate (R)");
}
@@ -543,93 +580,12 @@ fn draw_info_overlay(painter: &egui::Painter, state: &NostrverseState, rect: Rec
painter.galley(text_pos, galley, Color32::PLACEHOLDER);
}
-/// Render the side panel with room editing, object list, and object inspector.
-pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option<NostrverseAction> {
- let mut action = None;
-
- // --- Room Properties ---
- if let Some(room) = &mut state.room {
- ui.strong("Room");
- ui.separator();
-
- let name_changed = ui
- .horizontal(|ui| {
- ui.label("Name:");
- ui.text_edit_singleline(&mut room.name).changed()
- })
- .inner;
-
- let mut width = room.width;
- let mut height = room.height;
- let mut depth = room.depth;
-
- let dims_changed = ui
- .horizontal(|ui| {
- ui.label("W:");
- let w = ui
- .add(
- egui::DragValue::new(&mut width)
- .speed(0.5)
- .range(1.0..=200.0),
- )
- .changed();
- ui.label("H:");
- let h = ui
- .add(
- egui::DragValue::new(&mut height)
- .speed(0.5)
- .range(1.0..=200.0),
- )
- .changed();
- ui.label("D:");
- let d = ui
- .add(
- egui::DragValue::new(&mut depth)
- .speed(0.5)
- .range(1.0..=200.0),
- )
- .changed();
- w || h || d
- })
- .inner;
-
- room.width = width;
- room.height = height;
- room.depth = depth;
-
- let shape_changed = ui
- .horizontal(|ui| {
- ui.label("Shape:");
- let mut changed = false;
- egui::ComboBox::from_id_salt("room_shape")
- .selected_text(match room.shape {
- RoomShape::Rectangle => "Rectangle",
- RoomShape::Circle => "Circle",
- RoomShape::Custom => "Custom",
- })
- .show_ui(ui, |ui| {
- changed |= ui
- .selectable_value(&mut room.shape, RoomShape::Rectangle, "Rectangle")
- .changed();
- changed |= ui
- .selectable_value(&mut room.shape, RoomShape::Circle, "Circle")
- .changed();
- });
- changed
- })
- .inner;
-
- if name_changed || dims_changed || shape_changed {
- state.dirty = true;
- }
-
- ui.add_space(8.0);
- }
-
- // --- Object List ---
+/// Render the object list and add-object button. Returns an action if triggered.
+fn render_object_list(ui: &mut Ui, state: &NostrverseState) -> Option<NostrverseAction> {
ui.strong("Objects");
ui.separator();
+ let mut action = None;
let num_objects = state.objects.len();
for i in 0..num_objects {
let is_selected = state
@@ -657,130 +613,140 @@ pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option<
action = Some(NostrverseAction::AddObject(obj));
}
- ui.add_space(12.0);
-
- // --- Object Inspector ---
- if let Some(selected_id) = state.selected_object.as_ref()
- && let Some(obj) = state.objects.iter_mut().find(|o| &o.id == selected_id)
- {
- ui.strong("Inspector");
- ui.separator();
+ action
+}
- ui.small(format!("ID: {}", obj.id));
- ui.add_space(4.0);
+/// Render the object inspector panel for the selected object.
+/// Returns an action and whether any property changed.
+fn render_object_inspector(
+ ui: &mut Ui,
+ selected_id: &str,
+ obj: &mut RoomObject,
+ grid_snap_enabled: bool,
+ rotation_snap: f32,
+) -> (Option<NostrverseAction>, bool) {
+ let mut action = None;
- // Editable name
- let name_changed = ui
- .horizontal(|ui| {
- ui.label("Name:");
- ui.text_edit_singleline(&mut obj.name).changed()
- })
- .inner;
+ ui.strong("Inspector");
+ ui.separator();
- // Edit offset (relative to location base) or absolute position
- let base = obj.location_base.unwrap_or(Vec3::ZERO);
- let offset = obj.position - base;
- let mut ox = offset.x;
- let mut oy = offset.y;
- let mut oz = offset.z;
- let has_location = obj.location.is_some();
- let pos_label = if has_location { "Offset:" } else { "Pos:" };
- let pos_changed = ui
- .horizontal(|ui| {
- ui.label(pos_label);
- let x = ui
- .add(egui::DragValue::new(&mut ox).speed(0.1).prefix("x:"))
- .changed();
- let y = ui
- .add(egui::DragValue::new(&mut oy).speed(0.1).prefix("y:"))
- .changed();
- let z = ui
- .add(egui::DragValue::new(&mut oz).speed(0.1).prefix("z:"))
- .changed();
- x || y || z
- })
- .inner;
- obj.position = base + Vec3::new(ox, oy, oz);
+ ui.small(format!("ID: {}", obj.id));
+ ui.add_space(4.0);
- // Editable scale (uniform)
- let mut sx = obj.scale.x;
- let mut sy = obj.scale.y;
- let mut sz = obj.scale.z;
- let scale_changed = ui
- .horizontal(|ui| {
- ui.label("Scale:");
- let x = ui
- .add(
- egui::DragValue::new(&mut sx)
- .speed(0.05)
- .prefix("x:")
- .range(0.01..=100.0),
- )
- .changed();
- let y = ui
- .add(
- egui::DragValue::new(&mut sy)
- .speed(0.05)
- .prefix("y:")
- .range(0.01..=100.0),
- )
- .changed();
- let z = ui
- .add(
- egui::DragValue::new(&mut sz)
- .speed(0.05)
- .prefix("z:")
- .range(0.01..=100.0),
- )
- .changed();
- x || y || z
- })
- .inner;
- obj.scale = Vec3::new(sx, sy, sz);
-
- // Editable Y rotation (degrees)
- let (_, angle_y, _) = obj.rotation.to_euler(glam::EulerRot::YXZ);
- let mut deg = angle_y.to_degrees();
- let snap = state.grid_snap_enabled;
- let snap_deg = state.rotation_snap;
- let rot_changed = ui
- .horizontal(|ui| {
- ui.label("Rot Y:");
- let speed = if snap { snap_deg } else { 1.0 };
- ui.add(egui::DragValue::new(&mut deg).speed(speed).suffix("°"))
- .changed()
- })
- .inner;
- if rot_changed {
- if snap {
- deg = (deg / snap_deg).round() * snap_deg;
- }
- obj.rotation = Quat::from_rotation_y(deg.to_radians());
+ // Editable name
+ let name_changed = ui
+ .horizontal(|ui| {
+ ui.label("Name:");
+ ui.text_edit_singleline(&mut obj.name).changed()
+ })
+ .inner;
+
+ // Edit offset (relative to location base) or absolute position
+ let base = obj.location_base.unwrap_or(Vec3::ZERO);
+ let offset = obj.position - base;
+ let mut ox = offset.x;
+ let mut oy = offset.y;
+ let mut oz = offset.z;
+ let has_location = obj.location.is_some();
+ let pos_label = if has_location { "Offset:" } else { "Pos:" };
+ let pos_changed = ui
+ .horizontal(|ui| {
+ ui.label(pos_label);
+ let x = ui
+ .add(egui::DragValue::new(&mut ox).speed(0.1).prefix("x:"))
+ .changed();
+ let y = ui
+ .add(egui::DragValue::new(&mut oy).speed(0.1).prefix("y:"))
+ .changed();
+ let z = ui
+ .add(egui::DragValue::new(&mut oz).speed(0.1).prefix("z:"))
+ .changed();
+ x || y || z
+ })
+ .inner;
+ obj.position = base + Vec3::new(ox, oy, oz);
+
+ // Editable scale
+ let mut sx = obj.scale.x;
+ let mut sy = obj.scale.y;
+ let mut sz = obj.scale.z;
+ let scale_changed = ui
+ .horizontal(|ui| {
+ ui.label("Scale:");
+ let x = ui
+ .add(
+ egui::DragValue::new(&mut sx)
+ .speed(0.05)
+ .prefix("x:")
+ .range(0.01..=100.0),
+ )
+ .changed();
+ let y = ui
+ .add(
+ egui::DragValue::new(&mut sy)
+ .speed(0.05)
+ .prefix("y:")
+ .range(0.01..=100.0),
+ )
+ .changed();
+ let z = ui
+ .add(
+ egui::DragValue::new(&mut sz)
+ .speed(0.05)
+ .prefix("z:")
+ .range(0.01..=100.0),
+ )
+ .changed();
+ x || y || z
+ })
+ .inner;
+ obj.scale = Vec3::new(sx, sy, sz);
+
+ // Editable Y rotation (degrees)
+ let (_, angle_y, _) = obj.rotation.to_euler(glam::EulerRot::YXZ);
+ let mut deg = angle_y.to_degrees();
+ let rot_changed = ui
+ .horizontal(|ui| {
+ ui.label("Rot Y:");
+ let speed = if grid_snap_enabled {
+ rotation_snap
+ } else {
+ 1.0
+ };
+ ui.add(egui::DragValue::new(&mut deg).speed(speed).suffix("°"))
+ .changed()
+ })
+ .inner;
+ if rot_changed {
+ if grid_snap_enabled {
+ deg = (deg / rotation_snap).round() * rotation_snap;
}
+ obj.rotation = Quat::from_rotation_y(deg.to_radians());
+ }
- // Model URL (read-only for now)
- if let Some(url) = &obj.model_url {
- ui.add_space(4.0);
- ui.small(format!("Model: {}", url));
- }
+ // Model URL (read-only for now)
+ if let Some(url) = &obj.model_url {
+ ui.add_space(4.0);
+ ui.small(format!("Model: {}", url));
+ }
- if name_changed || pos_changed || scale_changed || rot_changed {
- state.dirty = true;
+ let changed = name_changed || pos_changed || scale_changed || rot_changed;
+
+ ui.add_space(8.0);
+ ui.horizontal(|ui| {
+ if ui.button("Duplicate").clicked() {
+ action = Some(NostrverseAction::DuplicateObject(selected_id.to_owned()));
+ }
+ if ui.button("Delete").clicked() {
+ action = Some(NostrverseAction::RemoveObject(selected_id.to_owned()));
}
+ });
- ui.add_space(8.0);
- ui.horizontal(|ui| {
- if ui.button("Duplicate").clicked() {
- action = Some(NostrverseAction::DuplicateObject(selected_id.to_owned()));
- }
- if ui.button("Delete").clicked() {
- action = Some(NostrverseAction::RemoveObject(selected_id.to_owned()));
- }
- });
- }
+ (action, changed)
+}
- // --- Grid Snap ---
- ui.add_space(8.0);
+/// Render grid snap and rotation snap controls.
+fn render_grid_snap_controls(ui: &mut Ui, state: &mut NostrverseState) {
ui.horizontal(|ui| {
ui.checkbox(&mut state.grid_snap_enabled, "Grid Snap (G)");
if state.grid_snap_enabled {
@@ -803,24 +769,15 @@ pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option<
);
});
}
+}
- // --- Save button ---
- ui.add_space(12.0);
- ui.separator();
- let save_label = if state.dirty { "Save *" } else { "Save" };
- if ui
- .add_enabled(state.dirty, egui::Button::new(save_label))
- .clicked()
- {
- action = Some(NostrverseAction::SaveRoom);
- }
-
- // --- Scene body (syntax-highlighted, read-only) ---
+/// Render the syntax-highlighted scene source preview.
+fn render_scene_preview(ui: &mut Ui, state: &mut NostrverseState) {
// Only re-serialize when not actively dragging an object
if state.drag_state.is_none()
- && let Some(room) = &state.room
+ && let Some(info) = &state.space
{
- let space = convert::build_space(room, &state.objects);
+ let space = convert::build_space(info, &state.objects);
state.cached_scene_text = protoverse::serialize(&space);
}
@@ -842,6 +799,74 @@ pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option<
ui.add(egui::Label::new(layout_job).wrap());
});
}
+}
+
+/// Render the side panel with space editing, object list, and object inspector.
+pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option<NostrverseAction> {
+ let mut action = None;
+
+ // --- Space Properties ---
+ if let Some(info) = &mut state.space {
+ ui.strong("Space");
+ ui.separator();
+
+ let name_changed = ui
+ .horizontal(|ui| {
+ ui.label("Name:");
+ ui.text_edit_singleline(&mut info.name).changed()
+ })
+ .inner;
+
+ if name_changed {
+ state.dirty = true;
+ }
+
+ ui.add_space(8.0);
+ }
+
+ // --- Object List ---
+ if let Some(a) = render_object_list(ui, state) {
+ action = Some(a);
+ }
+
+ ui.add_space(12.0);
+
+ // --- Object Inspector ---
+ if let Some(selected_id) = state.selected_object.clone()
+ && let Some(obj) = state.objects.iter_mut().find(|o| o.id == selected_id)
+ {
+ let (inspector_action, changed) = render_object_inspector(
+ ui,
+ &selected_id,
+ obj,
+ state.grid_snap_enabled,
+ state.rotation_snap,
+ );
+ if let Some(a) = inspector_action {
+ action = Some(a);
+ }
+ if changed {
+ state.dirty = true;
+ }
+ }
+
+ // --- Grid Snap ---
+ ui.add_space(8.0);
+ render_grid_snap_controls(ui, state);
+
+ // --- Save button ---
+ ui.add_space(12.0);
+ ui.separator();
+ let save_label = if state.dirty { "Save *" } else { "Save" };
+ if ui
+ .add_enabled(state.dirty, egui::Button::new(save_label))
+ .clicked()
+ {
+ action = Some(NostrverseAction::SaveSpace);
+ }
+
+ // --- Scene body ---
+ render_scene_preview(ui, state);
action
}
diff --git a/crates/notedeck_nostrverse/src/subscriptions.rs b/crates/notedeck_nostrverse/src/subscriptions.rs
@@ -1,25 +1,43 @@
//! Local nostrdb subscription management for nostrverse rooms.
//!
-//! Subscribes to room events (kind 37555) in the local nostrdb and
-//! polls for updates each frame. No remote relay subscriptions — rooms
-//! are local-only for now.
+//! Subscribes to room events (kind 37555) and presence events (kind 10555)
+//! in the local nostrdb and polls for updates each frame.
use nostrdb::{Filter, Ndb, Note, Subscription, Transaction};
use crate::kinds;
+/// A local nostrdb subscription that polls for notes of a given kind.
+struct KindSubscription {
+ sub: Subscription,
+}
+
+impl KindSubscription {
+ fn new(ndb: &Ndb, kind: u16) -> Self {
+ let filter = Filter::new().kinds([kind as u64]).build();
+ let sub = ndb.subscribe(&[filter]).expect("kind subscription");
+ Self { sub }
+ }
+
+ fn poll<'a>(&self, ndb: &'a Ndb, txn: &'a Transaction) -> Vec<Note<'a>> {
+ ndb.poll_for_notes(self.sub, 50)
+ .into_iter()
+ .filter_map(|nk| ndb.get_note_by_key(txn, nk).ok())
+ .collect()
+ }
+}
+
/// Manages a local nostrdb subscription for room events.
pub struct RoomSubscription {
- /// Local nostrdb subscription handle
- sub: Subscription,
+ inner: KindSubscription,
}
impl RoomSubscription {
/// Subscribe to all room events (kind 37555) in the local nostrdb.
pub fn new(ndb: &Ndb) -> Self {
- let filter = Filter::new().kinds([kinds::ROOM as u64]).build();
- let sub = ndb.subscribe(&[filter]).expect("room subscription");
- Self { sub }
+ Self {
+ inner: KindSubscription::new(ndb, kinds::ROOM),
+ }
}
/// Subscribe to room events from a specific author.
@@ -30,16 +48,14 @@ impl RoomSubscription {
.authors([author])
.build();
let sub = ndb.subscribe(&[filter]).expect("room subscription");
- Self { sub }
+ Self {
+ inner: KindSubscription { sub },
+ }
}
/// Poll for new room events. Returns parsed notes.
pub fn poll<'a>(&self, ndb: &'a Ndb, txn: &'a Transaction) -> Vec<Note<'a>> {
- let note_keys = ndb.poll_for_notes(self.sub, 50);
- note_keys
- .into_iter()
- .filter_map(|nk| ndb.get_note_by_key(txn, nk).ok())
- .collect()
+ self.inner.poll(ndb, txn)
}
/// Query for existing room events (e.g. on startup).
@@ -55,23 +71,19 @@ impl RoomSubscription {
/// Manages a local nostrdb subscription for presence events (kind 10555).
pub struct PresenceSubscription {
- sub: Subscription,
+ inner: KindSubscription,
}
impl PresenceSubscription {
/// Subscribe to presence events in the local nostrdb.
pub fn new(ndb: &Ndb) -> Self {
- let filter = Filter::new().kinds([kinds::PRESENCE as u64]).build();
- let sub = ndb.subscribe(&[filter]).expect("presence subscription");
- Self { sub }
+ Self {
+ inner: KindSubscription::new(ndb, kinds::PRESENCE),
+ }
}
/// Poll for new presence events.
pub fn poll<'a>(&self, ndb: &'a Ndb, txn: &'a Transaction) -> Vec<Note<'a>> {
- let note_keys = ndb.poll_for_notes(self.sub, 50);
- note_keys
- .into_iter()
- .filter_map(|nk| ndb.get_note_by_key(txn, nk).ok())
- .collect()
+ self.inner.poll(ndb, txn)
}
}