Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/staged/src/lib/features/branches/BranchCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,20 @@
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);
alerts.error(
e instanceof Error
? e.message
: 'Could not resume the session. Please try again.',
'Resume failed'
);
});
}}
onCommitClick={handleCommitClick}
onNoteClick={handleNoteClick}
onReviewClick={handleReviewClick}
Expand Down
3 changes: 2 additions & 1 deletion apps/staged/src/lib/features/sessions/SessionModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1215,7 +1216,7 @@
<span>{session.errorMessage}</span>
</div>
{:else if session && session.status !== 'running' && session.status !== 'queued'}
{#if session.completionReason === 'crashed' || session.completionReason === 'app_quit' || session.completionReason === 'interrupted'}
{#if isResumableReason(session.completionReason)}
<div
class="session-end-banner"
class:warning={session.completionReason === 'crashed' ||
Expand Down
14 changes: 14 additions & 0 deletions apps/staged/src/lib/features/timeline/BranchTimeline.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import type { Snippet } from 'svelte';
import { slide } from 'svelte/transition';
import { FileText, GitCommitVertical, FileSearch } from 'lucide-svelte';
import { isResumableReason } from '../../types';
import type { BranchTimeline as BranchTimelineData, HashtagItem } from '../../types';
import TimelineRow from './TimelineRow.svelte';
import type { TimelineItemType, TimelineBadge } from './TimelineRow.svelte';
Expand Down Expand Up @@ -49,6 +50,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;
Expand Down Expand Up @@ -95,6 +97,7 @@
prunedSessionIds = new Set(),
deletingItems = [],
onSessionClick,
onResumeClick,
onCommitClick,
onNoteClick,
onReviewClick,
Expand Down Expand Up @@ -199,6 +202,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));
Expand Down Expand Up @@ -285,6 +289,7 @@
commitSha: commit.sha || undefined,
commitId: commit.id ?? undefined,
deleteDisabledReason: isDeleting ? 'Deleting...' : undefined,
completionReason: commit.completionReason,
});
}

Expand Down Expand Up @@ -326,6 +331,7 @@
noteTitle: stripXmlTags(note.title),
noteContent: note.content,
deleteDisabledReason: isDeleting ? 'Deleting...' : undefined,
completionReason: note.completionReason,
});
}

Expand Down Expand Up @@ -388,6 +394,7 @@
sessionId: review.sessionId ?? undefined,
reviewId: review.id,
deleteDisabledReason: isDeleting ? 'Deleting...' : undefined,
completionReason: review.completionReason,
});
}

Expand Down Expand Up @@ -488,6 +495,10 @@
}
}

function isResumable(item: DisplayItem): boolean {
return !!item.sessionId && isResumableReason(item.completionReason) && !item.deleting;
}

function handleDeleteClick(item: DisplayItem, opts?: { altKey: boolean }) {
if (item.type === 'commit' && item.commitSha && onDeleteCommit) {
onDeleteCommit(item.commitSha, item.sessionId, opts);
Expand Down Expand Up @@ -555,6 +566,9 @@
onStartClick={item.type.startsWith('queued-') && !hasActiveSession
? onStartQueued
: undefined}
onResumeClick={isResumable(item) && onResumeClick && item.sessionId && !hasActiveSession
? () => onResumeClick!(item.sessionId!)
Comment on lines +569 to +570

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable resume while another start is pending

Gateing resume on !hasActiveSession here misses the in-flight start state where a pending item exists but has no sessionId yet. In that window, the Resume button is still enabled and can dispatch resumeSession even though another branch session is already being started, which can launch concurrent work on the same branch and bypass the intended single-active-session UX. Consider also blocking resume when there are non-queued pending items without IDs (or tracking an explicit in-flight flag).

Useful? React with 👍 / 👎.

: undefined}
/>
</div>
{/each}
Expand Down
36 changes: 34 additions & 2 deletions apps/staged/src/lib/features/timeline/TimelineRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
deleteDisabledReason?: string;
onRetryClick?: () => void;
onStartClick?: () => void;
onResumeClick?: () => void;
}

let {
Expand All @@ -76,6 +77,7 @@
deleteDisabledReason,
onRetryClick,
onStartClick,
onResumeClick,
}: Props = $props();

let isNote = $derived(
Expand Down Expand Up @@ -140,6 +142,11 @@
e.stopPropagation();
onStartClick?.();
}

function handleResumeClick(e: MouseEvent) {
e.stopPropagation();
onResumeClick?.();
}
</script>

<!-- svelte-ignore a11y_click_events_have_key_events -->
Expand Down Expand Up @@ -217,7 +224,10 @@
</div>
{/if}
</div>
<div class="timeline-actions" class:always-visible={!!onRetryClick || !!onStartClick}>
<div
class="timeline-actions"
class:always-visible={!!onRetryClick || !!onStartClick || !!onResumeClick}
>
{#if onStartClick}
<button class="action-btn start-btn" onclick={handleStartClick} title="Start">
Start
Expand All @@ -228,6 +238,11 @@
Retry
</button>
{/if}
{#if onResumeClick}
<button class="action-btn resume-btn" onclick={handleResumeClick} title="Resume session">
Resume
</button>
{/if}
{#if hasSession && !onStartClick && !isQueued}
<button class="action-btn session-btn" onclick={handleSessionClick} title="View session">
<MessageSquare size={12} />
Expand Down Expand Up @@ -503,13 +518,30 @@
}

.retry-btn,
.start-btn {
.start-btn,
.resume-btn {
width: auto;
padding: 0 8px;
font-size: var(--size-xs);
color: var(--text-muted);
}

.resume-btn {
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);
color: var(--text-primary);
background: var(--bg-hover);
}

.start-btn {
border: 1px solid var(--border-muted);
border-radius: 4px;
Expand Down
11 changes: 11 additions & 0 deletions apps/staged/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,17 @@ export type SessionStatus = 'queued' | 'running' | 'completed' | 'error' | 'canc

export type CompletionReason = 'turn_complete' | 'interrupted' | 'crashed' | 'app_quit' | 'unknown';

/** Completion reasons that indicate a session can be resumed. */
export const RESUMABLE_REASONS: ReadonlySet<CompletionReason> = new Set<CompletionReason>([
'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;
Expand Down