diff --git a/apps/staged/src-tauri/src/lib.rs b/apps/staged/src-tauri/src/lib.rs index 9c70f3add..7abf1c448 100644 --- a/apps/staged/src-tauri/src/lib.rs +++ b/apps/staged/src-tauri/src/lib.rs @@ -162,6 +162,11 @@ pub struct CommitTimelineItem { pub session_id: Option, pub session_status: Option, pub completion_reason: Option, + /// Kind of the git pipeline behind this row's session. Only set on pending + /// rows: it is how the frontend tells a queued/running rebase or squash + /// apart from a plain commit session, since the subject is a display label + /// an agent-pushed ACP title can replace mid-pipeline. + pub pipeline_kind: Option, /// Whether this commit was authored by the current git user. pub is_own_commit: bool, } @@ -1175,6 +1180,7 @@ async fn get_repo_default_branch_timeline( session_id: None, session_status: None, completion_reason: None, + pipeline_kind: None, is_own_commit: false, }) }) diff --git a/apps/staged/src-tauri/src/timeline.rs b/apps/staged/src-tauri/src/timeline.rs index 809e75cac..f21446d49 100644 --- a/apps/staged/src-tauri/src/timeline.rs +++ b/apps/staged/src-tauri/src/timeline.rs @@ -202,6 +202,7 @@ fn parse_commit_lines( session_id: resolved.session_id, session_status: resolved.status, completion_reason: resolved.completion_reason, + pipeline_kind: None, is_own_commit: false, // set later by build_branch_timeline }); } @@ -268,6 +269,7 @@ fn map_local_commits( session_id: resolved.session_id, session_status: resolved.status, completion_reason: resolved.completion_reason, + pipeline_kind: None, is_own_commit: false, // set later by build_branch_timeline } }) @@ -465,21 +467,17 @@ fn build_branch_timeline(store: &Arc, branch_id: &str) -> Result, branch_id: &str) -> Result Result { }); }); +describe('schema version', () => { + /** Write an entry the way a build one schema version behind would have. */ + async function writePreviousVersionEntry(command: string, data: unknown): Promise { + const key = _cacheKey(command); + await set( + key, + { key, data, fetchedAt: Date.now(), schemaVersion: CACHE_SCHEMA_VERSION - 1 }, + createStore('staged-cache', 'responses') + ); + } + + it('never yields an entry written under a previous schema version', async () => { + await writePreviousVersionEntry('cmd', 'old-shape'); + mockInvoke.mockResolvedValue('new-shape'); + + const results = []; + for await (const r of cachedInvoke('cmd', undefined, { ttl: 60_000 })) { + results.push(r); + } + + // A within-TTL entry would normally short-circuit the fetch entirely; the + // version mismatch has to demote it to a miss, or the deploy that changed + // the payload serves the old shape with no network correction. + expect(results).toEqual([ + { data: 'new-shape', source: 'network', fetchedAt: expect.any(Number) }, + ]); + }); + + it('treats a previous-version entry as a miss in cachedCommand', async () => { + await writePreviousVersionEntry('cmd', 'old-shape'); + mockInvoke.mockResolvedValue('new-shape'); + + await expect(cachedCommand('cmd', undefined, { ttl: 60_000 })).resolves.toEqual({ + data: 'new-shape', + revalidating: null, + }); + }); +}); + describe('cachedInvoke', () => { it('yields only network result on cache miss', async () => { mockInvoke.mockResolvedValue({ items: [1, 2] }); diff --git a/apps/staged/src/lib/cache.ts b/apps/staged/src/lib/cache.ts index 5abea2c9d..8b84efffd 100644 --- a/apps/staged/src/lib/cache.ts +++ b/apps/staged/src/lib/cache.ts @@ -1,7 +1,21 @@ import { get, set, del, keys, entries, clear, createStore } from 'idb-keyval'; import { invokeCommand, isTauri } from './transport'; -const CACHE_SCHEMA_VERSION = 1; +/** + * Stamped on every persisted cache entry and checked on read. Bump it whenever + * the shape of any cached command response changes — otherwise the first load + * after a deploy serves entries written by the previous build, and a field the + * new UI depends on reads as `undefined` (e.g. `CommitTimelineItem.pipelineKind`, + * whose absence silently re-enables the Rebase button mid-rebase). + * + * Entries that fail the check read as misses and sit inert in IndexedDB until + * they're overwritten or LRU-evicted. The cost of a bump is one cold-cache boot + * per client. + * + * The timeline boot snapshot in `commands.ts` is versioned by this same + * constant, so one bump covers both layers that survive a deploy. + */ +export const CACHE_SCHEMA_VERSION = 2; const MAX_CACHE_ENTRIES = 200; /** @@ -369,7 +383,6 @@ export async function clearAllCache(): Promise { // Exported for testing export { cacheKey as _cacheKey, - CACHE_SCHEMA_VERSION as _CACHE_SCHEMA_VERSION, MAX_CACHE_ENTRIES as _MAX_CACHE_ENTRIES, evictIfNeeded as _evictIfNeeded, }; diff --git a/apps/staged/src/lib/commands.test.ts b/apps/staged/src/lib/commands.test.ts index d1ede235c..63922e1b9 100644 --- a/apps/staged/src/lib/commands.test.ts +++ b/apps/staged/src/lib/commands.test.ts @@ -399,6 +399,7 @@ describe('cached mutation command wrappers', () => { invokeCommand, })); vi.doMock('./cache', () => ({ + CACHE_SCHEMA_VERSION: 1, cachedCommand, cachedInvoke: vi.fn(), invalidateCache, @@ -632,3 +633,114 @@ describe('cached mutation command wrappers', () => { }); }); }); + +describe('timeline boot snapshot', () => { + /** Deliberately not the real value — the snapshot must track whatever the shared constant says. */ + const SCHEMA_VERSION = 7; + + const stale = { + commits: [{ sha: 'pending-1', subject: 'Rebase onto main', pipelineKind: 'rebase' }], + notes: [], + reviews: [], + images: [], + }; + const fetched = { + commits: [{ sha: 'abc123', subject: 'Fix the thing' }], + notes: [], + reviews: [], + images: [], + }; + + let storage: Record; + let cachedInvoke: ReturnType; + let snapshotKey: string; + + beforeEach(async () => { + vi.resetModules(); + storage = {}; + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage[key] ?? null, + setItem: (key: string, value: string) => { + storage[key] = value; + }, + removeItem: (key: string) => { + delete storage[key]; + }, + }); + cachedInvoke = vi.fn(async function* () { + yield { data: fetched, source: 'network', fetchedAt: Date.now() }; + }); + + vi.doMock('./transport', () => ({ isTauri: false, invokeCommand: vi.fn() })); + vi.doMock('./cache', () => ({ + CACHE_SCHEMA_VERSION: SCHEMA_VERSION, + cachedCommand: vi.fn(), + cachedInvoke, + invalidateCache: vi.fn(), + invalidateCacheByCommand: vi.fn(), + invalidateCacheByArgs: vi.fn(), + })); + + ({ + SNAPSHOT_KEYS: { timelines: snapshotKey }, + } = await import('./shared/webSnapshot')); + }); + + afterEach(() => { + vi.doUnmock('./transport'); + vi.doUnmock('./cache'); + vi.unstubAllGlobals(); + }); + + it('discards a snapshot written before the payload carried a version', async () => { + // Pre-versioning format: a bare branchId -> entry record. + storage[snapshotKey] = JSON.stringify({ + 'branch-1': { timeline: stale, fetchedAt: Date.now() }, + }); + + const { getBranchTimelineWithRevalidation } = await import('./commands'); + const { cached, fresh } = getBranchTimelineWithRevalidation('branch-1'); + + expect(cached).toBeNull(); + await expect(fresh).resolves.toEqual(fetched); + expect(storage[snapshotKey]).toBeUndefined(); + }); + + it('discards a snapshot stamped by a previous build', async () => { + storage[snapshotKey] = JSON.stringify({ + schemaVersion: SCHEMA_VERSION - 1, + timelines: { 'branch-1': { timeline: stale, fetchedAt: Date.now() } }, + }); + + const { getBranchTimelineWithRevalidation } = await import('./commands'); + const { cached, fresh } = getBranchTimelineWithRevalidation('branch-1'); + + expect(cached).toBeNull(); + await expect(fresh).resolves.toEqual(fetched); + expect(storage[snapshotKey]).toBeUndefined(); + }); + + it('round-trips the timeline cache through a snapshot on the current version', async () => { + const beforeReload = await import('./commands'); + await expect(beforeReload.getBranchTimeline('branch-1')).resolves.toEqual(fetched); + beforeReload.persistTimelineSnapshot(); + + expect(JSON.parse(storage[snapshotKey])).toEqual({ + schemaVersion: SCHEMA_VERSION, + timelines: { 'branch-1': { timeline: fetched, fetchedAt: expect.any(Number) } }, + }); + + cachedInvoke.mockClear(); + vi.resetModules(); + const afterReload = await import('./commands'); + + // A just-seeded entry is inside TIMELINE_FRESH_MS, so this read is served + // with no fetch behind it — the window a mismatched payload shape would + // otherwise reach the UI through uncorrected. + expect(afterReload.getBranchTimelineWithRevalidation('branch-1')).toEqual({ + cached: fetched, + fresh: null, + }); + expect(cachedInvoke).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index bb0612bb1..9976df790 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -10,9 +10,10 @@ import { cachedInvoke, invalidateCacheByCommand, invalidateCache, + CACHE_SCHEMA_VERSION, type SwrResult, } from './cache'; -import { readSnapshot, writeSnapshot, SNAPSHOT_KEYS } from './shared/webSnapshot'; +import { readSnapshot, writeSnapshot, clearSnapshot, SNAPSHOT_KEYS } from './shared/webSnapshot'; import type { Project, ProjectRepo, @@ -465,19 +466,42 @@ const MAX_SNAPSHOT_TIMELINES = 40; type TimelineCacheEntry = { timeline: BranchTimeline; fetchedAt: number }; +/** + * Persisted shape of the boot snapshot. Stamped with the shared + * `CACHE_SCHEMA_VERSION` because this snapshot outlives a deploy just like the + * IndexedDB cache does, and seeds the same timelines the UI reads fields off. + */ +type TimelineSnapshot = { + schemaVersion: number; + timelines: Record; +}; + /** * Seed the in-memory timeline cache synchronously from the previous session's * localStorage snapshot. This runs at module init (before any BranchCard mounts) * so cached timelines can paint on the first frame of a cold iOS reload, instead * of each card awaiting its own asynchronous IndexedDB read. IndexedDB remains * the source of truth; the snapshot is only a paint-on-first-frame accelerator. - * The seeded entries carry their original `fetchedAt`, so they read as stale and - * `getBranchTimelineWithRevalidation` still kicks off a fresh fetch. + * The seeded entries carry their original `fetchedAt`, so an older snapshot + * reads as stale and `getBranchTimelineWithRevalidation` still kicks off a + * fresh fetch. + * + * Entries a previous build wrote are dropped: one restored inside + * `TIMELINE_FRESH_MS` suppresses revalidation entirely, so seeding a stale + * payload shape would hand the UI missing fields with no fetch to correct them. + * A pre-versioning snapshot is a bare record with no `schemaVersion`, so it + * fails the same check. */ function seedTimelineCacheFromSnapshot(): void { - const snapshot = readSnapshot>(SNAPSHOT_KEYS.timelines); + const snapshot = readSnapshot(SNAPSHOT_KEYS.timelines); if (!snapshot) return; - for (const [branchId, entry] of Object.entries(snapshot)) { + if (snapshot.schemaVersion !== CACHE_SCHEMA_VERSION || !snapshot.timelines) { + // Clear rather than leave it: it can never be read again, and it's the + // largest thing we put in localStorage. + clearSnapshot(SNAPSHOT_KEYS.timelines); + return; + } + for (const [branchId, entry] of Object.entries(snapshot.timelines)) { if (entry?.timeline && !timelineCache.has(branchId)) { timelineCache.set(branchId, entry); } @@ -494,12 +518,15 @@ seedTimelineCacheFromSnapshot(); */ export function persistTimelineSnapshot(): void { if (isTauri || timelineCache.size === 0) return; - const snapshot: Record = Object.fromEntries( + const timelines: Record = Object.fromEntries( [...timelineCache.entries()] .sort((a, b) => b[1].fetchedAt - a[1].fetchedAt) .slice(0, MAX_SNAPSHOT_TIMELINES) ); - writeSnapshot(SNAPSHOT_KEYS.timelines, snapshot); + writeSnapshot(SNAPSHOT_KEYS.timelines, { + schemaVersion: CACHE_SCHEMA_VERSION, + timelines, + }); } /** diff --git a/apps/staged/src/lib/features/branches/BranchCard.svelte b/apps/staged/src/lib/features/branches/BranchCard.svelte index 9e6caeaca..df08ab998 100644 --- a/apps/staged/src/lib/features/branches/BranchCard.svelte +++ b/apps/staged/src/lib/features/branches/BranchCard.svelte @@ -42,6 +42,7 @@ import { isSessionActive } from '../../shared/sessionStatus'; import { deleteSessionLinkedItem } from '../../shared/deleteSessionLinkedItem'; import { subscribeDragDrop } from './dragDrop'; + import { rebaseInFlight } from './rebaseInFlight'; import type { Branch, BranchGitState, @@ -686,6 +687,13 @@ let branchCommandDisabledReason = $derived( branchIdentityWarning ?? (commandPipelinePending ? 'Command in progress' : null) ); + /** + * A rebase is already queued or running, so the header Rebase button hides + * and the menu's Rebase item disables — see `rebaseInFlight`. + * `commandPipelinePending` covers the window between the click and the + * timeline reload that surfaces the pipeline's pending row. + */ + let rebaseAlreadyInFlight = $derived(rebaseInFlight(timeline?.commits)); /** * Gate for reset to origin, which still executes immediately: it is validated * against a point-in-time preview of what would be discarded, so a busy branch @@ -1912,10 +1920,8 @@ ? (branch.workspaceName ?? formatBaseBranch(branch.baseBranch)) : formatBaseBranch(branch.baseBranch)} parentAheadCount={timeline?.gitState?.base.commitsSinceFork ?? 0} - onRebase={branchCommandDisabledReason - ? undefined - : () => startBranchCommandPipeline('rebase')} - rebaseDisabled={!!branchCommandDisabledReason} + onRebase={rebaseAlreadyInFlight ? undefined : () => startBranchCommandPipeline('rebase')} + rebaseDisabledReason={branchCommandDisabledReason} warning={branchIdentityWarning} {refreshingGitState} fetchError={timeline?.gitState?.fetch.error ?? null} @@ -1938,6 +1944,7 @@ onRebaseBranch={() => startBranchCommandPipeline('rebase')} onSquashCommits={() => startBranchCommandPipeline('squash')} rebaseSquashDisabled={!!branchCommandDisabledReason} + {rebaseAlreadyInFlight} {commitCount} /> diff --git a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte index 4054c0903..04013f65a 100644 --- a/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardActionsBar.svelte @@ -50,6 +50,10 @@ /** Rebase/Squash queue behind running sessions, so this covers only the * cases where they can't run at all (detached HEAD, wrong branch). */ rebaseSquashDisabled?: boolean; + /** A rebase is already queued or running, which disables only Rebase: a + * second request wouldn't dedupe against a *running* rebase, it would land + * a redundant one behind it. Squash still queues normally. */ + rebaseAlreadyInFlight?: boolean; commitCount?: number; } @@ -67,6 +71,7 @@ onRebaseBranch, onSquashCommits, rebaseSquashDisabled = false, + rebaseAlreadyInFlight = false, commitCount = 0, }: Props = $props(); @@ -294,7 +299,10 @@ Move to Project… {/if} - onRebaseBranch?.()}> + onRebaseBranch?.()} + > Rebase Branch {#if commitCount >= 2} diff --git a/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte b/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte index 25d06e62c..acd68b295 100644 --- a/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte +++ b/apps/staged/src/lib/features/branches/BranchCardHeaderInfo.svelte @@ -15,7 +15,10 @@ baseBranch?: string | null; parentAheadCount?: number; onRebase?: () => void; - rebaseDisabled?: boolean; + /** Why the visible Rebase button can't run right now, shown as its tooltip. + * Callers withhold `onRebase` instead when the button shouldn't be offered + * at all. */ + rebaseDisabledReason?: string | null; warning?: string | null; refreshingGitState?: boolean; fetchError?: string | null; @@ -28,7 +31,7 @@ baseBranch = null, parentAheadCount = 0, onRebase, - rebaseDisabled = false, + rebaseDisabledReason = null, warning = null, refreshingGitState = false, fetchError = null, @@ -67,13 +70,13 @@ {#if parentAheadCount > 0 && onRebase} diff --git a/apps/staged/src/lib/features/branches/rebaseInFlight.test.ts b/apps/staged/src/lib/features/branches/rebaseInFlight.test.ts new file mode 100644 index 000000000..f033d6893 --- /dev/null +++ b/apps/staged/src/lib/features/branches/rebaseInFlight.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { rebaseInFlight } from './rebaseInFlight'; + +describe('rebaseInFlight', () => { + it('reports a queued rebase pipeline', () => { + expect(rebaseInFlight([{ pipelineKind: 'rebase', sessionStatus: 'queued' }])).toBe(true); + }); + + it('reports a running rebase pipeline', () => { + expect(rebaseInFlight([{ pipelineKind: 'rebase', sessionStatus: 'running' }])).toBe(true); + }); + + it('ignores a rebase that already finished', () => { + expect(rebaseInFlight([{ pipelineKind: 'rebase', sessionStatus: 'completed' }])).toBe(false); + }); + + it('ignores other active pipelines and plain commit sessions', () => { + expect( + rebaseInFlight([ + { pipelineKind: 'squash', sessionStatus: 'running' }, + { pipelineKind: null, sessionStatus: 'running' }, + { sessionStatus: 'queued' }, + ]) + ).toBe(false); + }); + + it('handles a missing timeline', () => { + expect(rebaseInFlight(undefined)).toBe(false); + }); +}); diff --git a/apps/staged/src/lib/features/branches/rebaseInFlight.ts b/apps/staged/src/lib/features/branches/rebaseInFlight.ts new file mode 100644 index 000000000..ce5cb8132 --- /dev/null +++ b/apps/staged/src/lib/features/branches/rebaseInFlight.ts @@ -0,0 +1,27 @@ +import type { CommitTimelineItem } from '../../types'; +import { isSessionActive } from '../../shared/sessionStatus'; + +/** + * Whether a rebase pipeline session is already queued or running on the branch. + * + * Both paths leave a pending commit row in the timeline that carries the + * pipeline kind, so this reads that structured field rather than the row's + * subject — the subject is a display label an agent-pushed ACP title can + * replace once a conflicted rebase hands off to an agent. + * + * The header Rebase button hides while this is true, and the `…` menu's Rebase + * item disables. The backend only dedupes the *queued* case + * (`queue_commit_pipeline_locked` matches against `find_queued_pipeline`, which + * reads queued sessions only), so against a rebase that is already **running** a + * second request inserts a fresh queued rebase that runs once the first lands. + * This guard is what stops that redundant second rebase, not a cosmetic tidy-up. + * + * The header renders nothing rather than a disabled control — it is a transient + * state with the rebase row already visible in the timeline, and the button + * comes back on its own if the rebase finishes with the parent still ahead. + */ +export function rebaseInFlight( + commits: Pick[] | undefined +): boolean { + return !!commits?.some((c) => c.pipelineKind === 'rebase' && isSessionActive(c.sessionStatus)); +} diff --git a/apps/staged/src/lib/types.ts b/apps/staged/src/lib/types.ts index 9977c37d1..2cb7db0ca 100644 --- a/apps/staged/src/lib/types.ts +++ b/apps/staged/src/lib/types.ts @@ -168,6 +168,13 @@ export interface CommitTimelineItem { sessionId: string | null; sessionStatus: string | null; completionReason: string | null; + /** + * Kind of the git pipeline behind this row's session. Only set on pending + * rows: it is how a queued/running rebase or squash is told apart from a + * plain commit session, since the subject is a display label an agent-pushed + * ACP title can replace mid-pipeline. + */ + pipelineKind?: PipelineKind | null; /** Whether this commit was authored by the current git user. */ isOwnCommit: boolean; }