notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
M.beads/issues.jsonl | 11+++++++++--
MCargo.lock | 4++++
Mcrates/notedeck_dave/src/backend/claude.rs | 38++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/backend/codex.rs | 287+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/notedeck_dave/src/backend/codex_protocol.rs | 27+++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/backend/shared.rs | 5+++++
Mcrates/notedeck_dave/src/backend/traits.rs | 11+++++++++++
Mcrates/notedeck_dave/src/lib.rs | 64+++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/notedeck_dave/src/session.rs | 236++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/notedeck_dave/src/ui/dave.rs | 28++++++++++++++++++++--------
Mcrates/notedeck_dave/src/ui/mod.rs | 5++++-
Mcrates/notedeck_nostrverse/Cargo.toml | 4++++
Mcrates/notedeck_nostrverse/src/convert.rs | 241++++++++++++++++++++++++++++++++++---------------------------------------------
Mcrates/notedeck_nostrverse/src/lib.rs | 640++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Acrates/notedeck_nostrverse/src/model_cache.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_nostrverse/src/nostr_events.rs | 99+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcrates/notedeck_nostrverse/src/presence.rs | 17+++++++++--------
Mcrates/notedeck_nostrverse/src/room_state.rs | 71++++++++++++++++++++++++++++++++---------------------------------------
Mcrates/notedeck_nostrverse/src/room_view.rs | 837+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mcrates/notedeck_nostrverse/src/subscriptions.rs | 58+++++++++++++++++++++++++++++++++++-----------------------
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 &notes { - 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(&note) else { + let Ok(event) = enostr::ClientMessage::event(&note) 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(&note).expect("parse room event"); - assert_eq!(parsed.name(parsed.root), Some("Test Room")); + let parsed = parse_space_event(&note).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(&note), Some("my-id")); + assert_eq!(get_space_id(&note), 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(&note), Some("37555:abc123:my-room")); + assert_eq!(get_presence_space(&note), Some("37555:abc123:my-room")); let parsed_pos = parse_presence_position(&note).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 &notes { - // 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) } }