Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
c66b7e8
chore: ignore subagent-driven-development scratch directory
laksamanakeris Aug 8, 2026
593c9a8
feat(seam): column-addressed row decoder with column assertion
laksamanakeris Aug 8, 2026
4084a00
fix(decode): clarify short-row error message and add covering test
laksamanakeris Aug 8, 2026
7ebefe7
feat(seam): require an explicit username to connect
laksamanakeris Aug 8, 2026
6a07d43
feat(seam): shared mock behaviour with a delayed variant
laksamanakeris Aug 8, 2026
64b95da
feat(seam): ExplorerData trait, models and fixtures
laksamanakeris Aug 8, 2026
dde4827
test(seam): cover record_detail and assert canonical group order
laksamanakeris Aug 8, 2026
53e9690
feat(seam): AdminData trait, models and fixtures
laksamanakeris Aug 8, 2026
17a1afe
test(seam): cover admin behaviour paths and fixture semantics
laksamanakeris Aug 8, 2026
6dea12f
feat(seam): CDC consumer-group lifecycle plus MV, topics, cron and no…
laksamanakeris Aug 8, 2026
335ebb2
fix(seam): stop CDC cursor advancing on empty or erroring reads
laksamanakeris Aug 8, 2026
5b11ec3
feat(seam): WorkbenchData trait for query, explain and schema tree
laksamanakeris Aug 8, 2026
23f84db
feat(seam): ViewersData trait for graph, vector, series, spatial, FTS…
laksamanakeris Aug 8, 2026
efc3ff4
feat(seam): nav badges, session info and database list
laksamanakeris Aug 8, 2026
ebef86b
feat(seam): wire Explorer sidebar to collection_groups, add seam-disc…
laksamanakeris Aug 8, 2026
622ced4
fix(services): unblock AsyncView for single-value seam reads
laksamanakeris Aug 8, 2026
1f8e031
chore(services): retag decoder dead-code allows as SEAM-UNWIRED
laksamanakeris Aug 8, 2026
478ade5
test: remove or repair three weak seam tests
laksamanakeris Aug 8, 2026
7c60ef9
fix(state): redact Credentials password from Debug output
laksamanakeris Aug 8, 2026
92449d1
fix(data): align nav_badges streams fixture with the rail literal
laksamanakeris Aug 8, 2026
6480ebc
fix(views): derive Explorer's default selection from loaded collections
laksamanakeris Aug 9, 2026
c7cf37a
chore: drop internal task-id references from source and comments
laksamanakeris Aug 9, 2026
4f987bd
fix(ui): surface connect() failures instead of dropping them
laksamanakeris Aug 9, 2026
f877482
fix(data): give admin fixtures id/name pairs distinct like streams does
laksamanakeris Aug 9, 2026
1205088
fix(services): reach AsyncState::Empty for query and graph reads
laksamanakeris Aug 9, 2026
41a1602
docs(comments): correct false statements in two comments
laksamanakeris Aug 9, 2026
6d2d27c
fix(notifications): keep a failed mark-all-read out of the read store
laksamanakeris Aug 27, 2026
6ad62c2
fix(ui): render connect failures instead of logging them
laksamanakeris Sep 15, 2026
6c9b32e
feat(modals): require an explicit username in the new-connection form
laksamanakeris Sep 15, 2026
9ff4c20
feat(views): read notify through the seam and drop the last exception
laksamanakeris Sep 15, 2026
ce25b32
docs(comments): scope the decode string claim, drop stale CLAUDE.md refs
laksamanakeris Sep 15, 2026
1e0ca25
fix(ui): stop a connect task dying with the component that started it
laksamanakeris Sep 15, 2026
a09a03e
fix(ui): overlay the connect error instead of inserting it into the flow
laksamanakeris Sep 15, 2026
7f22ec6
fix(views): drive the notify empty state from the filtered tail
laksamanakeris Sep 15, 2026
176df24
fix(views): drop an Explorer selection the reload no longer contains
laksamanakeris Sep 15, 2026
be8a6f6
fix(notifications): keep mark-all-read alive when the popover closes
laksamanakeris Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,6 @@ docs/nodedb_lab_studio_mockup_v4.html
specs/*
docs/*
.DS_Store

# Subagent-driven-development scratch (ledger, briefs, review packages)
.superpowers/
18 changes: 18 additions & 0 deletions nodedb-studio/assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,19 @@
/* ============================================================
Modal overlay
============================================================ */
/* Connect failures overlay everything, the modal scrim (z-index 150)
included: a failed "Save & connect" must be readable without closing the
form that caused it. Fixed, because html/body are 100vh with overflow
hidden, so a banner in normal flow pushes the shell off-screen. */
.connect-error-bar {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 200;
background: var(--bg-secondary);
border-bottom: 0.5px solid var(--border-mid);
}
.connect-error-bar .async-error { padding: 12px 20px; }

.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
Expand Down Expand Up @@ -441,6 +454,11 @@
font-size: 13px;
}
.form-field input:focus, .form-field select:focus { border-color: var(--accent); }
.field-error {
margin-top: 5px;
font-size: 11px;
color: var(--text-danger);
}
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }

/* ============================================================
Expand Down
26 changes: 24 additions & 2 deletions nodedb-studio/src/app.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Root component: provides global state and the top-level state machine.
//!
//! Two states, handled by a root conditional (NOT routing — see CLAUDE.md §5):
//! Two states, handled by a root conditional rather than by routing, because
//! the Connection Manager is not a route: the Router mounts inside `Studio`.
//! - Disconnected -> `ConnectionManager` (full screen, no studio chrome)
//! - Connected -> `Studio`
//!
Expand All @@ -12,12 +13,13 @@ use std::rc::Rc;

use dioxus::prelude::*;

use crate::components::async_view::AsyncView;
use crate::modals::ModalHost;
use crate::models::notification::Notification;
use crate::services::async_state::AsyncState;
use crate::services::backend::Backend;
use crate::services::connection_service::MockConnectionService;
use crate::state::connection::ActiveConnection;
use crate::state::connection::{ActiveConnection, ConnectError};
use crate::state::connections_registry::SavedConnection;
use crate::state::preferences::Preferences;
use crate::state::ui::ModalKind;
Expand Down Expand Up @@ -47,6 +49,10 @@ pub fn App() -> Element {
// Modal state is provided here (not in Studio) because Preferences is
// reachable while disconnected and via Cmd+, in either state.
use_context_provider(|| Signal::new(None::<ModalKind>));
// Connect failures are provided here, not inside the views that start them:
// the command palette and the switch popover both close on click, so an
// error they owned would be dropped before it could render.
let connect_error = use_context_provider(|| Signal::new(ConnectError(None)));

// Seed the registry + notification feed asynchronously, at the seam. The
// mock resolves instantly; the real client awaits the network. The guard
Expand Down Expand Up @@ -74,9 +80,25 @@ pub fn App() -> Element {
use_context_provider(|| reload_feed);

let active = use_context::<Signal<Option<ActiveConnection>>>();
// Rendered through the same component every failed read uses, so the markup
// and styling live in one place. It is fixed-position and above the modal
// scrim: `html, body` are `100vh; overflow: hidden`, so a banner in normal
// flow would push the statusbar off-screen, and a failed "Save & connect"
// has to be readable without closing the form that caused it.
// `retriable` is deliberately false: the way
// to retry a connect is the Connect button the user just pressed, which is
// still on screen, and a second affordance here would need the name and
// credentials of the attempt that failed. The message clears when the next
// attempt starts (see the call sites) or succeeds.
let connect_error_msg = connect_error.read().0.as_ref().map(|e| e.to_string());

rsx! {
document::Stylesheet { href: STYLES }
if let Some(msg) = connect_error_msg {
div { class: "connect-error-bar",
AsyncView { loading: false, empty: false, error: Some(msg) }
}
}
if active.read().is_some() {
Studio {}
} else {
Expand Down
3 changes: 2 additions & 1 deletion nodedb-studio/src/components/async_view.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Shared loading/empty/error renderer for any seam-backed read.
//! Shared loading/empty/error renderer for any seam-backed read, and the
//! error/retry renderer for a seam-backed write.
//!
//! The caller maps its `use_resource` result to `AsyncState<T>` (plain Rust),
//! renders the `Loaded(T)` case itself, and delegates the three non-loaded
Expand Down
54 changes: 49 additions & 5 deletions nodedb-studio/src/components/command_palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,25 @@
//! Rendered inside the router (via `StudioLayout`) so navigation items can use
//! the navigator. Open state is the shared `Signal<bool>` provided by `Studio`.

use dioxus::core::spawn_forever;
use dioxus::prelude::*;

use crate::routes::Route;
use crate::services::backend::Backend;
use crate::state::connection::ActiveConnection;
use crate::state::connection::{ActiveConnection, ConnectError, apply_connect};
use crate::state::connections_registry::{Credentials, SavedConnection};
use crate::state::ui::ModalKind;

#[component]
pub fn CommandPalette() -> Element {
let mut open = use_context::<Signal<bool>>();
let mut active = use_context::<Signal<Option<ActiveConnection>>>();
// The palette closes on click, so a failure it owned would never render;
// the surface lives at the app root instead.
let mut connect_error = use_context::<Signal<ConnectError>>();
let mut modal = use_context::<Signal<Option<ModalKind>>>();
let service = use_context::<std::rc::Rc<dyn Backend>>();
let registry = use_context::<Signal<Vec<SavedConnection>>>();
let nav = use_navigator();

if !*open.read() {
Expand All @@ -26,6 +32,30 @@ pub fn CommandPalette() -> Element {
// each switch handler clones it.
let switch_svc = service.clone();

// A saved entry's stored profile IS its explicit username; the palette
// switches between entries that already have one, so there is no field to
// type into here. An entry with no profile yields a blank, which the seam
// rejects with MissingUsername and the app root renders — never a silent
// fallback to `admin`. The connections fixture carries the invariant
// that keeps connectable entries from reaching that state.
let creds_for = |name: &str| -> Credentials {
Credentials {
username: registry
// .read(), not .peek(): this runs at render time, not in an
// event handler. peek() would freeze the credentials at the
// render where the palette opened, so a registry that resolves
// later (a real backend awaiting the network) would leave every
// switch sending a blank username with no re-render to fix it.
.read()
.iter()
.find(|c| c.name == name)
.and_then(|c| c.profile.as_ref())
.map(|p| p.user.clone())
.unwrap_or_default(),
password: None,
}
};

rsx! {
div {
class: "palette-overlay open",
Expand Down Expand Up @@ -66,10 +96,17 @@ pub fn CommandPalette() -> Element {
div { class: "palette-section", "Connections" }
div { class: "palette-item", onclick: {
let svc = switch_svc.clone();
let creds = creds_for("staging-cluster");
move |_| {
let svc = svc.clone();
spawn(async move {
if let Ok(s) = svc.connect("staging-cluster").await { active.set(Some(s)); }
let creds = creds.clone();
connect_error.set(ConnectError(None));
// spawn_forever: the palette closes on the next
// line, and a scope-bound task would be dropped.
spawn_forever(async move {
let result = svc.connect("staging-cluster", &creds).await;
let err = apply_connect(&mut active.write(), result);
connect_error.set(ConnectError(err));
});
open.set(false);
}
Expand All @@ -78,10 +115,17 @@ pub fn CommandPalette() -> Element {
}
div { class: "palette-item", onclick: {
let svc = switch_svc.clone();
let creds = creds_for("prod-replica-eu");
move |_| {
let svc = svc.clone();
spawn(async move {
if let Ok(s) = svc.connect("prod-replica-eu").await { active.set(Some(s)); }
let creds = creds.clone();
connect_error.set(ConnectError(None));
// spawn_forever: the palette closes on the next
// line, and a scope-bound task would be dropped.
spawn_forever(async move {
let result = svc.connect("prod-replica-eu", &creds).await;
let err = apply_connect(&mut active.write(), result);
connect_error.set(ConnectError(err));
});
open.set(false);
}
Expand Down
34 changes: 30 additions & 4 deletions nodedb-studio/src/components/popovers/connection_popover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@

use std::rc::Rc;

use dioxus::core::spawn_forever;
use dioxus::prelude::*;

use crate::services::backend::Backend;
use crate::state::connection::ActiveConnection;
use crate::state::connections_registry::{ConnStatus, SavedConnection};
use crate::state::connection::{ActiveConnection, ConnectError, apply_connect};
use crate::state::connections_registry::{ConnStatus, Credentials, SavedConnection};
use crate::state::ui::{ModalKind, Popover};

#[component]
Expand All @@ -17,6 +18,9 @@ pub fn ConnectionPopover() -> Element {
let mut modal = use_context::<Signal<Option<ModalKind>>>();
let registry = use_context::<Signal<Vec<SavedConnection>>>();
let service = use_context::<Rc<dyn Backend>>();
// The popover closes on click (see `popover.set(None)` below), so a failure
// it owned would never render; the surface lives at the app root instead.
let mut connect_error = use_context::<Signal<ConnectError>>();

let conn = active.read();
let Some(c) = conn.as_ref() else {
Expand Down Expand Up @@ -50,6 +54,18 @@ pub fn ConnectionPopover() -> Element {
};
let svc = service.clone();
let item_class = if disabled { "cp-item disabled" } else { "cp-item" };
// The stored profile IS this entry's explicit username; the
// popover only switches between already-saved connections. A
// profile-less entry yields a blank, which the seam rejects
// with MissingUsername and the app root renders.
let creds = Credentials {
username: sc
.profile
.as_ref()
.map(|p| p.user.clone())
.unwrap_or_default(),
password: None,
};
rsx! {
div {
class: "{item_class}",
Expand All @@ -59,8 +75,18 @@ pub fn ConnectionPopover() -> Element {
// set `active` (Copy) only after the await resolves.
let svc = svc.clone();
let name = name.clone();
spawn(async move {
if let Ok(s) = svc.connect(&name).await { active.set(Some(s)); }
let creds = creds.clone();
connect_error.set(ConnectError(None));
// spawn_forever, not spawn: this popover is
// conditionally mounted and closes on the next
// line, and Dioxus drops a scope's tasks when
// the scope goes away. A plain spawn dies at
// the await against any backend that actually
// yields, leaving no session and no error.
spawn_forever(async move {
let result = svc.connect(&name, &creds).await;
let err = apply_connect(&mut active.write(), result);
connect_error.set(ConnectError(err));
});
popover.set(None);
}
Expand Down
72 changes: 52 additions & 20 deletions nodedb-studio/src/components/popovers/notification_popover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,24 @@
//! list itself. Mark-all-read / per-item clicks MUTATE the store, so the badge
//! and the list never diverge. The Error-state Retry reloads the feed via the
//! shared `Resource` handle, gated on `StudioError::is_retriable()`.
//!
//! A failed mark-all-read is a WRITE failure and is kept out of the read store:
//! the list the user was looking at is still correct, so it stays on screen and
//! the failure renders beside it, with a retry that re-issues the write.

use std::rc::Rc;

use dioxus::core::spawn_forever;
use dioxus::prelude::*;

use crate::components::async_view::AsyncView;
use crate::models::notification::{Notification, NotificationTarget};
use crate::routes::Route;
use crate::services::async_state::AsyncState;
use crate::services::backend::Backend;
use crate::services::error::StudioError;
use crate::state::connection::{ActiveConnection, Capabilities};
use crate::state::notifications::{mark_all_read, mark_read, visible};
use crate::state::notifications::{apply_mark_all_read, mark_read, visible};
use crate::state::ui::Popover;

/// Where a notification navigates when clicked.
Expand Down Expand Up @@ -50,6 +56,32 @@ pub fn NotificationPopover() -> Element {
let active = use_context::<Signal<Option<ActiveConnection>>>();
let backend = use_context::<Rc<dyn Backend>>();
let nav = use_navigator();
// Write failure for mark-all-read, separate from the read store.
let mut write_error: Signal<Option<StudioError>> = use_signal(|| None);
// Persist first; the local list changes only once the seam acknowledges
// the write (see apply_mark_all_read). The spawn keeps every signal guard
// out of the await. Built once so the header button and its Retry are the
// same operation, not two copies that can drift.
//
// spawn_forever, not spawn: this popover is conditionally mounted, and
// Dioxus drops a scope's tasks on unmount. Clicking away while the write
// is in flight would kill it at the await, leaving the server's state
// unknown and the shared store never reconciled. The store is app-level
// context, so the reconcile still lands; only `write_error` is scoped
// here, so a failure the user navigated away from is not shown. That is
// acceptable because the failure path leaves the list untouched, which is
// what reopening the popover shows.
let mark_all = {
let backend = backend.clone();
move || {
let backend = backend.clone();
spawn_forever(async move {
let result = backend.mark_all_read().await;
let err = apply_mark_all_read(&mut store.write(), result);
write_error.set(err);
});
}
};

// Capability gate (unchanged): no connection -> render nothing.
let caps: Capabilities = match active.read().as_ref() {
Expand Down Expand Up @@ -89,29 +121,29 @@ pub fn NotificationPopover() -> Element {
div { class: "notif-header",
h4 { "Notifications " span { class: "count", "{count_label}" } }
button {
onclick: move |_| {
// In-memory update first for snappy UI (write guard dropped
// before the block ends, never held across an await).
if let Some(items) = store.write().loaded_mut() {
mark_all_read(items);
}
// Persist through the seam so the badge stays cleared on
// any subsequent reload (fixes POP-03). The spawn avoids
// holding any signal guard across the await.
// On success, reconcile the shared feed so the real client's
// persisted state is reflected (reload.restart() re-fetches).
let backend = backend.clone();
let mut reload = reload;
spawn(async move {
match backend.mark_all_read().await {
Ok(()) => reload.restart(),
Err(e) => tracing::warn!("mark_all_read failed: {e}"),
}
});
onclick: {
let mark_all = mark_all.clone();
move |_| mark_all()
},
"Mark all read"
}
}
// A failed write renders through the same component as a failed
// read, so it is styled, tested, and gated on retriability exactly
// once. Its Retry re-issues the write, not the read.
if let Some(e) = write_error.read().as_ref() {
AsyncView {
loading: false,
empty: false,
error: Some(format!("Could not mark all read: {e}")),
retriable: e.is_retriable(),
empty_message: String::new(),
on_retry: {
let mark_all = mark_all.clone();
move |_| mark_all()
},
}
}
div { class: "notif-list",
// Loading / Empty / Error -> shared AsyncView, driven by AsyncState.
AsyncView {
Expand Down
Loading
Loading