From a733cbe55158d5ebbb8483f9f25a0dd3d29515aa Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 15 Apr 2026 15:33:50 +1000 Subject: [PATCH 1/4] feat(staged): add resume button to timeline rows for resumable sessions Sessions that ended due to crash, app quit, or interruption now show a Resume button directly in the timeline row, to the left of the session dialog button. This mirrors the resume functionality already available inside the session modal, making it quicker to resume without opening the dialog first. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../lib/features/branches/BranchCard.svelte | 6 ++++ .../features/timeline/BranchTimeline.svelte | 20 ++++++++++++ .../lib/features/timeline/TimelineRow.svelte | 31 +++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index d3c5dc81e..d2918822b 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -995,6 +995,12 @@ deletingItems={timelineDeletingItems} reviewCommentBreakdown={timelineReviewDetailsById} onSessionClick={(sid) => sessionMgr.handleTimelineSessionClick(sid)} + onResumeClick={(sid) => { + commands + .resumeSession(sid, 'Continue where you left off.', undefined, branch.id) + .then(() => loadTimeline()) + .catch((e) => console.error('Failed to resume session:', e)); + }} onCommitClick={handleCommitClick} onNoteClick={handleNoteClick} onReviewClick={handleReviewClick} diff --git a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte index efe3cd283..7652e97d4 100644 --- a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte +++ b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte @@ -49,6 +49,7 @@ /** Existing timeline rows currently being deleted (rendered in-place as deleting). */ deletingItems?: { type: 'commit' | 'note' | 'review' | 'image'; id: string }[]; onSessionClick?: (sessionId: string) => void; + onResumeClick?: (sessionId: string) => void; onCommitClick?: (sha: string) => void; onNoteClick?: (noteId: string, title: string, content: string, sessionId?: string) => void; onReviewClick?: (reviewId: string) => void; @@ -95,6 +96,7 @@ prunedSessionIds = new Set(), deletingItems = [], onSessionClick, + onResumeClick, onCommitClick, onNoteClick, onReviewClick, @@ -199,6 +201,7 @@ badges?: TimelineBadge[]; /** When set, delete button is shown but disabled with this tooltip. */ deleteDisabledReason?: string; + completionReason?: string | null; }; let runningSessionIds = $derived.by(() => collectRunningSessionIds(timeline, pendingItems)); @@ -285,6 +288,7 @@ commitSha: commit.sha || undefined, commitId: commit.id ?? undefined, deleteDisabledReason: isDeleting ? 'Deleting...' : undefined, + completionReason: commit.completionReason, }); } @@ -326,6 +330,7 @@ noteTitle: stripXmlTags(note.title), noteContent: note.content, deleteDisabledReason: isDeleting ? 'Deleting...' : undefined, + completionReason: note.completionReason, }); } @@ -388,6 +393,7 @@ sessionId: review.sessionId ?? undefined, reviewId: review.id, deleteDisabledReason: isDeleting ? 'Deleting...' : undefined, + completionReason: review.completionReason, }); } @@ -488,6 +494,17 @@ } } + const resumableReasons = new Set(['crashed', 'app_quit', 'interrupted']); + + function isResumable(item: DisplayItem): boolean { + return ( + !!item.sessionId && + !!item.completionReason && + resumableReasons.has(item.completionReason) && + !item.deleting + ); + } + function handleDeleteClick(item: DisplayItem, opts?: { altKey: boolean }) { if (item.type === 'commit' && item.commitSha && onDeleteCommit) { onDeleteCommit(item.commitSha, item.sessionId, opts); @@ -555,6 +572,9 @@ onStartClick={item.type.startsWith('queued-') && !hasActiveSession ? onStartQueued : undefined} + onResumeClick={isResumable(item) && onResumeClick && item.sessionId + ? () => onResumeClick!(item.sessionId!) + : undefined} /> {/each} diff --git a/apps/staged/src/lib/features/timeline/TimelineRow.svelte b/apps/staged/src/lib/features/timeline/TimelineRow.svelte index 2f825ea90..ed7df0e8b 100644 --- a/apps/staged/src/lib/features/timeline/TimelineRow.svelte +++ b/apps/staged/src/lib/features/timeline/TimelineRow.svelte @@ -14,6 +14,7 @@ Trash2, AlertTriangle, Clock, + Play, } from 'lucide-svelte'; import Spinner from '../../shared/Spinner.svelte'; @@ -58,6 +59,7 @@ deleteDisabledReason?: string; onRetryClick?: () => void; onStartClick?: () => void; + onResumeClick?: () => void; } let { @@ -76,6 +78,7 @@ deleteDisabledReason, onRetryClick, onStartClick, + onResumeClick, }: Props = $props(); let isNote = $derived( @@ -140,6 +143,11 @@ e.stopPropagation(); onStartClick?.(); } + + function handleResumeClick(e: MouseEvent) { + e.stopPropagation(); + onResumeClick?.(); + } @@ -217,7 +225,10 @@ {/if} -
+
{#if onStartClick} {/if} + {#if onResumeClick} + + {/if} {#if hasSession && !onStartClick && !isQueued} {/if} @@ -529,9 +527,17 @@ } .resume-btn { - display: inline-flex; - align-items: center; - gap: 3px; + border: 1px solid var(--border-subtle); + border-radius: 6px; + font-weight: 500; + transition: + color 0.15s, + border-color 0.15s, + background-color 0.15s; + } + + .resume-btn:hover { + border-color: var(--border-muted); } .start-btn { From c25d1b658af0fbcef88f0186fb8811ffdeaf3c45 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 15 Apr 2026 16:31:35 +1000 Subject: [PATCH 3/4] fix(staged): address review feedback on timeline resume button - Show error toast when resume fails instead of silently swallowing the error in console.error (BranchCard.svelte) - Guard resume button with !hasActiveSession to prevent starting a second concurrent session (BranchTimeline.svelte) - Merge duplicate .resume-btn:hover CSS rules into a single block and remove .resume-btn from the shared hover selector (TimelineRow.svelte) --- .../staged/src/lib/features/branches/BranchCard.svelte | 10 +++++++++- .../src/lib/features/timeline/BranchTimeline.svelte | 2 +- .../src/lib/features/timeline/TimelineRow.svelte | 5 +++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index d2918822b..90c756a2f 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -999,7 +999,15 @@ commands .resumeSession(sid, 'Continue where you left off.', undefined, branch.id) .then(() => loadTimeline()) - .catch((e) => console.error('Failed to resume session:', e)); + .catch((e) => { + console.error('Failed to resume session:', e); + alerts.error( + e instanceof Error + ? e.message + : 'Could not resume the session. Please try again.', + 'Resume failed' + ); + }); }} onCommitClick={handleCommitClick} onNoteClick={handleNoteClick} diff --git a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte index 7652e97d4..5f785c29d 100644 --- a/apps/staged/src/lib/features/timeline/BranchTimeline.svelte +++ b/apps/staged/src/lib/features/timeline/BranchTimeline.svelte @@ -572,7 +572,7 @@ onStartClick={item.type.startsWith('queued-') && !hasActiveSession ? onStartQueued : undefined} - onResumeClick={isResumable(item) && onResumeClick && item.sessionId + onResumeClick={isResumable(item) && onResumeClick && item.sessionId && !hasActiveSession ? () => onResumeClick!(item.sessionId!) : undefined} /> diff --git a/apps/staged/src/lib/features/timeline/TimelineRow.svelte b/apps/staged/src/lib/features/timeline/TimelineRow.svelte index 6ef6b7cee..7a3dfbef2 100644 --- a/apps/staged/src/lib/features/timeline/TimelineRow.svelte +++ b/apps/staged/src/lib/features/timeline/TimelineRow.svelte @@ -538,6 +538,8 @@ .resume-btn:hover { border-color: var(--border-muted); + color: var(--text-primary); + background: var(--bg-hover); } .start-btn { @@ -546,8 +548,7 @@ } .retry-btn:hover, - .start-btn:hover, - .resume-btn:hover { + .start-btn:hover { color: var(--text-primary); background: var(--bg-hover); } From 0a7070f55221ab40abe072b0c0421ed791cda289 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 15 Apr 2026 16:42:55 +1000 Subject: [PATCH 4/4] refactor(staged): extract shared RESUMABLE_REASONS constant from duplicated checks The resumable completion reasons ('crashed', 'app_quit', 'interrupted') were independently defined in both BranchTimeline.svelte and SessionModal.svelte. Extract a shared RESUMABLE_REASONS constant and isResumableReason() helper into types.ts so both consumers reference a single source of truth. Resolves review feedback from #review:9ff8e575. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/features/sessions/SessionModal.svelte | 3 ++- .../src/lib/features/timeline/BranchTimeline.svelte | 10 ++-------- apps/staged/src/lib/types.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/staged/src/lib/features/sessions/SessionModal.svelte b/apps/staged/src/lib/features/sessions/SessionModal.svelte index 8e376b9d2..23d2d0bde 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -45,6 +45,7 @@ import Spinner from '../../shared/Spinner.svelte'; import { marked } from 'marked'; import { sanitize } from '../../shared/sanitize'; + import { isResumableReason } from '../../types'; import type { Session, SessionMessage, HashtagItem } from '../../types'; import { cancelSession, @@ -1215,7 +1216,7 @@ {session.errorMessage}
{:else if session && session.status !== 'running' && session.status !== 'queued'} - {#if session.completionReason === 'crashed' || session.completionReason === 'app_quit' || session.completionReason === 'interrupted'} + {#if isResumableReason(session.completionReason)}
= new Set([ + 'crashed', + 'app_quit', + 'interrupted', +]); + +export function isResumableReason(reason: string | null | undefined): boolean { + return !!reason && RESUMABLE_REASONS.has(reason as CompletionReason); +} + export interface Session { id: string; prompt: string;