Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ pub struct NoteTimelineItem {
/// `None` for session-produced notes, `"written"` for user-authored ones —
/// the frontend routes the latter to the editor instead of the viewer.
pub subtype: Option<String>,
/// When the session's chat last got a reply it shows.
pub last_chat_message_at: Option<i64>,
/// When the user last read the session's chat (`sessions.chat_read_at`).
pub chat_read_at: Option<i64>,
}

/// Review with session status resolved.
Expand Down Expand Up @@ -2330,6 +2334,7 @@ pub fn run() {
session_commands::get_tool_image,
session_commands::get_tool_call_body,
session_commands::count_assistant_messages_after,
session_commands::mark_session_chat_read,
session_commands::start_session,
session_commands::resume_session,
session_commands::queue_session_message,
Expand Down
11 changes: 11 additions & 0 deletions apps/staged/src-tauri/src/note_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ use std::sync::{Arc, Mutex};
/// session status identically.
pub(crate) fn note_to_timeline_item(store: &Store, note: Note) -> NoteTimelineItem {
let resolved = store.resolve_session_status(note.session_id.as_deref());
// Best-effort, like the status above: a lookup miss leaves the chat button
// in its plain state rather than failing the note.
let (last_chat_message_at, chat_read_at) = note
.session_id
.as_deref()
.and_then(|session_id| store.session_chat_activity(session_id).ok())
.unwrap_or_default();
NoteTimelineItem {
id: note.id,
title: note.title,
Expand All @@ -25,6 +32,8 @@ pub(crate) fn note_to_timeline_item(store: &Store, note: Note) -> NoteTimelineIt
suggested_next_commit_step: note.suggested_next_commit_step,
suggested_next_note_step: note.suggested_next_note_step,
subtype: note.subtype,
last_chat_message_at,
chat_read_at,
}
}

Expand All @@ -44,6 +53,8 @@ pub(crate) fn standalone_note_to_timeline_item(note: Note) -> NoteTimelineItem {
suggested_next_commit_step: None,
suggested_next_note_step: None,
subtype: note.subtype,
last_chat_message_at: None,
chat_read_at: None,
}
}

Expand Down
12 changes: 12 additions & 0 deletions apps/staged/src-tauri/src/session_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,18 @@ pub fn count_assistant_messages_after(
.map_err(|e| e.to_string())
}

/// Record that the user has read the session's chat, clearing the unread dot
/// on its note row.
#[tauri::command]
pub fn mark_session_chat_read(
store: tauri::State<'_, Mutex<Option<Arc<Store>>>>,
session_id: String,
) -> Result<(), String> {
get_store(&store)?
.mark_session_chat_read(&session_id)
.map_err(|e| e.to_string())
}

// =============================================================================
// Lifecycle commands
// =============================================================================
Expand Down
35 changes: 30 additions & 5 deletions apps/staged/src-tauri/src/store/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const SESSION_MESSAGE_COLUMNS: &str = "id, session_id, role, content, created_at
acp_raw_input, acp_raw_output, acp_content, acp_locations, acp_usage,
acp_session_info, acp_config_options, acp_session_mode_state, acp_origin";
const VISIBLE_MESSAGE_FILTER: &str = "NOT (content = '' AND acp_event_kind IS NOT NULL)";
/// Assistant replies the chat shows (empty ACP rows never render): what
/// [`Store::count_assistant_messages_after`] counts and what a note row's chat
/// button compares against.
pub(super) const VISIBLE_ASSISTANT_FILTER: &str =
"role = 'assistant' AND NOT (content = '' AND acp_event_kind IS NOT NULL)";
/// ACP rows the transcript needs on top of [`Store::get_session_messages`]:
/// hidden rows of the kinds the frontend reads (`acpTranscript.ts`,
/// `acpConfigMetadata.ts`), plus visible tool rows still in flight — the
Expand Down Expand Up @@ -592,17 +597,37 @@ impl Store {
) -> Result<i64, StoreError> {
let conn = self.conn.lock().unwrap();
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM session_messages
WHERE session_id = ?1
AND role = 'assistant'
AND created_at > ?2
AND NOT (content = '' AND acp_event_kind IS NOT NULL)",
&format!(
"SELECT COUNT(*) FROM session_messages
WHERE session_id = ?1 AND created_at > ?2 AND {VISIBLE_ASSISTANT_FILTER}"
),
params![session_id, after_timestamp],
|row| row.get(0),
)?;
Ok(count)
}

/// When a session's chat last got a reply it shows, and when the user last
/// read it (`sessions.chat_read_at`): the inputs to a note row's chat
/// button. Either is `None` when there is no such reply, or no read yet.
pub fn session_chat_activity(
&self,
session_id: &str,
) -> Result<(Option<i64>, Option<i64>), StoreError> {
let conn = self.conn.lock().unwrap();
conn.query_row(
&format!(
"SELECT
(SELECT MAX(created_at) FROM session_messages
WHERE session_id = ?1 AND {VISIBLE_ASSISTANT_FILTER}),
(SELECT chat_read_at FROM sessions WHERE id = ?1)"
),
params![session_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(Into::into)
}

/// Get messages with id >= since_id, plus fresh copies of `refetch_ids`.
///
/// The inclusive `>=` re-fetches the last known message so the caller
Expand Down
74 changes: 64 additions & 10 deletions apps/staged/src-tauri/src/store/migration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ fn test_store_bootstraps_fresh_database_with_baseline_migration() {
)
.unwrap();

assert_eq!(version, 34);
assert_eq!(version, 35);
assert_eq!(app_version, super::APP_VERSION);
assert!(table_exists(&conn, "projects"));
assert!(table_exists(&conn, "project_notes"));
Expand Down Expand Up @@ -271,7 +271,7 @@ fn test_store_repairs_github_comment_tracking_user_version() {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 34);
assert_eq!(version, 35);
assert!(column_exists(&conn, "sessions", "pipeline"));
assert!(column_exists(&conn, "sessions", "acp_config_selection"));
assert!(column_exists(&conn, "sessions", "acp_title"));
Expand Down Expand Up @@ -365,7 +365,7 @@ fn test_store_repairs_pipeline_user_version() {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 34);
assert_eq!(version, 35);
assert!(column_exists(&conn, "comments", "github_comment_id"));
assert!(column_exists(&conn, "comments", "github_comment_type"));
assert!(column_exists(&conn, "comments", "github_comment_stale"));
Expand Down Expand Up @@ -454,7 +454,7 @@ fn test_completion_effects_migration_backfills_finished_pipeline_sessions() {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 34);
assert_eq!(version, 35);
assert!(column_exists(&conn, "sessions", "completion_effects_at"));

let marker = |id: &str| -> Option<i64> {
Expand Down Expand Up @@ -530,7 +530,7 @@ fn test_auto_review_removal_migration_deletes_auto_reviews_and_drops_flag() {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 34);
assert_eq!(version, 35);
assert!(!column_exists(&conn, "reviews", "is_auto"));

// Reviews the removed auto-review feature created in the background are
Expand Down Expand Up @@ -605,7 +605,7 @@ fn test_detecting_pid_migration_clears_orphaned_detection_flags() {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 34);
assert_eq!(version, 35);
assert!(column_exists(&conn, "action_contexts", "detecting_pid"));

// No shipped build ever cleared the flag from outside the process that set
Expand Down Expand Up @@ -683,7 +683,7 @@ fn test_note_migrations_backfill_subtype_and_completion_for_session_less_notes()
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 34);
assert_eq!(version, 35);
assert!(column_exists(&conn, "notes", "subtype"));

let subtype = |id: &str| -> Option<String> {
Expand Down Expand Up @@ -782,7 +782,7 @@ fn test_pinned_actions_migration_pins_each_contexts_first_run_action() {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 34);
assert_eq!(version, 35);
assert!(column_exists(&conn, "repo_actions", "pinned"));
assert!(column_exists(&conn, "repo_actions", "icon"));

Expand Down Expand Up @@ -853,7 +853,7 @@ fn test_auto_commit_removal_migration_drops_flag_and_keeps_actions() {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 34);
assert_eq!(version, 35);
assert!(!column_exists(&conn, "repo_actions", "auto_commit"));

// Only the flag goes: an action that had auto-commit enabled keeps running
Expand Down Expand Up @@ -911,7 +911,7 @@ fn test_session_branch_id_backfill_links_artifact_backed_sessions() {
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 34);
assert_eq!(version, 35);

let branch_of = |id: &str| -> Option<String> {
conn.query_row(
Expand All @@ -932,3 +932,57 @@ fn test_session_branch_id_backfill_links_artifact_backed_sessions() {

cleanup_db(&path);
}

#[test]
fn test_session_chat_read_at_migration_marks_existing_chats_read() {
let path = temp_db_path("session-chat-read-at");
let conn = Connection::open(&path).unwrap();
conn.execute_batch(
"
PRAGMA user_version = 34;
CREATE TABLE app_metadata (
id INTEGER PRIMARY KEY CHECK (id = 1),
app_version TEXT NOT NULL
);
INSERT INTO app_metadata (id, app_version) VALUES (1, '0.2.9');
-- Only the table 0035 adds its column to.
CREATE TABLE sessions (id TEXT PRIMARY KEY);
INSERT INTO sessions (id) VALUES ('old-a'), ('old-b');
",
)
.unwrap();
drop(conn);

let migrated_from = super::now_timestamp() / 1000 * 1000;
let store = Store::new(&path).unwrap();
drop(store);
let migrated_by = super::now_timestamp();

let conn = Connection::open(&path).unwrap();
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 35);
assert!(column_exists(&conn, "sessions", "chat_read_at"));

let read_at = |id: &str| -> Option<i64> {
conn.query_row(
"SELECT chat_read_at FROM sessions WHERE id = ?1",
params![id],
|row| row.get(0),
)
.unwrap()
};
// Chats from before the upgrade read as read, in ms, so the upgrade lights
// no unread dots.
for id in ["old-a", "old-b"] {
let at = read_at(id).unwrap();
assert!((migrated_from..=migrated_by).contains(&at), "{id}: {at}");
}
// A session created afterwards has not been read.
conn.execute("INSERT INTO sessions (id) VALUES ('new')", [])
.unwrap();
assert_eq!(read_at("new"), None);

cleanup_db(&path);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- When the user last read a session's chat, in ms from the backend clock (the
-- same clock as `session_messages.created_at`). A note row lights its chat
-- button while the chat has replies newer than both the note and this.
ALTER TABLE sessions ADD COLUMN chat_read_at INTEGER;
-- Treat existing chats as read so the upgrade doesn't light up every old note.
UPDATE sessions SET chat_read_at = CAST(strftime('%s', 'now') AS INTEGER) * 1000;
4 changes: 3 additions & 1 deletion apps/staged/src-tauri/src/store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ use tokio::sync::broadcast;
/// Sessions are deliberately absent: chat polls at 500ms and session
/// lifecycle already flows through `session-status-changed` & friends, so
/// session-family writes (sessions, messages, queued messages, session-scoped
/// images, repo actions) publish nothing here.
/// images, repo actions) publish nothing here. The exception is
/// [`Store::mark_session_chat_read`], which publishes [`StoreChange::Notes`]
/// because note rows show whether their chat has unread replies.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum StoreChange {
/// A project row or its attached `project_repos` changed.
Expand Down
9 changes: 9 additions & 0 deletions apps/staged/src-tauri/src/store/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,13 @@ pub struct ProjectNote {
/// Why the session reached its terminal state.
#[serde(skip_deserializing)]
pub completion_reason: Option<String>,
/// When the session's chat last got a reply it shows. Populated at query
/// time, like `session_status`.
#[serde(skip_deserializing)]
pub last_chat_message_at: Option<i64>,
/// When the user last read the session's chat (`sessions.chat_read_at`).
#[serde(skip_deserializing)]
pub chat_read_at: Option<i64>,
}

impl ProjectNote {
Expand All @@ -1053,6 +1060,8 @@ impl ProjectNote {
suggested_next_note_step: None,
session_status: None,
completion_reason: None,
last_chat_message_at: None,
chat_read_at: None,
}
}

Expand Down
33 changes: 23 additions & 10 deletions apps/staged/src-tauri/src/store/project_notes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,27 @@ impl Store {
suggested_next_note_step: row.get(9)?,
session_status: None,
completion_reason: None,
last_chat_message_at: None,
chat_read_at: None,
})
}

/// Fill a project note's query-time fields from its session: status, and
/// the chat activity its row's chat button shows.
///
/// Best-effort, like the status: a lookup miss leaves the chat button in
/// its plain state rather than failing the note or its list.
fn resolve_project_note_session(&self, note: &mut ProjectNote) {
let resolved = self.resolve_session_status(note.session_id.as_deref());
note.session_status = resolved.status;
note.completion_reason = resolved.completion_reason;
(note.last_chat_message_at, note.chat_read_at) = note
.session_id
.as_deref()
.and_then(|session_id| self.session_chat_activity(session_id).ok())
.unwrap_or_default();
}

/// Find a project note by id with session status resolved.
///
/// Unlike [`Self::list_project_notes_with_status`] this takes no project
Expand All @@ -265,9 +283,7 @@ impl Store {
Some(n) => n,
None => return Ok(None),
};
let resolved = self.resolve_session_status(note.session_id.as_deref());
note.session_status = resolved.status;
note.completion_reason = resolved.completion_reason;
self.resolve_project_note_session(&mut note);
Ok(Some(note))
}

Expand All @@ -280,22 +296,19 @@ impl Store {
Some(n) => n,
None => return Ok(None),
};
let resolved = self.resolve_session_status(note.session_id.as_deref());
note.session_status = resolved.status;
note.completion_reason = resolved.completion_reason;
self.resolve_project_note_session(&mut note);
Ok(Some(note))
}

/// Return project notes with session status resolved from the sessions table.
/// Return project notes with session status and chat activity resolved
/// from the sessions table.
pub fn list_project_notes_with_status(
&self,
project_id: &str,
) -> Result<Vec<ProjectNote>, StoreError> {
let mut notes = self.list_project_notes(project_id)?;
for note in &mut notes {
let resolved = self.resolve_session_status(note.session_id.as_deref());
note.session_status = resolved.status;
note.completion_reason = resolved.completion_reason;
self.resolve_project_note_session(note);
}
Ok(notes)
}
Expand Down
Loading
Loading