Skip to content
Open
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
5 changes: 5 additions & 0 deletions apps/staged/src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4229,6 +4229,11 @@ async fn dispatch(command: &str, args: Value, state: &WebAppState) -> Result<Val
.map_err(|e| format!("Failed to save preferences: {e}"))?;
Ok(Value::Null)
}
"list_preference_keys" => {
let store = preferences_store(app_handle)?;
serde_json::to_value(store.keys())
.map_err(|e| format!("Failed to list preference keys: {e}"))
}
"delete_preference" => {
let key: String = arg(&args, "key")?;
let store = preferences_store(app_handle)?;
Expand Down
8 changes: 8 additions & 0 deletions apps/staged/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
import { projectsDataStore } from './lib/stores/projectsData.svelte';
import { projectRunActionsStore } from './lib/stores/projectRunActions.svelte';
import { initBloxEnv } from './lib/stores/bloxEnv.svelte';
import { sweepExpiredDrafts } from './lib/shared/draftCache';
import { listenForSessionStatus } from './lib/listeners/sessionStatusListener';
import { listenForCacheInvalidation } from './lib/listeners/cacheInvalidationListener';
import { listenForPageLifecycle } from './lib/listeners/pageLifecycleListener';
Expand Down Expand Up @@ -470,6 +471,13 @@
// Cache the BLOX_ENV value once at startup (process-level, never changes).
initBloxEnv();

// Expire cached unsent drafts whose owning session/branch/project is gone —
// nothing ever reads those keys again, so only this sweep can clear them.
// Fire-and-forget: no view waits on it.
void sweepExpiredDrafts().catch((e) => {
console.error('Failed to sweep expired drafts:', e);
});

// Restore persisted unread state and sync the dock badge.
try {
await projectStateStore.initFromStore();
Expand Down
10 changes: 10 additions & 0 deletions apps/staged/src/lib/features/branches/BranchCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2359,6 +2359,16 @@
remote={isRemote}
willQueue={sessionMgr.willQueue}
willQueueForMode={(mode) => sessionMgr.willQueueForMode(mode)}
loadDraftForMode={(mode) => sessionMgr.loadNewSessionDraftForMode(mode)}
onDraftChange={(draft, opts) => {
// Don't persist prefilled text as a draft — it should be re-evaluated
// each time the dialog opens based on the current timeline state.
const prompt =
draft.prompt.trim() === commitPrefill.trim() || draft.prompt.trim() === notePrefill.trim()
? ''
: draft.prompt;
sessionMgr.handleNewSessionDraftChange({ ...draft, prompt }, opts);
}}
onClose={(draft) => {
// Don't persist prefilled text as a draft — it should be re-evaluated
// each time the dialog opens based on the current timeline state.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Runs in the "component" vitest project: the manager is a .svelte.ts class
// whose $state/$derived fields need the svelte plugin to compile.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Branch } from '../../types';

const { writer, loadDraftMock } = vi.hoisted(() => ({
writer: {
scheduleSave: vi.fn(),
saveNow: vi.fn(async () => {}),
clearNow: vi.fn(async () => {}),
flush: vi.fn(async () => {}),
},
loadDraftMock: vi.fn(async (_key: string): Promise<string | null> => null),
}));

vi.mock('../../shared/draftCache', () => ({
createDraftWriteScheduler: () => writer,
loadDraft: (key: string) => loadDraftMock(key),
}));
vi.mock('./branchSessionLaunch.svelte', () => ({
getPendingSessionItems: () => [],
hasPendingQueuedSession: () => false,
hasPendingSessionStart: () => false,
startOrQueueBranchSessionWithPending: vi.fn(async () => {}),
}));

import BranchCardSessionManager from './BranchCardSessionManager.svelte';

const branch = { id: 'branch-1', projectId: 'project-1' } as unknown as Branch;

function createManager() {
return new BranchCardSessionManager({
getBranch: () => branch,
getIsRemote: () => false,
loadTimeline: () => {},
getTimeline: () => null,
});
}

beforeEach(() => {
writer.scheduleSave.mockClear();
writer.saveNow.mockClear();
writer.flush.mockClear();
loadDraftMock.mockReset();
loadDraftMock.mockImplementation(async () => null);
});

describe('BranchCardSessionManager referring open', () => {
it("does not leak another mode's held draft into a commit open racing the referring load", async () => {
// Regression: openNewSessionReferring used to set newSessionMode='commit'
// before its awaited flush+read, so a concurrent openNewSession('commit')
// saw mode === newSessionMode and kept a draftPrompt that actually
// belonged to the held mode, seeding commit mode with note's text.
const mgr = createManager();
// A note draft held in memory from a previous close.
mgr.handleNewSessionClose({ prompt: 'note text', mode: 'note', imageIds: [] });

// The referring open's store read stays in flight…
let resolveCommitLoad!: (value: string | null) => void;
loadDraftMock.mockImplementation(
() =>
new Promise<string | null>((resolve) => {
resolveCommitLoad = resolve;
})
);
const referringOpen = mgr.openNewSessionReferring('#note:abc123');

// …while the user clicks "New commit". The commit modal must open with a
// clean composer, not the held note text.
mgr.openNewSession('commit');
expect(mgr.showNewSession).toBe(true);
expect(mgr.newSessionMode).toBe('commit');
expect(mgr.draftPrompt).toBe('');

// The superseded referring open must not touch the prompt when it lands.
// (Its store read only starts once the writer flush ahead of it settles.)
await vi.waitFor(() => expect(loadDraftMock).toHaveBeenCalled());
resolveCommitLoad('stored commit draft');
await referringOpen;
expect(mgr.draftPrompt).toBe('');
expect(mgr.newSessionMode).toBe('commit');
});

it('still builds the referring prompt from the stored commit draft when unraced', async () => {
const mgr = createManager();
mgr.handleNewSessionClose({ prompt: 'note text', mode: 'note', imageIds: ['note-img'] });

loadDraftMock.mockImplementation(async () => 'stored commit draft');
await mgr.openNewSessionReferring('#note:abc123');

expect(mgr.showNewSession).toBe(true);
expect(mgr.newSessionMode).toBe('commit');
expect(mgr.draftPrompt).toContain('stored commit draft');
expect(mgr.draftPrompt).toContain('#note:abc123');
expect(mgr.draftPrompt).not.toContain('note text');
// Images aren't persisted, but the held ones belong to note mode and must
// not seed the commit modal.
expect(mgr.draftImageIds).toEqual([]);
});

it('keeps held commit-mode images across a referring open', async () => {
const mgr = createManager();
mgr.handleNewSessionClose({ prompt: 'commit text', mode: 'commit', imageIds: ['commit-img'] });

await mgr.openNewSessionReferring('#note:abc123');

expect(mgr.newSessionMode).toBe('commit');
expect(mgr.draftPrompt).toContain('commit text');
expect(mgr.draftImageIds).toEqual(['commit-img']);
});
});

describe('BranchCardSessionManager per-mode image drafts', () => {
it("clears another mode's held images when opening a different mode", () => {
// Regression: openNewSession cleared draftPrompt when opening a different
// mode but left draftImageIds, so images attached while drafting a note
// seeded the commit modal via initialImageIds.
const mgr = createManager();
mgr.handleNewSessionClose({ prompt: 'note text', mode: 'note', imageIds: ['note-img'] });

mgr.openNewSession('commit');

expect(mgr.showNewSession).toBe(true);
expect(mgr.newSessionMode).toBe('commit');
expect(mgr.draftPrompt).toBe('');
expect(mgr.draftImageIds).toEqual([]);
});

it('keeps held images when reopening the same mode', () => {
const mgr = createManager();
mgr.handleNewSessionClose({ prompt: 'note text', mode: 'note', imageIds: ['note-img'] });

mgr.openNewSession('note');

expect(mgr.draftPrompt).toBe('note text');
expect(mgr.draftImageIds).toEqual(['note-img']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
BranchSessionType,
} from '../../types';
import { buildReferringPrompt } from '../../shared/buildReferringPrompt';
import { createDraftWriteScheduler, loadDraft } from '../../shared/draftCache';
import { shouldQueueBranchSession } from './branchSessionQueue';
import {
getPendingSessionItems,
Expand All @@ -36,6 +37,8 @@ export default class BranchCardSessionManager {
private loadTimeline: (opts?: { timelineKey?: string | null; force?: boolean }) => void =
undefined!;
private getTimeline: () => BranchTimelineData | null = () => null;
private draftWriter = createDraftWriteScheduler();
private openNewSessionRequest = 0;

// New session modal state
showNewSession = $state(false);
Expand Down Expand Up @@ -98,6 +101,10 @@ export default class BranchCardSessionManager {
});
}

private draftKeyForMode(mode: BranchSessionType): string {
return `draft:branch-session:${this.getBranch().id}:${mode}`;
}

async startOrQueueSession(
mode: BranchSessionType,
prompt: string,
Expand Down Expand Up @@ -125,33 +132,119 @@ export default class BranchCardSessionManager {
void this.startReviewSessionWithoutDialog();
return;
}
// Supersede any in-flight referring open so it can't clobber this one.
++this.openNewSessionRequest;
// Open synchronously — the modal fetches the stored draft itself (via
// loadDraftForMode) so the click isn't gated on store IPC. Seed it with
// the draft held in memory only when that draft belongs to the mode being
// opened, or another mode's text or images would leak in.
if (mode !== this.newSessionMode) {
this.draftPrompt = '';
this.draftImageIds = [];
}
this.newSessionMode = mode;
this.showNewSession = true;
}

openNewSessionReferring(hashtagRef: string) {
const hadContent = this.showNewSession && this.draftPrompt.trim();
async openNewSessionReferring(hashtagRef: string) {
const request = ++this.openNewSessionRequest;
const wasOpen = this.showNewSession;
const hadContent = wasOpen && this.draftPrompt.trim();
if (!wasOpen) {
const heldMode = this.newSessionMode;
const heldPrompt = this.draftPrompt;
// newSessionMode is only assigned after the generation guard below:
// setting it to 'commit' before the awaited read would let a concurrent
// openNewSession('commit') see mode === newSessionMode and keep a
// draftPrompt that actually belongs to heldMode, seeding commit mode
// with another mode's text.
const draft = await this.loadNewSessionDraftForMode('commit');
if (request !== this.openNewSessionRequest) return;
this.draftPrompt = this.resolveOpenPrompt('commit', draft, heldMode, heldPrompt);
// Images aren't persisted to the store, so the held ones survive only
// when they already belong to commit mode — same rule as the prompt.
if (heldMode !== 'commit') {
this.draftImageIds = [];
}
}
this.draftPrompt = buildReferringPrompt(this.draftPrompt, hashtagRef);
if (!hadContent) {
if (!wasOpen || !hadContent) {
this.newSessionMode = 'commit';
this.showNewSession = true;
}
// Keep this save in the same tick as opening the modal so its
// loadInitialDraft hits the equality fast path (stored draft === seed)
// and the ref is preserved exactly. An await here wouldn't lose the ref,
// but the open would then depend on the insertedText prefix/suffix
// heuristic instead.
this.handleNewSessionDraftChange(
{ prompt: this.draftPrompt, mode: this.newSessionMode, imageIds: this.draftImageIds },
{ immediate: true }
);
}

async startReviewSessionWithoutDialog() {
this.newSessionMode = 'review';
this.showNewSession = false;
this.draftPrompt = '';
this.draftImageIds = [];
void this.draftWriter.clearNow(this.draftKeyForMode('review'));

const reviewPrompt = 'Review the code changes on this branch.';
await this.startOrQueueSession('review', reviewPrompt);
}

handleNewSessionClose(draft: { prompt: string; mode: BranchSessionType; imageIds: string[] }) {
async loadNewSessionDraftForMode(mode: BranchSessionType): Promise<string | null> {
// Never reject: the modal calls this fire-and-forget on mount and on mode
// switch, so a failed store read should degrade to keeping the seeded
// draft instead of blocking the modal.
try {
// Settle queued writes first. Closing the modal and switching modes both
// save immediately, so reading the store straight away can return the
// value from before that write landed.
await this.draftWriter.flush();
return await loadDraft(this.draftKeyForMode(mode));
} catch (e) {
console.error('[BranchCard] Failed to load session draft:', e);
return null;
}
}

/**
* Prompt to show when opening the modal for `mode`.
*
* `loadNewSessionDraftForMode` returns null both when nothing is stored and
* when the store read fails, so fall back to the draft this manager still
* holds in memory rather than blanking it — but only when that draft belongs
* to the mode being opened, or another mode's text would leak in.
*/
private resolveOpenPrompt(
mode: BranchSessionType,
loaded: string | null,
heldMode: BranchSessionType,
heldPrompt: string
): string {
if (loaded !== null) return loaded;
return mode === heldMode ? heldPrompt : '';
}

handleNewSessionDraftChange(
draft: { prompt: string; mode: BranchSessionType; imageIds: string[] },
opts: { immediate?: boolean } = {}
) {
this.draftPrompt = draft.prompt;
this.newSessionMode = draft.mode;
this.draftImageIds = draft.imageIds;
const key = this.draftKeyForMode(draft.mode);
if (opts.immediate) {
void this.draftWriter.saveNow(key, draft.prompt);
} else {
this.draftWriter.scheduleSave(key, draft.prompt);
}
}

handleNewSessionClose(draft: { prompt: string; mode: BranchSessionType; imageIds: string[] }) {
this.handleNewSessionDraftChange(draft, { immediate: true });
this.showNewSession = false;
}

Expand All @@ -166,6 +259,7 @@ export default class BranchCardSessionManager {
this.showNewSession = false;
this.draftPrompt = '';
this.draftImageIds = [];
void this.draftWriter.clearNow(this.draftKeyForMode(data.mode));

const prompt =
data.prompt.trim() === '' && data.mode === 'review'
Expand Down
Loading
Loading