commit 15b8656d1f1d66d23fc7e5f4603d097d7ff98ad6
parent cbf42c7f4bdfc10d2a81dd0b87cc921d94c7378d
Author: William Casarin <jb55@jb55.com>
Date: Thu, 19 Feb 2026 10:19:09 -0800
Merge work from various agents
Diffstat:
14 files changed, 277 insertions(+), 36 deletions(-)
diff --git a/crates/notedeck/src/persist/settings_handler.rs b/crates/notedeck/src/persist/settings_handler.rs
@@ -42,6 +42,8 @@ pub struct Settings {
pub animate_nav_transitions: bool,
pub max_hashtags_per_note: usize,
#[serde(default)]
+ pub welcome_completed: bool,
+ #[serde(default)]
pub tos_accepted: bool,
#[serde(default)]
pub tos_accepted_at: Option<u64>,
@@ -70,6 +72,7 @@ impl Default for Settings {
note_body_font_size: DEFAULT_NOTE_BODY_FONT_SIZE,
animate_nav_transitions: default_animate_nav_transitions(),
max_hashtags_per_note: DEFAULT_MAX_HASHTAGS_PER_NOTE,
+ welcome_completed: false,
tos_accepted: false,
tos_accepted_at: None,
tos_version: default_tos_version(),
@@ -295,6 +298,18 @@ impl SettingsHandler {
.unwrap_or(DEFAULT_MAX_HASHTAGS_PER_NOTE)
}
+ pub fn welcome_completed(&self) -> bool {
+ self.current_settings
+ .as_ref()
+ .map(|s| s.welcome_completed)
+ .unwrap_or(false)
+ }
+
+ pub fn complete_welcome(&mut self) {
+ self.get_settings_mut().welcome_completed = true;
+ self.try_save_settings();
+ }
+
pub fn tos_accepted(&self) -> bool {
self.current_settings
.as_ref()
diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs
@@ -256,7 +256,16 @@ fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Con
warn!("update_damus init: {err}");
}
- if is_compiled_as_mobile() && !app_ctx.settings.tos_accepted() {
+ if !app_ctx.settings.welcome_completed() {
+ let split =
+ egui_nav::Split::PercentFromTop(egui_nav::Percent::new(40).expect("40 <= 100"));
+ if let Some(col) = damus
+ .decks_cache
+ .selected_column_mut(app_ctx.i18n, app_ctx.accounts)
+ {
+ col.sheet_router.route_to(Route::Welcome, split);
+ }
+ } else if is_compiled_as_mobile() && !app_ctx.settings.tos_accepted() {
damus
.columns_mut(app_ctx.i18n, app_ctx.accounts)
.get_selected_router()
@@ -844,6 +853,7 @@ fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool {
Route::Following(_) => false,
Route::FollowedBy(_) => false,
Route::TosAcceptance => false,
+ Route::Welcome => false,
Route::Report(_) => false,
}
}
diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs
@@ -1,5 +1,5 @@
use crate::{
- accounts::{render_accounts_route, AccountsAction, AccountsResponse},
+ accounts::{render_accounts_route, AccountsAction, AccountsResponse, AccountsRoute},
app::{get_active_columns_mut, get_decks_mut},
column::ColumnsAction,
deck_state::DeckState,
@@ -1054,6 +1054,31 @@ fn render_nav_body(
DragResponse::none()
}
+ Route::Welcome => {
+ let resp = ui::welcome::WelcomeView::new(ctx.i18n).show(ui);
+
+ if let Some(welcome_resp) = resp {
+ ctx.settings.complete_welcome();
+ match welcome_resp {
+ ui::welcome::WelcomeResponse::CreateAccount => {
+ app.columns_mut(ctx.i18n, ctx.accounts)
+ .column_mut(col)
+ .router_mut()
+ .route_to(Route::Accounts(AccountsRoute::Onboarding));
+ }
+ ui::welcome::WelcomeResponse::Login => {
+ app.columns_mut(ctx.i18n, ctx.accounts)
+ .column_mut(col)
+ .router_mut()
+ .route_to(Route::Accounts(AccountsRoute::AddAccount));
+ }
+ ui::welcome::WelcomeResponse::Browse => {}
+ }
+ return DragResponse::output(Some(RenderNavAction::Back));
+ }
+
+ DragResponse::none()
+ }
Route::Wallet(wallet_type) => {
let state = match wallet_type {
notedeck::WalletType::Auto => 's: {
diff --git a/crates/notedeck_columns/src/route.rs b/crates/notedeck_columns/src/route.rs
@@ -39,6 +39,7 @@ pub enum Route {
Following(Pubkey),
FollowedBy(Pubkey),
TosAcceptance,
+ Welcome,
Report(ReportTarget),
}
@@ -158,6 +159,9 @@ impl Route {
Route::TosAcceptance => {
writer.write_token("tos");
}
+ Route::Welcome => {
+ writer.write_token("welcome");
+ }
Route::Report(target) => {
writer.write_token("report");
writer.write_token(&target.pubkey.hex());
@@ -310,6 +314,12 @@ impl Route {
},
|p| {
p.parse_all(|p| {
+ p.parse_token("welcome")?;
+ Ok(Route::Welcome)
+ })
+ },
+ |p| {
+ p.parse_all(|p| {
p.parse_token("report")?;
let pubkey = Pubkey::from_hex(p.pull_token()?)
.map_err(|_| ParseError::HexDecodeFailed)?;
@@ -448,6 +458,9 @@ impl Route {
"Terms of Service",
"Column title for TOS acceptance screen"
)),
+ Route::Welcome => {
+ ColumnTitle::formatted(tr!(i18n, "Welcome", "Column title for welcome screen"))
+ }
Route::Report(_) => {
ColumnTitle::formatted(tr!(i18n, "Report", "Column title for report screen"))
}
diff --git a/crates/notedeck_columns/src/ui/column/header.rs b/crates/notedeck_columns/src/ui/column/header.rs
@@ -549,6 +549,7 @@ impl<'a> NavTitle<'a> {
Route::Following(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)),
Route::FollowedBy(pubkey) => Some(self.show_profile(ui, pubkey, pfp_size)),
Route::TosAcceptance => None,
+ Route::Welcome => None,
Route::Report(_) => None,
}
}
diff --git a/crates/notedeck_columns/src/ui/mod.rs b/crates/notedeck_columns/src/ui/mod.rs
@@ -23,6 +23,7 @@ pub mod timeline;
pub mod toolbar;
pub mod tos;
pub mod wallet;
+pub mod welcome;
pub mod widgets;
pub use accounts::AccountsView;
diff --git a/crates/notedeck_columns/src/ui/welcome.rs b/crates/notedeck_columns/src/ui/welcome.rs
@@ -0,0 +1,121 @@
+use egui::{vec2, Button, Label, Layout, RichText};
+use notedeck::{tr, Localization, NotedeckTextStyle};
+use notedeck_ui::padding;
+
+pub enum WelcomeResponse {
+ CreateAccount,
+ Login,
+ Browse,
+}
+
+pub struct WelcomeView<'a> {
+ i18n: &'a mut Localization,
+}
+
+impl<'a> WelcomeView<'a> {
+ pub fn new(i18n: &'a mut Localization) -> Self {
+ Self { i18n }
+ }
+
+ pub fn show(&mut self, ui: &mut egui::Ui) -> Option<WelcomeResponse> {
+ let mut response = None;
+
+ padding(16.0, ui, |ui| {
+ ui.spacing_mut().item_spacing = vec2(0.0, 16.0);
+
+ ui.with_layout(Layout::top_down(egui::Align::Center), |ui| {
+ ui.add_space(48.0);
+
+ ui.add(Label::new(
+ RichText::new(tr!(
+ self.i18n,
+ "Welcome to Notedeck",
+ "Welcome screen title"
+ ))
+ .text_style(NotedeckTextStyle::Heading2.text_style()),
+ ));
+
+ ui.add_space(8.0);
+
+ let max_width: f32 = 400.0;
+ ui.allocate_ui(vec2(max_width.min(ui.available_width()), 0.0), |ui| {
+ ui.add(
+ Label::new(
+ RichText::new(tr!(
+ self.i18n,
+ "Notedeck is a client for Nostr, an open protocol for decentralized social networking. Unlike traditional platforms, no single company controls your feed, your identity, or your data. Your account is a cryptographic key pair \u{2014} no emails, no passwords, no phone numbers.",
+ "Welcome screen body text explaining what Notedeck and Nostr are"
+ ))
+ .text_style(NotedeckTextStyle::Body.text_style()),
+ )
+ .wrap(),
+ );
+ });
+
+ ui.add_space(24.0);
+
+ let button_size = vec2(200.0, 40.0);
+ let font_size =
+ notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
+
+ if ui
+ .add(
+ Button::new(
+ RichText::new(tr!(
+ self.i18n,
+ "Create Account",
+ "Button to create a new Nostr account"
+ ))
+ .size(font_size),
+ )
+ .fill(notedeck_ui::colors::PINK)
+ .min_size(button_size),
+ )
+ .clicked()
+ {
+ response = Some(WelcomeResponse::CreateAccount);
+ }
+
+ ui.add_space(4.0);
+
+ if ui
+ .add(
+ Button::new(
+ RichText::new(tr!(
+ self.i18n,
+ "I have a Nostr key",
+ "Button for existing Nostr users to log in with their key"
+ ))
+ .size(font_size),
+ )
+ .min_size(button_size),
+ )
+ .clicked()
+ {
+ response = Some(WelcomeResponse::Login);
+ }
+
+ ui.add_space(4.0);
+
+ if ui
+ .add(
+ Button::new(
+ RichText::new(tr!(
+ self.i18n,
+ "Just browsing",
+ "Button to dismiss welcome and browse the app without an account"
+ ))
+ .size(font_size),
+ )
+ .frame(false),
+ )
+ .clicked()
+ {
+ response = Some(WelcomeResponse::Browse);
+ }
+ });
+ });
+
+ response
+ }
+}
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -347,7 +347,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
// Create a default session with current directory
let sid = manager.new_session(std::env::current_dir().unwrap_or_default(), ai_mode);
if let Some(session) = manager.get_mut(sid) {
- session.hostname = hostname.clone();
+ session.details.hostname = hostname.clone();
}
(manager, DaveOverlay::None)
}
@@ -1094,7 +1094,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
// Focus on new session
if let Some(session) = self.session_manager.get_mut(id) {
- session.hostname = self.hostname.clone();
+ session.details.hostname = self.hostname.clone();
session.focus_requested = true;
if self.show_scene {
self.scene.select(id);
@@ -1257,7 +1257,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
queue_built_event(
session_events::build_session_state_event(
&claude_sid,
- &session.title,
+ &session.details.title,
&cwd,
status,
&self.hostname,
@@ -1412,7 +1412,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
// Local sessions use the current machine's hostname;
// remote sessions use what was stored in the event.
- session.hostname = if is_remote {
+ session.details.hostname = if is_remote {
state.hostname.clone()
} else {
self.hostname.clone()
@@ -1585,7 +1585,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
let loaded = session_loader::load_session_messages(ctx.ndb, &txn, claude_sid);
if let Some(session) = self.session_manager.get_mut(dave_sid) {
- session.hostname = hostname;
+ session.details.hostname = hostname;
if !loaded.messages.is_empty() {
tracing::info!(
"loaded {} messages for discovered session",
@@ -1828,7 +1828,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
if let Some(claude_sid) = agentic.event_session_id() {
self.pending_deletions.push(DeletedSessionInfo {
claude_session_id: claude_sid.to_string(),
- title: session.title.clone(),
+ title: session.details.title.clone(),
cwd: agentic.cwd.to_string_lossy().to_string(),
});
}
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -27,6 +27,13 @@ pub enum SessionSource {
Remote,
}
+/// Session metadata for display in chat headers
+pub struct SessionDetails {
+ pub title: String,
+ pub hostname: String,
+ pub cwd: Option<PathBuf>,
+}
+
/// State for permission response with message
#[derive(Default, Clone, Copy, PartialEq)]
pub enum PermissionMessageState {
@@ -263,7 +270,6 @@ impl AgenticSessionData {
/// A single chat session with Dave
pub struct ChatSession {
pub id: SessionId,
- pub title: String,
pub chat: Vec<Message>,
pub input: String,
pub incoming_tokens: Option<Receiver<DaveApiResponse>>,
@@ -282,8 +288,8 @@ pub struct ChatSession {
pub agentic: Option<AgenticSessionData>,
/// Whether this session is local (has a Claude process) or remote (relay-only).
pub source: SessionSource,
- /// Hostname of the machine where this session originated.
- pub hostname: String,
+ /// Session metadata for display (title, hostname, cwd)
+ pub details: SessionDetails,
}
impl Drop for ChatSession {
@@ -296,6 +302,11 @@ impl Drop for ChatSession {
impl ChatSession {
pub fn new(id: SessionId, cwd: PathBuf, ai_mode: AiMode) -> Self {
+ let details_cwd = if ai_mode == AiMode::Agentic {
+ Some(cwd.clone())
+ } else {
+ None
+ };
let agentic = match ai_mode {
AiMode::Agentic => Some(AgenticSessionData::new(id, cwd)),
AiMode::Chat => None,
@@ -303,7 +314,6 @@ impl ChatSession {
ChatSession {
id,
- title: "New Chat".to_string(),
chat: vec![],
input: String::new(),
incoming_tokens: None,
@@ -314,7 +324,11 @@ impl ChatSession {
ai_mode,
agentic,
source: SessionSource::Local,
- hostname: String::new(),
+ details: SessionDetails {
+ title: "New Chat".to_string(),
+ hostname: String::new(),
+ cwd: details_cwd,
+ },
}
}
@@ -330,7 +344,7 @@ impl ChatSession {
if let Some(ref mut agentic) = session.agentic {
agentic.resume_session_id = Some(resume_session_id);
}
- session.title = title;
+ session.details.title = title;
session
}
@@ -430,8 +444,8 @@ impl ChatSession {
} else {
title
};
- if new_title != self.title {
- self.title = new_title;
+ if new_title != self.details.title {
+ self.details.title = new_title;
self.state_dirty = true;
}
break;
diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs
@@ -13,7 +13,7 @@ use crate::{
PermissionResponse, PermissionResponseType, QuestionAnswer, SubagentInfo, SubagentStatus,
ToolResult,
},
- session::{PermissionMessageState, SessionId},
+ session::{PermissionMessageState, SessionDetails, SessionId},
tools::{PresentNotesCall, ToolCall, ToolCalls, ToolResponse},
};
use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
@@ -52,6 +52,8 @@ pub struct DaveUi<'a> {
git_status: Option<&'a mut GitStatusCache>,
/// Whether this is a remote session (no local Claude process)
is_remote: bool,
+ /// Session details for header display
+ details: Option<&'a SessionDetails>,
}
/// The response the app generates. The response contains an optional
@@ -159,9 +161,15 @@ impl<'a> DaveUi<'a> {
ai_mode,
git_status: None,
is_remote: false,
+ details: None,
}
}
+ pub fn details(mut self, details: &'a SessionDetails) -> Self {
+ self.details = Some(details);
+ self
+ }
+
pub fn permission_message_state(mut self, state: PermissionMessageState) -> Self {
self.permission_message_state = state;
self
@@ -324,7 +332,13 @@ impl<'a> DaveUi<'a> {
.show(ui, |ui| {
self.chat_frame(ui.ctx())
.show(ui, |ui| {
- ui.vertical(|ui| self.render_chat(app_ctx, ui)).inner
+ ui.vertical(|ui| {
+ if let Some(details) = self.details {
+ session_header_ui(ui, details);
+ }
+ self.render_chat(app_ctx, ui)
+ })
+ .inner
})
.inner
})
@@ -1226,13 +1240,11 @@ fn status_bar_ui(
// Right-aligned section: badges then refresh
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- let action = if is_agentic {
+ if is_agentic {
toggle_badges_ui(ui, plan_mode_active, auto_steal_focus)
} else {
None
- };
-
- action
+ }
})
.inner
} else if is_agentic {
@@ -1301,3 +1313,29 @@ fn toggle_badges_ui(
action
}
+
+fn session_header_ui(ui: &mut egui::Ui, details: &SessionDetails) {
+ ui.horizontal(|ui| {
+ ui.heading(&details.title);
+ });
+
+ if let Some(cwd) = &details.cwd {
+ let cwd_display = super::path_utils::abbreviate_path(cwd);
+ let display_text = if details.hostname.is_empty() {
+ cwd_display
+ } else {
+ format!("{}:{}", details.hostname, cwd_display)
+ };
+ ui.add(
+ egui::Label::new(
+ egui::RichText::new(display_text)
+ .monospace()
+ .size(11.0)
+ .weak(),
+ )
+ .wrap_mode(egui::TextWrapMode::Truncate),
+ );
+ }
+
+ ui.separator();
+}
diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -64,7 +64,8 @@ fn build_dave_ui<'a>(
.has_pending_permission(has_pending_permission)
.plan_mode_active(plan_mode_active)
.auto_steal_focus(auto_steal_focus)
- .is_remote(is_remote);
+ .is_remote(is_remote)
+ .details(&session.details);
if let Some(agentic) = &mut session.agentic {
ui_builder = ui_builder
@@ -263,7 +264,7 @@ pub fn scene_ui(
.show(ui, |ui| {
if let Some(selected_id) = scene.primary_selection() {
if let Some(session) = session_manager.get_mut(selected_id) {
- ui.heading(&session.title);
+ ui.heading(&session.details.title);
ui.separator();
let response = build_dave_ui(
diff --git a/crates/notedeck_dave/src/ui/scene.rs b/crates/notedeck_dave/src/ui/scene.rs
@@ -165,7 +165,7 @@ impl AgentScene {
let keybind_number = keybind_idx + 1; // 1-indexed for display
let position = agentic.scene_position;
let status = session.status();
- let title = &session.title;
+ let title = &session.details.title;
let is_selected = selected_ids.contains(&id);
let queue_priority = focus_queue.get_session_priority(id);
@@ -411,7 +411,7 @@ impl AgentScene {
);
// Cwd label (monospace, weak+small)
- let cwd_text = cwd.to_string_lossy();
+ let cwd_text = super::path_utils::abbreviate_path(cwd);
let cwd_pos = center + Vec2::new(0.0, agent_radius + 38.0);
painter.text(
cwd_pos,
diff --git a/crates/notedeck_dave/src/ui/session_list.rs b/crates/notedeck_dave/src/ui/session_list.rs
@@ -156,9 +156,9 @@ impl<'a> SessionListUi<'a> {
let response = self.session_item_ui(
ui,
- &session.title,
+ &session.details.title,
cwd,
- &session.hostname,
+ &session.details.hostname,
is_active,
shortcut_hint,
session.status(),
@@ -298,9 +298,9 @@ impl<'a> SessionListUi<'a> {
/// Draw cwd text (monospace, weak+small) with clipping.
/// Shows "hostname:cwd" when hostname is non-empty.
fn cwd_ui(ui: &mut egui::Ui, cwd_path: &Path, hostname: &str, pos: egui::Pos2, max_width: f32) {
- let cwd_str = cwd_path.to_string_lossy();
+ let cwd_str = super::path_utils::abbreviate_path(cwd_path);
let display_text = if hostname.is_empty() {
- cwd_str.to_string()
+ cwd_str
} else {
format!("{}:{}", hostname, cwd_str)
};
diff --git a/crates/notedeck_dave/src/update.rs b/crates/notedeck_dave/src/update.rs
@@ -574,14 +574,16 @@ pub fn process_auto_steal_focus(
tracing::debug!("Auto-steal: saved home session {:?}", home_session);
}
- // Jump to first Done item
+ // Jump to first Done item and clear it from the queue
if let Some(idx) = focus_queue.first_done_index() {
focus_queue.set_cursor(idx);
if let Some(entry) = focus_queue.current() {
- switch_and_focus_session(session_manager, scene, show_scene, entry.session_id);
+ let sid = entry.session_id;
+ switch_and_focus_session(session_manager, scene, show_scene, sid);
+ focus_queue.dequeue(sid);
tracing::debug!(
- "Auto-steal: switched to Done session {:?}",
- entry.session_id
+ "Auto-steal: switched to Done session {:?} and cleared indicator",
+ sid
);
return true;
}
@@ -868,7 +870,7 @@ pub fn create_session_with_cwd(
let id = session_manager.new_session(cwd, ai_mode);
if let Some(session) = session_manager.get_mut(id) {
- session.hostname = hostname.to_string();
+ session.details.hostname = hostname.to_string();
session.focus_requested = true;
if show_scene {
scene.select(id);
@@ -897,7 +899,7 @@ pub fn create_resumed_session_with_cwd(
let id = session_manager.new_resumed_session(cwd, resume_session_id, title, ai_mode);
if let Some(session) = session_manager.get_mut(id) {
- session.hostname = hostname.to_string();
+ session.details.hostname = hostname.to_string();
session.focus_requested = true;
if show_scene {
scene.select(id);