From 0fbd80a0e05c1b416f25b89c62c8f27a02710a6f Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 13 May 2026 19:07:11 +1000 Subject: [PATCH 01/10] fix(staged): resolve project note session status from backend Project notes used a content-based heuristic (empty title + content = running) to determine active state, causing two bugs: 1. Replying to a completed note didn't show the spinner due to a race condition in the isKnownNoteSession gate 2. Cancelled sessions left empty stubs that still appeared as "generating" This unifies project notes with the branch timeline approach by: - Adding sessionStatus and completionReason fields to ProjectNote, resolved at query time via the same resolve_session_status() used by timeline items - Extracting a shared isSessionActive() helper used by ProjectSection, BranchCard, and BranchCardSessionManager - Simplifying the ProjectSection event handler to always refresh from the backend (removing the racy isKnownNoteSession check) - Filtering out empty stubs from cancelled/errored sessions in the backend Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/note_commands.rs | 2 +- apps/staged/src-tauri/src/store/models.rs | 9 ++++ .../src-tauri/src/store/project_notes.rs | 26 ++++++++++++ apps/staged/src-tauri/src/web_server.rs | 2 +- .../lib/features/branches/BranchCard.svelte | 9 ++-- .../BranchCardSessionManager.svelte.ts | 7 ++-- .../features/projects/ProjectSection.svelte | 42 +++++++------------ apps/staged/src/lib/shared/sessionStatus.ts | 3 ++ apps/staged/src/lib/types.ts | 2 + 9 files changed, 64 insertions(+), 38 deletions(-) create mode 100644 apps/staged/src/lib/shared/sessionStatus.ts diff --git a/apps/staged/src-tauri/src/note_commands.rs b/apps/staged/src-tauri/src/note_commands.rs index daee87e29..315a7570b 100644 --- a/apps/staged/src-tauri/src/note_commands.rs +++ b/apps/staged/src-tauri/src/note_commands.rs @@ -79,7 +79,7 @@ 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()) } 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..0b2828a22 100644 --- a/apps/staged/src-tauri/src/store/project_notes.rs +++ b/apps/staged/src-tauri/src/store/project_notes.rs @@ -130,6 +130,32 @@ 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, }) } + + /// Return project notes with session status resolved from the sessions table. + /// Filters out empty stubs whose session was cancelled (nothing to show). + 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; + } + // Remove empty stubs from cancelled/errored sessions — no content to display. + notes.retain(|n| { + let is_empty = n.title.trim().is_empty() && n.content.trim().is_empty(); + let is_terminal = matches!( + n.session_status.as_deref(), + Some("cancelled") | Some("error") + ); + !(is_empty && is_terminal) + }); + Ok(notes) + } } diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index e9e8269cf..16e3cba3f 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2106,7 +2106,7 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result): 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); 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..6fc54e7db 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -40,6 +40,7 @@ import { projectStateStore } from '../../stores/projectState.svelte'; import BranchCard from '../branches/BranchCard.svelte'; import Spinner from '../../shared/Spinner.svelte'; + import { isSessionActive } from '../../shared/sessionStatus'; import AddRepoModal from './AddRepoModal.svelte'; import SuggestedRepos from './SuggestedRepos.svelte'; import type { RepoSelection as RepoPickerSelection } from '../../shared/githubUrl'; @@ -184,7 +185,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); } } @@ -440,36 +441,21 @@ 'session-status-changed', (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 next loadProjectNotes() picks it up via sessionStatus activeSessionIds = new Set([...activeSessionIds, sessionId]); - sessionRegistry.register(sessionId, project.id, 'note'); - projectStateStore.addRunningSession(project.id, sessionId); } - 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; } + + // Always refresh — backend now provides authoritative status + loadProjectNotes(); } ).then((unlisten) => { unlistenSession = unlisten; @@ -647,7 +633,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/shared/sessionStatus.ts b/apps/staged/src/lib/shared/sessionStatus.ts new file mode 100644 index 000000000..8a2fdd74f --- /dev/null +++ b/apps/staged/src/lib/shared/sessionStatus.ts @@ -0,0 +1,3 @@ +export function isSessionActive(status: string | null): 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 { From 3bd0373498a3222f77b3e25dae34e16b8e893d10 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 14 May 2026 11:51:05 +1000 Subject: [PATCH 02/10] fix(staged): cancel sessions on project note deletion and unify delete helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project note deletion didn't cancel running sessions or clean up session records, leaving orphaned sessions running to completion. Branch notes handled this correctly via a cancel → delete → cleanup flow. This fix: - Adds delete_session handling to delete_project_note (Tauri command + web server), mirroring the existing delete_note pattern - Extracts a shared deleteSessionLinkedItem() frontend helper that encapsulates cancel → delete → registry cleanup, used by both ProjectSection and BranchCard (note, review, pending commit deletion) - Fixes project note sorting to use isSessionActive(sessionStatus) instead of the stale content-based heuristic Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/note_commands.rs | 18 +++++-- apps/staged/src-tauri/src/web_server.rs | 7 +++ .../lib/features/branches/BranchCard.svelte | 47 ++++--------------- .../features/projects/ProjectSection.svelte | 11 +++-- .../src/lib/shared/deleteSessionLinkedItem.ts | 23 +++++++++ 5 files changed, 62 insertions(+), 44 deletions(-) create mode 100644 apps/staged/src/lib/shared/deleteSessionLinkedItem.ts diff --git a/apps/staged/src-tauri/src/note_commands.rs b/apps/staged/src-tauri/src/note_commands.rs index 315a7570b..45cf1ede6 100644 --- a/apps/staged/src-tauri/src/note_commands.rs +++ b/apps/staged/src-tauri/src/note_commands.rs @@ -83,12 +83,24 @@ pub fn list_project_notes( .map_err(|e| e.to_string()) } -#[tauri::command] +/// 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 note = store + .get_project_note(¬e_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project note not found: {note_id}"))?; + + store .delete_project_note(¬e_id) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + + if let Some(sid) = note.session_id { + let _ = store.delete_session(&sid); + } + Ok(()) } diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 16e3cba3f..ee10c221d 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2113,9 +2113,16 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { let store = get_store(store_mutex)?; let note_id: String = arg(&args, "noteId")?; + let note = store + .get_project_note(¬e_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("Project note not found: {note_id}"))?; store .delete_project_note(¬e_id) .map_err(|e| e.to_string())?; + if let Some(sid) = note.session_id { + let _ = store.delete_session(&sid); + } Ok(Value::Null) } diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index 01df74df9..66931b619 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -23,6 +23,7 @@ } 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 { @@ -60,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'; @@ -1032,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)); @@ -1070,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)); @@ -1143,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/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 6fc54e7db..c0ff23899 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -41,6 +41,7 @@ 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'; @@ -404,9 +405,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); @@ -420,9 +423,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); }) ); 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); + } +} From 854207af41827e37c811344002be1d2296a81075 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 14 May 2026 12:01:15 +1000 Subject: [PATCH 03/10] fix(staged): stop filtering empty cancelled/errored project notes in backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend filter removed project notes with empty content whose session was cancelled or errored. This caused notes to vanish entirely from the UI — the frontend already handles these via the "failed-note" display type ("Session finished — no note created"), so the backend filter was redundant and overly aggressive. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/store/project_notes.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/staged/src-tauri/src/store/project_notes.rs b/apps/staged/src-tauri/src/store/project_notes.rs index 0b2828a22..c0fdb4fae 100644 --- a/apps/staged/src-tauri/src/store/project_notes.rs +++ b/apps/staged/src-tauri/src/store/project_notes.rs @@ -136,7 +136,6 @@ impl Store { } /// Return project notes with session status resolved from the sessions table. - /// Filters out empty stubs whose session was cancelled (nothing to show). pub fn list_project_notes_with_status( &self, project_id: &str, @@ -147,15 +146,6 @@ impl Store { note.session_status = resolved.status; note.completion_reason = resolved.completion_reason; } - // Remove empty stubs from cancelled/errored sessions — no content to display. - notes.retain(|n| { - let is_empty = n.title.trim().is_empty() && n.content.trim().is_empty(); - let is_terminal = matches!( - n.session_status.as_deref(), - Some("cancelled") | Some("error") - ); - !(is_empty && is_terminal) - }); Ok(notes) } } From d2d7e6409ceff773269a28de4f8a37d6d7dd79ea Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 14 May 2026 12:50:37 +1000 Subject: [PATCH 04/10] fix(staged): make project hashtag items resilient to branch timeline failures buildProjectHashtagItems used Promise.all for branch timeline fetches, so a single failing branch caused the entire operation to reject silently (the $effect had no .catch handler), leaving hashtagItems as [] and the # dropdown permanently empty. This fix: - Switches to Promise.allSettled so individual branch timeline failures are skipped rather than aborting all hashtag item loading - Adds .catch() to the $effect to log errors instead of swallowing them - Adds a stale flag to prevent race conditions when dependencies change rapidly (matching the pattern used in NewSessionModal) Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- .../lib/features/projects/ProjectSection.svelte | 14 +++++++++++--- .../src/lib/features/sessions/hashtagItems.ts | 11 +++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index c0ff23899..7fd47a5b6 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -207,9 +207,17 @@ // Hashtag reference items let hashtagItems = $state([]); $effect(() => { - buildProjectHashtagItems(project.id, branches, reposById).then((items) => { - hashtagItems = items; - }); + 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 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; From 02b201e48c745f4c2ede96c05cfd50853229ce7d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 14 May 2026 13:34:59 +1000 Subject: [PATCH 05/10] fix(staged): populate project_id and branch_id on session completion events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session completion/error/cancelled events emitted by emit_status() had project_id and branch_id hardcoded to None, causing ProjectSection's listener to silently drop them (it filters on projectId === project.id). This left the spinner stuck until the user opened and closed the session dialog, which triggered an unconditional loadProjectNotes(). Fix by piping branch_id and project_id through SessionConfig and PipelineConfig from callers who already have the context, so emit_status can include them in terminal events without a DB lookup. This makes the data flow explicit from session creation to completion. Changes: - Add branch_id/project_id fields to SessionConfig and PipelineConfig - All ~12 callers of start_session/start_pipeline_session now populate these fields from the branch/project context they already hold - emit_status() accepts and forwards the piped IDs instead of hardcoding None - Pipeline→AI handoff copies IDs from PipelineConfig to SessionConfig - Pipeline terminal paths use piped branch_id for queue draining instead of a DB lookup via get_branch_id_for_session - recover_orphaned_sessions (app-quit recovery) falls back to DB lookup via new Store::get_project_id_for_session() since it has no caller context - cancel_session command also looks up IDs from DB for its inline event Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/prs.rs | 29 ++++-- apps/staged/src-tauri/src/session_commands.rs | 21 ++++- apps/staged/src-tauri/src/session_runner.rs | 90 ++++++++++++------- apps/staged/src-tauri/src/store/sessions.rs | 38 ++++++++ apps/staged/src-tauri/src/web_server.rs | 11 +++ 5 files changed, 150 insertions(+), 39 deletions(-) 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..70d47dc36 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, }, @@ -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/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 ee10c221d..3ddf9f47d 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2478,6 +2478,8 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result Result Result Result Date: Thu, 14 May 2026 14:53:29 +1000 Subject: [PATCH 06/10] fix(staged): update stale comment referencing removed content-based heuristic The comment on the project note stub creation still referenced the old !title && !content frontend check, which was replaced by backend-resolved sessionStatus in 0fbd80a. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/session_commands.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/staged/src-tauri/src/session_commands.rs b/apps/staged/src-tauri/src/session_commands.rs index 70d47dc36..55aee8710 100644 --- a/apps/staged/src-tauri/src/session_commands.rs +++ b/apps/staged/src-tauri/src/session_commands.rs @@ -611,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) From 74d1d27f1f11ba46ab20262a7603a0cd1531e572 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 14 May 2026 14:55:48 +1000 Subject: [PATCH 07/10] fix(staged): eliminate TOCTOU race in project note deletion delete_project_note used separate get + delete calls, creating a window where the note could be deleted by another caller between reading its session_id and performing the delete. Fix by using DELETE ... RETURNING session_id to atomically delete the note and retrieve its session_id in a single query. Both the Tauri command and web server handler now use this simplified path consistently. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/note_commands.rs | 9 ++------- apps/staged/src-tauri/src/store/project_notes.rs | 13 ++++++++++--- apps/staged/src-tauri/src/web_server.rs | 8 ++------ 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/apps/staged/src-tauri/src/note_commands.rs b/apps/staged/src-tauri/src/note_commands.rs index 45cf1ede6..0fa319685 100644 --- a/apps/staged/src-tauri/src/note_commands.rs +++ b/apps/staged/src-tauri/src/note_commands.rs @@ -90,16 +90,11 @@ pub fn delete_project_note( note_id: String, ) -> Result<(), String> { let store = crate::get_store(&store)?; - let note = store - .get_project_note(¬e_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project note not found: {note_id}"))?; - - store + let session_id = store .delete_project_note(¬e_id) .map_err(|e| e.to_string())?; - if let Some(sid) = note.session_id { + if let Some(sid) = session_id { let _ = store.delete_session(&sid); } Ok(()) diff --git a/apps/staged/src-tauri/src/store/project_notes.rs b/apps/staged/src-tauri/src/store/project_notes.rs index c0fdb4fae..91af77626 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 { diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 3ddf9f47d..17414d541 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2113,14 +2113,10 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result { let store = get_store(store_mutex)?; let note_id: String = arg(&args, "noteId")?; - let note = store - .get_project_note(¬e_id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("Project note not found: {note_id}"))?; - store + let session_id = store .delete_project_note(¬e_id) .map_err(|e| e.to_string())?; - if let Some(sid) = note.session_id { + if let Some(sid) = session_id { let _ = store.delete_session(&sid); } Ok(Value::Null) From ad4f9f79b1aef24e99ac8d00c998f21819b0188c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 14 May 2026 14:56:26 +1000 Subject: [PATCH 08/10] fix(staged): accept undefined in isSessionActive parameter type Some callers (e.g. BranchCard timeline items) may pass undefined for sessionStatus. The function already returns false for undefined, but the type signature didn't reflect this, masking potential missing-data bugs. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src/lib/shared/sessionStatus.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/staged/src/lib/shared/sessionStatus.ts b/apps/staged/src/lib/shared/sessionStatus.ts index 8a2fdd74f..c95a2b9d9 100644 --- a/apps/staged/src/lib/shared/sessionStatus.ts +++ b/apps/staged/src/lib/shared/sessionStatus.ts @@ -1,3 +1,3 @@ -export function isSessionActive(status: string | null): boolean { +export function isSessionActive(status: string | null | undefined): boolean { return status === 'queued' || status === 'running'; } From a047f3f62183f9bd641592015b67a103c52d1fcf Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 14 May 2026 15:40:59 +1000 Subject: [PATCH 09/10] fix(staged): surgically update only affected project note on session events ProjectSection's session-status-changed handler called loadProjectNotes() on every event (including running), reloading all notes when only one changed. This is wasteful since a session maps 1:1 to a single note. Fix by: - Adding get_project_note_by_session command (Tauri + web server) that returns a single note with resolved session status - Skipping backend fetch on 'running' events (the stub is already loaded by startProjectSession) - On terminal events (completed/error/cancelled), fetching only the affected note by session ID and patching it into the local array Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- apps/staged/src-tauri/src/lib.rs | 1 + apps/staged/src-tauri/src/note_commands.rs | 11 +++++++++++ .../src-tauri/src/store/project_notes.rs | 15 +++++++++++++++ apps/staged/src-tauri/src/web_server.rs | 8 ++++++++ apps/staged/src/lib/commands.ts | 6 ++++++ .../features/projects/ProjectSection.svelte | 18 +++++++++++++----- 6 files changed, 54 insertions(+), 5 deletions(-) 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 0fa319685..e081d4045 100644 --- a/apps/staged/src-tauri/src/note_commands.rs +++ b/apps/staged/src-tauri/src/note_commands.rs @@ -83,6 +83,17 @@ pub fn list_project_notes( .map_err(|e| e.to_string()) } +/// 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( diff --git a/apps/staged/src-tauri/src/store/project_notes.rs b/apps/staged/src-tauri/src/store/project_notes.rs index 91af77626..b41e8515d 100644 --- a/apps/staged/src-tauri/src/store/project_notes.rs +++ b/apps/staged/src-tauri/src/store/project_notes.rs @@ -142,6 +142,21 @@ impl Store { }) } + /// 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, diff --git a/apps/staged/src-tauri/src/web_server.rs b/apps/staged/src-tauri/src/web_server.rs index 17414d541..aa6a978b1 100644 --- a/apps/staged/src-tauri/src/web_server.rs +++ b/apps/staged/src-tauri/src/web_server.rs @@ -2110,6 +2110,14 @@ 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")?; diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index 90bf9a608..81dadc3ba 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -202,6 +202,12 @@ export function createProjectNote( return invokeCommand('create_project_note', { projectId, title, content }); } +export function getProjectNoteBySession( + sessionId: string +): Promise { + 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/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index 7fd47a5b6..f3dbdb8f0 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -450,23 +450,31 @@ let unlistenSession: (() => void) | undefined; listenToEvent<{ sessionId: string; status: string; projectId?: string }>( 'session-status-changed', - (payload) => { + async (payload) => { const { sessionId, status, projectId } = payload; if (projectId !== project.id) return; if (status === 'running') { - // Bridge: track until next loadProjectNotes() picks it up via sessionStatus + // Bridge: track until the stub (already loaded by startProjectSession) is + // updated with an authoritative sessionStatus on the terminal event. activeSessionIds = new Set([...activeSessionIds, sessionId]); + return; } if (status === 'completed' || status === 'error' || status === 'cancelled') { const next = new Set(activeSessionIds); next.delete(sessionId); activeSessionIds = next; - } - // Always refresh — backend now provides authoritative status - loadProjectNotes(); + // 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); + } + } } ).then((unlisten) => { unlistenSession = unlisten; From cd31a29e8b1d22212ae7373a39297d938f02211e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 14 May 2026 16:25:07 +1000 Subject: [PATCH 10/10] fix(staged): refresh project hashtag items on session completion and timeline invalidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The # hashtag reference dropdown in project prompts showed stale data after sessions created commits, because nothing triggered a rebuild of hashtagItems. Two contributing factors: 1. The hashtag $effect only depended on project.id, branches, and reposById — none of which change when a session creates a commit 2. The timeline cache wasn't invalidated by project session completion, so even a forced re-run would return stale data Fix by adding a hashtagVersion counter as a reactive dependency in the hashtag $effect. The counter is bumped in two places: - On project session terminal events (completed/error/cancelled), after invalidating all branch timeline caches - On timeline-invalidated custom events, catching branch-level session completions that BranchCard already fires Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Matt Toohey --- .../lib/features/projects/ProjectSection.svelte | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/staged/src/lib/features/projects/ProjectSection.svelte b/apps/staged/src/lib/features/projects/ProjectSection.svelte index f3dbdb8f0..b16cbe9f3 100644 --- a/apps/staged/src/lib/features/projects/ProjectSection.svelte +++ b/apps/staged/src/lib/features/projects/ProjectSection.svelte @@ -206,7 +206,9 @@ // Hashtag reference items let hashtagItems = $state([]); + let hashtagVersion = $state(0); $effect(() => { + const _v = hashtagVersion; // reactive dependency for manual invalidation let stale = false; buildProjectHashtagItems(project.id, branches, reposById) .then((items) => { @@ -447,6 +449,12 @@ 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', @@ -474,6 +482,12 @@ // 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) => { @@ -482,6 +496,7 @@ return () => { unlistenSession?.(); + window.removeEventListener('timeline-invalidated', onTimelineInvalidated); }; });