diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 5c32e7be3..070a01854 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -1855,6 +1855,7 @@ pub fn run() { note_commands::delete_note, note_commands::create_project_note, note_commands::list_project_notes, + note_commands::get_project_note_by_session, note_commands::delete_project_note, // Images image_commands::create_image, diff --git a/apps/staged/src-tauri/src/note_commands.rs b/apps/staged/src-tauri/src/note_commands.rs index daee87e29..e081d4045 100644 --- a/apps/staged/src-tauri/src/note_commands.rs +++ b/apps/staged/src-tauri/src/note_commands.rs @@ -79,16 +79,34 @@ pub fn list_project_notes( project_id: String, ) -> Result, String> { crate::get_store(&store)? - .list_project_notes(&project_id) + .list_project_notes_with_status(&project_id) .map_err(|e| e.to_string()) } -#[tauri::command] +/// Get a single project note by its linked session ID, with resolved session status. +#[tauri::command(rename_all = "camelCase")] +pub fn get_project_note_by_session( + store: tauri::State<'_, Mutex>>>, + session_id: String, +) -> Result, String> { + crate::get_store(&store)? + .get_project_note_by_session_with_status(&session_id) + .map_err(|e| e.to_string()) +} + +/// Delete a project note and its linked session (if any). +#[tauri::command(rename_all = "camelCase")] pub fn delete_project_note( store: tauri::State<'_, Mutex>>>, note_id: String, ) -> Result<(), String> { - crate::get_store(&store)? + let store = crate::get_store(&store)?; + let session_id = store .delete_project_note(¬e_id) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + if let Some(sid) = session_id { + let _ = store.delete_session(&sid); + } + Ok(()) } diff --git a/apps/staged/src-tauri/src/prs.rs b/apps/staged/src-tauri/src/prs.rs index f81941047..2ed0fd81d 100644 --- a/apps/staged/src-tauri/src/prs.rs +++ b/apps/staged/src-tauri/src/prs.rs @@ -153,11 +153,14 @@ fn start_pipeline_for_branch( // Emit "running" event *before* returning so the global session listener // registers this session atomically — avoiding the race where the session // completes before the frontend `.then()` callback fires. + let branch_id = ctx.branch.id.clone(); + let project_id = ctx.branch.project_id.clone(); + session_runner::emit_session_running( app_handle, &session.id, - &ctx.branch.id, - &ctx.branch.project_id, + &branch_id, + &project_id, session_type, ); @@ -172,6 +175,8 @@ fn start_pipeline_for_branch( provider, workspace_name: ctx.workspace_name, remote_working_dir: ctx.remote_working_dir, + branch_id: Some(branch_id), + project_id: Some(project_id), }, store, app_handle.clone(), @@ -313,14 +318,17 @@ async fn start_running_commit_pipeline_for_branch( session.pipeline = Some(pipeline.clone()); store.create_session(&session).map_err(|e| e.to_string())?; - let commit = store::Commit::new_pending(&ctx.branch.id).with_session(&session.id); + let branch_id = ctx.branch.id.clone(); + let project_id = ctx.branch.project_id.clone(); + + let commit = store::Commit::new_pending(&branch_id).with_session(&session.id); store.create_commit(&commit).map_err(|e| e.to_string())?; session_runner::emit_session_running( app_handle, &session.id, - &ctx.branch.id, - &ctx.branch.project_id, + &branch_id, + &project_id, "commit", ); @@ -335,6 +343,8 @@ async fn start_running_commit_pipeline_for_branch( provider, workspace_name: ctx.workspace_name, remote_working_dir: ctx.remote_working_dir, + branch_id: Some(branch_id), + project_id: Some(project_id), }, store, app_handle.clone(), @@ -440,11 +450,14 @@ pub(crate) async fn start_queued_commit_pipeline_for_branch( .update_session_pipeline(&session.id, &pipeline) .map_err(|e| e.to_string())?; + let branch_id = ctx.branch.id.clone(); + let project_id = ctx.branch.project_id.clone(); + session_runner::emit_session_running( &app_handle, &session.id, - &ctx.branch.id, - &ctx.branch.project_id, + &branch_id, + &project_id, "commit", ); @@ -459,6 +472,8 @@ pub(crate) async fn start_queued_commit_pipeline_for_branch( provider: effective_provider, workspace_name: ctx.workspace_name, remote_working_dir: ctx.remote_working_dir, + branch_id: Some(branch_id), + project_id: Some(project_id), }, store, app_handle, diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 54373bb30..55aee8710 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -160,6 +160,8 @@ pub async fn start_session( action_registry: None, remote_working_dir: None, image_ids: vec![], + branch_id: None, + project_id: None, }, store, app_handle, @@ -321,6 +323,9 @@ pub async fn resume_session( return Err("Session is already running".to_string()); } + let config_branch_id = event_branch_id.clone(); + let config_project_id = event_project_id.clone().or(mcp_project_id.clone()); + crate::web_server::emit_to_all( &app_handle, "session-status-changed", @@ -363,6 +368,8 @@ pub async fn resume_session( }, remote_working_dir, image_ids: image_ids.unwrap_or_default(), + branch_id: config_branch_id, + project_id: config_project_id, }, store, app_handle, @@ -407,6 +414,8 @@ pub fn cancel_session( None, Some(&store::CompletionReason::Interrupted), ); + let branch_id = store.get_branch_id_for_session(&session_id).ok().flatten(); + let project_id = store.get_project_id_for_session(&session_id).ok().flatten(); crate::web_server::emit_to_all( &app_handle, "session-status-changed", @@ -415,8 +424,8 @@ pub fn cancel_session( status: "cancelled".to_string(), error_message: None, completion_reason: Some("interrupted".to_string()), - branch_id: None, - project_id: None, + branch_id, + project_id, session_type: None, is_auto_review: false, }, @@ -602,8 +611,8 @@ Begin the note with a markdown H1 heading as the title.\n\n" } store.create_session(&session).map_err(|e| e.to_string())?; - // Always create a project note stub with empty title and content so that the - // frontend can detect it as "generating" via the !title && !content check. + // Create a project note stub linked to the session. The frontend uses the + // backend-resolved sessionStatus to determine whether the note is generating. let note = store::ProjectNote::new(&project_id, "", "").with_session(&session.id); store .create_project_note(¬e) @@ -625,6 +634,8 @@ Begin the note with a markdown H1 heading as the title.\n\n" action_registry: Some(Arc::clone(&action_registry)), remote_working_dir: None, image_ids: image_ids.unwrap_or_default(), + branch_id: None, + project_id: Some(project_id), }, store, app_handle, @@ -876,6 +887,8 @@ pub async fn start_branch_session( action_registry: None, remote_working_dir, image_ids: image_ids.unwrap_or_default(), + branch_id: Some(branch_id), + project_id: Some(branch.project_id.clone()), }, store, app_handle, @@ -1283,6 +1296,8 @@ pub async fn drain_queued_sessions_for_branch( action_registry: None, remote_working_dir, image_ids, + branch_id: Some(branch_id), + project_id: Some(branch.project_id.clone()), }, store, app_handle, @@ -1567,6 +1582,8 @@ pub async fn trigger_auto_review( action_registry: None, remote_working_dir, image_ids: vec![], + branch_id: Some(branch_id.clone()), + project_id: Some(branch.project_id.clone()), }, store, app_handle, diff --git a/apps/staged/src-tauri/src/session_runner.rs b/apps/staged/src-tauri/src/session_runner.rs index 0d05fcb24..6ad9ffb5d 100644 --- a/apps/staged/src-tauri/src/session_runner.rs +++ b/apps/staged/src-tauri/src/session_runner.rs @@ -226,6 +226,12 @@ pub struct SessionConfig { /// Image IDs to include in the prompt. The runner reads the image files, /// base64-encodes them, and passes them as content blocks to the driver. pub image_ids: Vec, + /// Branch that owns this session (branch-level sessions only). + /// Threaded through so terminal events carry the same context as start events. + pub branch_id: Option, + /// Project that owns this session. Set for both project-note sessions + /// (directly) and branch-level sessions (via the branch's project). + pub project_id: Option, } /// Start a session: persist the user message, spawn the agent, stream to DB. @@ -476,13 +482,12 @@ pub fn start_session( new_status, error_msg, Some(&completion_reason), + config.branch_id.clone(), + config.project_id.clone(), ); if transitioned { - let branch_id = store_for_status - .get_branch_id_for_session(&session_id_for_status) - .ok() - .flatten(); + let branch_id = config.branch_id.clone(); let auto_review_branch_id = committed_branch_id.clone(); if let Some(branch_id) = branch_id { @@ -587,6 +592,11 @@ pub struct PipelineConfig { pub workspace_name: Option, /// Remote working directory for remote branches. pub remote_working_dir: Option, + /// Branch that owns this pipeline session. Copied to `SessionConfig` on + /// AI handoff and used by `emit_status` for terminal events. + pub branch_id: Option, + /// Project that owns this pipeline session. + pub project_id: Option, } /// Result of running a pipeline — tells the caller what happened. @@ -653,10 +663,6 @@ pub fn start_pipeline_session( match outcome { PipelineOutcome::CompletedWithoutAi => { // Pipeline completed successfully — transition session to completed. - let branch_id = store_for_status - .get_branch_id_for_session(&session_id) - .ok() - .flatten(); resolve_pipeline_artifacts_without_ai(&config, &store_for_status, true); let status_enum = SessionStatus::Completed; let reason = CompletionReason::TurnComplete; @@ -664,13 +670,21 @@ pub fn start_pipeline_session( let transitioned = store_for_status .transition_from_running(&session_id, status_enum, None, Some(&reason)) .unwrap_or(false); - emit_status(&app_handle, &session_id, "completed", None, Some(&reason)); + emit_status( + &app_handle, + &session_id, + "completed", + None, + Some(&reason), + config.branch_id.clone(), + config.project_id.clone(), + ); if transitioned { drain_queued_after_pipeline_terminal( Arc::clone(&store_for_status), Arc::clone(®istry), app_handle.clone(), - branch_id, + config.branch_id.clone(), ); } } @@ -712,6 +726,8 @@ pub fn start_pipeline_session( action_registry: None, remote_working_dir: config.remote_working_dir.clone(), image_ids: vec![], + branch_id: config.branch_id.clone(), + project_id: config.project_id.clone(), }; if let Err(e) = start_session( ai_config, @@ -743,10 +759,6 @@ pub fn start_pipeline_session( } } } - let branch_id = store_for_status - .get_branch_id_for_session(&session_id) - .ok() - .flatten(); resolve_pipeline_artifacts_without_ai(&config, &store_for_status, false); let transitioned = finish_failed_pipeline_handoff_start( &store_for_status, @@ -760,13 +772,15 @@ pub fn start_pipeline_session( "error", Some(e), Some(&CompletionReason::Crashed), + config.branch_id.clone(), + config.project_id.clone(), ); if transitioned { drain_queued_after_pipeline_terminal( Arc::clone(&store_for_status), Arc::clone(®istry), app_handle.clone(), - branch_id, + config.branch_id.clone(), ); } } @@ -774,10 +788,6 @@ pub fn start_pipeline_session( PipelineOutcome::Aborted { .. } => { // Pipeline aborted (e.g. non-fast-forward). Mark as completed so // the frontend can inspect the pipeline steps for the failure. - let branch_id = store_for_status - .get_branch_id_for_session(&session_id) - .ok() - .flatten(); resolve_pipeline_artifacts_without_ai(&config, &store_for_status, false); let reason = CompletionReason::TurnComplete; registry.deregister(&session_id); @@ -789,21 +799,25 @@ pub fn start_pipeline_session( Some(&reason), ) .unwrap_or(false); - emit_status(&app_handle, &session_id, "completed", None, Some(&reason)); + emit_status( + &app_handle, + &session_id, + "completed", + None, + Some(&reason), + config.branch_id.clone(), + config.project_id.clone(), + ); if transitioned { drain_queued_after_pipeline_terminal( Arc::clone(&store_for_status), Arc::clone(®istry), app_handle.clone(), - branch_id, + config.branch_id.clone(), ); } } PipelineOutcome::Cancelled => { - let branch_id = store_for_status - .get_branch_id_for_session(&session_id) - .ok() - .flatten(); resolve_pipeline_artifacts_without_ai(&config, &store_for_status, false); let reason = CompletionReason::Interrupted; registry.deregister(&session_id); @@ -815,13 +829,21 @@ pub fn start_pipeline_session( Some(&reason), ) .unwrap_or(false); - emit_status(&app_handle, &session_id, "cancelled", None, Some(&reason)); + emit_status( + &app_handle, + &session_id, + "cancelled", + None, + Some(&reason), + config.branch_id.clone(), + config.project_id.clone(), + ); if transitioned { drain_queued_after_pipeline_terminal( Arc::clone(&store_for_status), Arc::clone(®istry), app_handle.clone(), - branch_id, + config.branch_id.clone(), ); } } @@ -1557,15 +1579,19 @@ pub fn recover_dead_sessions( Some(&CompletionReason::AppQuit), ) .unwrap_or(false); + let recovered_branch_id = store.get_branch_id_for_session(&session.id).ok().flatten(); + let recovered_project_id = store.get_project_id_for_session(&session.id).ok().flatten(); emit_status( &app_handle, &session.id, "error", None, Some(&CompletionReason::AppQuit), + recovered_branch_id.clone(), + recovered_project_id, ); if transitioned { - let branch_id = store.get_branch_id_for_session(&session.id).ok().flatten(); + let branch_id = recovered_branch_id; if let Some(branch_id) = branch_id { let store_for_follow_up = Arc::clone(&store); let registry_for_follow_up = Arc::clone(®istry); @@ -2270,14 +2296,16 @@ fn emit_status( status: &str, error: Option, completion_reason: Option<&CompletionReason>, + branch_id: Option, + project_id: Option, ) { let event = SessionStatusEvent { session_id: session_id.to_string(), status: status.to_string(), error_message: error, completion_reason: completion_reason.map(|r| r.as_str().to_string()), - branch_id: None, - project_id: None, + branch_id, + project_id, session_type: None, is_auto_review: false, }; @@ -2421,6 +2449,8 @@ mod tests { provider: None, workspace_name: None, remote_working_dir: None, + branch_id: None, + project_id: None, } } diff --git a/apps/staged/src-tauri/src/store/models.rs b/apps/staged/src-tauri/src/store/models.rs index 666dcd5c9..32696923d 100644 --- a/apps/staged/src-tauri/src/store/models.rs +++ b/apps/staged/src-tauri/src/store/models.rs @@ -758,6 +758,13 @@ pub struct ProjectNote { pub suggested_next_commit_step: Option, /// AI-suggested prompt for a follow-up note session. pub suggested_next_note_step: Option, + /// Resolved session status (e.g. "running", "completed", "cancelled"). + /// Populated at query time via `resolve_session_status()`. + #[serde(skip_deserializing)] + pub session_status: Option, + /// Why the session reached its terminal state. + #[serde(skip_deserializing)] + pub completion_reason: Option, } impl ProjectNote { @@ -775,6 +782,8 @@ impl ProjectNote { completed_at: if has_content { Some(now) } else { None }, suggested_next_commit_step: None, suggested_next_note_step: None, + session_status: None, + completion_reason: None, } } diff --git a/apps/staged/src-tauri/src/store/project_notes.rs b/apps/staged/src-tauri/src/store/project_notes.rs index df0de4f18..b41e8515d 100644 --- a/apps/staged/src-tauri/src/store/project_notes.rs +++ b/apps/staged/src-tauri/src/store/project_notes.rs @@ -112,10 +112,17 @@ impl Store { Ok(()) } - pub fn delete_project_note(&self, id: &str) -> Result<(), StoreError> { + /// Delete a project note and return its session_id (if any) atomically. + pub fn delete_project_note(&self, id: &str) -> Result, StoreError> { let conn = self.conn.lock().unwrap(); - conn.execute("DELETE FROM project_notes WHERE id = ?1", params![id])?; - Ok(()) + let session_id: Option> = conn + .query_row( + "DELETE FROM project_notes WHERE id = ?1 RETURNING session_id", + params![id], + |row| row.get(0), + ) + .optional()?; + Ok(session_id.flatten()) } fn row_to_project_note(row: &rusqlite::Row) -> rusqlite::Result { @@ -130,6 +137,37 @@ impl Store { completed_at: row.get(7)?, suggested_next_commit_step: row.get(8)?, suggested_next_note_step: row.get(9)?, + session_status: None, + completion_reason: None, }) } + + /// Find a project note by session ID with session status resolved. + pub fn get_project_note_by_session_with_status( + &self, + session_id: &str, + ) -> Result, StoreError> { + let mut note = match self.get_project_note_by_session(session_id)? { + 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; + Ok(Some(note)) + } + + /// Return project notes with session status resolved from the sessions table. + pub fn list_project_notes_with_status( + &self, + project_id: &str, + ) -> Result, 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; + } + Ok(notes) + } } diff --git a/apps/staged/src-tauri/src/store/sessions.rs b/apps/staged/src-tauri/src/store/sessions.rs index a8bb385f8..c418b043e 100644 --- a/apps/staged/src-tauri/src/store/sessions.rs +++ b/apps/staged/src-tauri/src/store/sessions.rs @@ -332,6 +332,44 @@ impl Store { .map_err(Into::into) } + /// Resolve the project that owns a session. + /// + /// Checks project_notes first (project-note sessions), then falls back to + /// resolving via the branch (branch-level sessions have their branch linked + /// to a project). Used by the recovery path (`recover_orphaned_sessions`) + /// which has no caller context to pipe through. + pub fn get_project_id_for_session( + &self, + session_id: &str, + ) -> Result, StoreError> { + let conn = self.conn.lock().unwrap(); + let direct: Option = conn + .query_row( + "SELECT project_id FROM project_notes WHERE session_id = ?1 LIMIT 1", + params![session_id], + |row| row.get(0), + ) + .optional()?; + if direct.is_some() { + return Ok(direct); + } + conn.query_row( + "SELECT b.project_id FROM branches b + INNER JOIN ( + SELECT branch_id FROM commits WHERE session_id = ?1 + UNION ALL + SELECT branch_id FROM notes WHERE session_id = ?1 + UNION ALL + SELECT branch_id FROM reviews WHERE session_id = ?1 + ) a ON a.branch_id = b.id + LIMIT 1", + params![session_id], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) + } + pub fn delete_session(&self, id: &str) -> Result<(), StoreError> { let conn = self.conn.lock().unwrap(); conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])?; diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index e9e8269cf..aa6a978b1 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2106,16 +2106,27 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { + let store = get_store(store_mutex)?; + let session_id: String = arg(&args, "sessionId")?; + let note = store + .get_project_note_by_session_with_status(&session_id) + .map_err(|e| e.to_string())?; + Ok(serde_json::to_value(note).unwrap()) + } "delete_project_note" => { let store = get_store(store_mutex)?; let note_id: String = arg(&args, "noteId")?; - store + let session_id = store .delete_project_note(¬e_id) .map_err(|e| e.to_string())?; + if let Some(sid) = session_id { + let _ = store.delete_session(&sid); + } Ok(Value::Null) } @@ -2471,6 +2482,8 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Result Result Result { + return invokeCommand('get_project_note_by_session', { sessionId }); +} + export function deleteProjectNote(noteId: string): Promise { return invokeCommand('delete_project_note', { noteId }); } diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index c3c82a3b3..66931b619 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -22,6 +22,8 @@ GitPullRequestDraft, } from 'lucide-svelte'; import Spinner from '../../shared/Spinner.svelte'; + import { isSessionActive } from '../../shared/sessionStatus'; + import { deleteSessionLinkedItem } from '../../shared/deleteSessionLinkedItem'; import { listenToEvent, type UnlistenFn } from '../../transport'; import { subscribeDragDrop } from './dragDrop'; import type { @@ -59,7 +61,6 @@ import { alerts } from '../../shared/alerts.svelte'; import { aggregateProjectPrStatus } from '../../shared/utils'; import { timelineToHashtagItems, projectNotesToHashtagItems } from '../sessions/hashtagItems'; - import { sessionRegistry } from '../../stores/sessionRegistry.svelte'; import { getPreferredAgent } from '../settings/preferences.svelte'; import { agentState, REMOTE_AGENTS } from '../agents/agent.svelte'; import type { WorktreeChangesPreview } from '../../commands'; @@ -282,11 +283,9 @@ /** True when the branch has at least one queued or running session. */ function hasActiveSessions(tl: NonNullable): boolean { return ( - tl.commits.some((c) => c.sessionStatus === 'queued' || c.sessionStatus === 'running') || - tl.notes.some((n) => n.sessionStatus === 'queued' || n.sessionStatus === 'running') || - tl.reviews.some( - (r) => !r.isAuto && (r.sessionStatus === 'queued' || r.sessionStatus === 'running') - ) + tl.commits.some((c) => isSessionActive(c.sessionStatus)) || + tl.notes.some((n) => isSessionActive(n.sessionStatus)) || + tl.reviews.some((r) => !r.isAuto && isSessionActive(r.sessionStatus)) ); } let commandPipelinePending = $state(false); @@ -1033,19 +1032,8 @@ const doDelete = async () => { confirmDelete = null; try { - if (sessionId) { - try { - await commands.cancelSession(sessionId); - } catch { - // Session may already be finished - } - } - await commands.deleteNote(noteId, !!sessionId); - if (sessionId) { - sessionRegistry.cleanupSession(sessionId); - } + await deleteSessionLinkedItem(() => commands.deleteNote(noteId, !!sessionId), sessionId); loadTimeline(); - // Drain the next queued session now that this one has been removed. commands .drainQueuedSessions(branch.id) .catch((e) => console.error('Failed to drain queued sessions:', e)); @@ -1071,19 +1059,11 @@ const doDelete = async () => { confirmDelete = null; try { - if (sessionId) { - try { - await commands.cancelSession(sessionId); - } catch { - // Session may already be finished - } - } - await commands.deleteReview(reviewId, !!sessionId); - if (sessionId) { - sessionRegistry.cleanupSession(sessionId); - } + await deleteSessionLinkedItem( + () => commands.deleteReview(reviewId, !!sessionId), + sessionId + ); loadTimeline(); - // Drain the next queued session now that this one has been removed. commands .drainQueuedSessions(branch.id) .catch((e) => console.error('Failed to drain queued sessions:', e)); @@ -1144,19 +1124,11 @@ async function handleDeletePendingCommit(commitId: string, sessionId?: string) { deletingCommitKeys = new Set([...deletingCommitKeys, commitId]); try { - if (sessionId) { - try { - await commands.cancelSession(sessionId); - } catch { - // Session may already be finished, that's fine - } - } - await commands.deletePendingCommit(commitId, !!sessionId); - if (sessionId) { - sessionRegistry.cleanupSession(sessionId); - } + await deleteSessionLinkedItem( + () => commands.deletePendingCommit(commitId, !!sessionId), + sessionId + ); await loadTimeline(); - // Drain the next queued session now that this one has been removed. commands .drainQueuedSessions(branch.id) .catch((e) => console.error('Failed to drain queued sessions:', e)); diff --git a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts index c25a9a409..11d666c30 100644 --- a/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts +++ b/apps/staged/src/lib/features/branches/BranchCardSessionManager.svelte.ts @@ -11,6 +11,7 @@ import type { Branch, BranchTimeline as BranchTimelineData, BranchSessionType } from '../../types'; import * as commands from '../../api/commands'; +import { isSessionActive } from '../../shared/sessionStatus'; import { getPreferredAgent } from '../settings/preferences.svelte'; import { agentState, REMOTE_AGENTS } from '../agents/agent.svelte'; import { alerts } from '../../shared/alerts.svelte'; @@ -66,9 +67,9 @@ export default class BranchCardSessionManager { const tl = this.getTimeline(); if (!tl) return false; return ( - tl.commits.some((c) => c.sessionStatus === 'running') || - tl.notes.some((n) => n.sessionStatus === 'running') || - tl.reviews.some((r) => r.sessionStatus === 'running' && !r.isAuto) + tl.commits.some((c) => isSessionActive(c.sessionStatus)) || + tl.notes.some((n) => isSessionActive(n.sessionStatus)) || + tl.reviews.some((r) => isSessionActive(r.sessionStatus) && !r.isAuto) ); }); diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 9a5a98a48..b16cbe9f3 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -40,6 +40,8 @@ import { projectStateStore } from '../../stores/projectState.svelte'; import BranchCard from '../branches/BranchCard.svelte'; import Spinner from '../../shared/Spinner.svelte'; + import { isSessionActive } from '../../shared/sessionStatus'; + import { deleteSessionLinkedItem } from '../../shared/deleteSessionLinkedItem'; import AddRepoModal from './AddRepoModal.svelte'; import SuggestedRepos from './SuggestedRepos.svelte'; import type { RepoSelection as RepoPickerSelection } from '../../shared/githubUrl'; @@ -184,7 +186,7 @@ let runningNoteSessionIds = $derived.by(() => { const ids = new Set(); for (const note of projectNotes) { - if (!note.title.trim() && !note.content.trim() && note.sessionId) { + if (isSessionActive(note.sessionStatus) && note.sessionId) { ids.add(note.sessionId); } } @@ -204,10 +206,20 @@ // Hashtag reference items let hashtagItems = $state([]); + let hashtagVersion = $state(0); $effect(() => { - buildProjectHashtagItems(project.id, branches, reposById).then((items) => { - hashtagItems = items; - }); + const _v = hashtagVersion; // reactive dependency for manual invalidation + let stale = false; + buildProjectHashtagItems(project.id, branches, reposById) + .then((items) => { + if (!stale) hashtagItems = items; + }) + .catch((err) => { + console.error('[ProjectSection] Failed to build hashtag items:', err); + }); + return () => { + stale = true; + }; }); // Image attachment state @@ -403,9 +415,11 @@ } async function handleDeleteNote(noteId: string) { + const note = projectNotes.find((n) => n.id === noteId); + const sessionId = note?.sessionId ?? undefined; deletingNoteIds = new Set([...deletingNoteIds, noteId]); try { - await commands.deleteProjectNote(noteId); + await deleteSessionLinkedItem(() => commands.deleteProjectNote(noteId), sessionId); projectNotes = projectNotes.filter((n) => n.id !== noteId); } catch (e) { console.error('[ProjectSection] Failed to delete project note:', e); @@ -419,9 +433,9 @@ /** All notes: completed (oldest first) followed by generating – matches branch timeline order. */ let timelineNotes = $derived( [...projectNotes].sort((a, b) => { - const aIsGenerating = !a.title.trim() && !a.content.trim(); - const bIsGenerating = !b.title.trim() && !b.content.trim(); - if (aIsGenerating !== bIsGenerating) return aIsGenerating ? 1 : -1; + const aIsActive = isSessionActive(a.sessionStatus); + const bIsActive = isSessionActive(b.sessionStatus); + if (aIsActive !== bIsActive) return aIsActive ? 1 : -1; return (a.completedAt ?? a.createdAt) - (b.completedAt ?? b.createdAt); }) ); @@ -435,40 +449,45 @@ onMount(() => { loadProjectNotes(); + // Refresh hashtag items when branch timelines are invalidated (e.g. branch session completion) + const onTimelineInvalidated = () => { + hashtagVersion++; + }; + window.addEventListener('timeline-invalidated', onTimelineInvalidated); + let unlistenSession: (() => void) | undefined; listenToEvent<{ sessionId: string; status: string; projectId?: string }>( 'session-status-changed', - (payload) => { + async (payload) => { const { sessionId, status, projectId } = payload; - const isTracked = activeSessionIds.has(sessionId); - // Also reload if this session belongs to a known project note (handles - // sessions that were already running when the component mounted). - const isKnownNoteSession = projectNotes.some((n) => n.sessionId === sessionId); - - // Handle resumed sessions: when a project note session is resumed, - // the backend emits a "running" event with the projectId. Re-add it - // to active tracking so the row spinner and sidebar spinner appear. - if ( - status === 'running' && - !isTracked && - isKnownNoteSession && - (projectId === project.id || !projectId) - ) { + if (projectId !== project.id) return; + + if (status === 'running') { + // Bridge: track until the stub (already loaded by startProjectSession) is + // updated with an authoritative sessionStatus on the terminal event. activeSessionIds = new Set([...activeSessionIds, sessionId]); - sessionRegistry.register(sessionId, project.id, 'note'); - projectStateStore.addRunningSession(project.id, sessionId); + return; } - if (isTracked || isKnownNoteSession) { - if (status === 'completed' || status === 'error' || status === 'cancelled') { - if (isTracked) { - const next = new Set(activeSessionIds); - next.delete(sessionId); - activeSessionIds = next; - } - // Refresh notes after session completes - loadProjectNotes(); + if (status === 'completed' || status === 'error' || status === 'cancelled') { + const next = new Set(activeSessionIds); + next.delete(sessionId); + activeSessionIds = next; + + // Surgically update just the affected note instead of reloading all + const updatedNote = await commands.getProjectNoteBySession(sessionId); + if (updatedNote) { + projectNotes = projectNotes.map((n) => (n.id === updatedNote.id ? updatedNote : n)); + } else { + // Note was filtered out (e.g. deleted) — remove from local list + projectNotes = projectNotes.filter((n) => n.sessionId !== sessionId); + } + + // Invalidate timeline caches so hashtag items pick up new commits/notes + for (const b of branches) { + commands.invalidateBranchTimeline(b.id); } + hashtagVersion++; } } ).then((unlisten) => { @@ -477,6 +496,7 @@ return () => { unlistenSession?.(); + window.removeEventListener('timeline-invalidated', onTimelineInvalidated); }; }); @@ -647,7 +667,7 @@
{#each timelineNotes as note, index (note.id)} - {@const isRunning = !note.title.trim() && !note.content.trim()} + {@const isRunning = isSessionActive(note.sessionStatus)} {@const isFailed = !isRunning && !!note.sessionId && !note.content.trim()} {@const noteType = isRunning ? 'generating-note' : isFailed ? 'failed-note' : 'note'} {@const liveHint = diff --git a/apps/staged/src/lib/features/sessions/hashtagItems.ts b/apps/staged/src/lib/features/sessions/hashtagItems.ts index bb89a9d81..87d4dee16 100644 --- a/apps/staged/src/lib/features/sessions/hashtagItems.ts +++ b/apps/staged/src/lib/features/sessions/hashtagItems.ts @@ -66,13 +66,20 @@ export async function buildProjectHashtagItems( const items: HashtagItem[] = []; const readyBranches = branches.filter((branch) => branchTimelineReadyKey(branch) !== null); - const [timelines, projectNotes] = await Promise.all([ - Promise.all( + const [timelineResults, projectNotes] = await Promise.all([ + Promise.allSettled( readyBranches.map((b) => getBranchTimeline(b.id).then((t) => ({ branch: b, timeline: t }))) ), listProjectNotes(projectId), ]); + const timelines = timelineResults + .filter( + (r): r is PromiseFulfilledResult<{ branch: Branch; timeline: BranchTimeline }> => + r.status === 'fulfilled' + ) + .map((r) => r.value); + for (const { branch, timeline } of timelines) { const repo = branch.projectRepoId && reposById ? reposById.get(branch.projectRepoId) : null; const repoSlug = repo?.githubRepo; diff --git a/apps/staged/src/lib/shared/deleteSessionLinkedItem.ts b/apps/staged/src/lib/shared/deleteSessionLinkedItem.ts new file mode 100644 index 000000000..c0551518a --- /dev/null +++ b/apps/staged/src/lib/shared/deleteSessionLinkedItem.ts @@ -0,0 +1,23 @@ +import * as commands from '../commands'; +import { sessionRegistry } from '../stores/sessionRegistry.svelte'; + +/** + * Cancel a running session, delete the linked item, and clean up the registry. + * Shared by branch note/review/commit deletion and project note deletion. + */ +export async function deleteSessionLinkedItem( + deleteItem: () => Promise, + sessionId?: string +): Promise { + if (sessionId) { + try { + await commands.cancelSession(sessionId); + } catch { + // Session may already be finished + } + } + await deleteItem(); + if (sessionId) { + sessionRegistry.cleanupSession(sessionId); + } +} diff --git a/apps/staged/src/lib/shared/sessionStatus.ts b/apps/staged/src/lib/shared/sessionStatus.ts new file mode 100644 index 000000000..c95a2b9d9 --- /dev/null +++ b/apps/staged/src/lib/shared/sessionStatus.ts @@ -0,0 +1,3 @@ +export function isSessionActive(status: string | null | undefined): boolean { + return status === 'queued' || status === 'running'; +} diff --git a/apps/staged/src/lib/types.ts b/apps/staged/src/lib/types.ts index 07601c421..725682242 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -288,6 +288,8 @@ export interface ProjectNote { completedAt: number | null; suggestedNextCommitStep: string | null; suggestedNextNoteStep: string | null; + sessionStatus: string | null; + completionReason: string | null; } export interface ProjectSessionResponse {