Skip to content

Commit 640fc57

Browse files
committed
fix(desktop): preserve generation elapsed time
1 parent 3198d49 commit 640fc57

9 files changed

Lines changed: 89 additions & 38 deletions

File tree

apps/desktop/src/main/generation-ipc.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ describe('withInFlightGenerationForDesign', () => {
125125
const controller = new AbortController();
126126
const otherController = new AbortController();
127127
const inFlight = new Map<string, AbortController>();
128-
const inFlightByDesign = new Map<string, string>();
128+
const inFlightByDesign = new Map<string, { generationId: string; startedAt: number }>();
129129
let releaseFirst!: () => void;
130130
const firstDone = new Promise<void>((resolve) => {
131131
releaseFirst = resolve;
@@ -143,7 +143,7 @@ describe('withInFlightGenerationForDesign', () => {
143143
},
144144
);
145145

146-
await vi.waitFor(() => expect(inFlightByDesign.get('design-1')).toBe('gen-1'));
146+
await vi.waitFor(() => expect(inFlightByDesign.get('design-1')?.generationId).toBe('gen-1'));
147147
await expect(
148148
withInFlightGenerationForDesign(
149149
'gen-2',
@@ -167,7 +167,7 @@ describe('withInFlightGenerationForDesign', () => {
167167

168168
it('allows different designs to run concurrently', async () => {
169169
const inFlight = new Map<string, AbortController>();
170-
const inFlightByDesign = new Map<string, string>();
170+
const inFlightByDesign = new Map<string, { generationId: string; startedAt: number }>();
171171
const firstController = new AbortController();
172172
const secondController = new AbortController();
173173

@@ -196,7 +196,7 @@ describe('withInFlightGenerationForDesign', () => {
196196
it('clears the design lock when cancellation removes the generation', async () => {
197197
const controller = makeController();
198198
const inFlight = new Map([['gen-1', controller]]);
199-
const inFlightByDesign = new Map([['design-1', 'gen-1']]);
199+
const inFlightByDesign = new Map([['design-1', { generationId: 'gen-1', startedAt: 1234 }]]);
200200
const logIpc = { info: vi.fn() };
201201

202202
cancelGenerationRequest('gen-1', inFlight, logIpc, inFlightByDesign);
@@ -207,15 +207,15 @@ describe('withInFlightGenerationForDesign', () => {
207207
});
208208

209209
describe('listInFlightGenerations', () => {
210-
it('returns design/generation pairs from the main-process in-flight registry', () => {
210+
it('returns design/generation start times from the main-process in-flight registry', () => {
211211
const inFlightByDesign = new Map([
212-
['design-b', 'gen-b'],
213-
['design-a', 'gen-a'],
212+
['design-b', { generationId: 'gen-b', startedAt: 2000 }],
213+
['design-a', { generationId: 'gen-a', startedAt: 1000 }],
214214
]);
215215

216216
expect(listInFlightGenerations(inFlightByDesign)).toEqual([
217-
{ designId: 'design-a', generationId: 'gen-a' },
218-
{ designId: 'design-b', generationId: 'gen-b' },
217+
{ designId: 'design-a', generationId: 'gen-a', startedAt: 1000 },
218+
{ designId: 'design-b', generationId: 'gen-b', startedAt: 2000 },
219219
]);
220220
});
221221
});

apps/desktop/src/main/generation-ipc.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@ export interface CancellationLogger {
44
info: (event: string, payload: { id: string }) => void;
55
}
66

7+
export interface InFlightGeneration {
8+
generationId: string;
9+
startedAt: number;
10+
}
11+
712
export function cancelGenerationRequest(
813
raw: unknown,
914
inFlight: Map<string, AbortController>,
1015
logIpc: CancellationLogger,
11-
inFlightByDesign?: Map<string, string>,
16+
inFlightByDesign?: Map<string, InFlightGeneration>,
1217
): void {
1318
if (typeof raw !== 'string') {
1419
throw new CodesignError(
@@ -23,8 +28,8 @@ export function cancelGenerationRequest(
2328
controller.abort();
2429
inFlight.delete(raw);
2530
if (inFlightByDesign !== undefined) {
26-
for (const [designId, generationId] of inFlightByDesign) {
27-
if (generationId === raw) inFlightByDesign.delete(designId);
31+
for (const [designId, generation] of inFlightByDesign) {
32+
if (generation.generationId === raw) inFlightByDesign.delete(designId);
2833
}
2934
}
3035
logIpc.info('generate.cancelled', { id: raw });
@@ -50,32 +55,33 @@ export async function withInFlightGenerationForDesign<T>(
5055
id: string,
5156
designId: string,
5257
inFlight: Map<string, AbortController>,
53-
inFlightByDesign: Map<string, string>,
58+
inFlightByDesign: Map<string, InFlightGeneration>,
5459
controller: AbortController,
5560
run: () => Promise<T>,
5661
): Promise<T> {
5762
const existing = inFlightByDesign.get(designId);
58-
if (existing !== undefined && existing !== id) {
63+
if (existing !== undefined && existing.generationId !== id) {
5964
throw new CodesignError(
6065
'A generation is already running for this design. Wait for it to finish or stop it before continuing.',
6166
'GENERATION_ALREADY_RUNNING',
6267
);
6368
}
64-
inFlightByDesign.set(designId, id);
69+
const startedAt = existing?.startedAt ?? Date.now();
70+
inFlightByDesign.set(designId, { generationId: id, startedAt });
6571
try {
6672
return await withInFlightGeneration(id, inFlight, controller, run);
6773
} finally {
68-
if (inFlightByDesign.get(designId) === id) {
74+
if (inFlightByDesign.get(designId)?.generationId === id) {
6975
inFlightByDesign.delete(designId);
7076
}
7177
}
7278
}
7379

7480
export function listInFlightGenerations(
75-
inFlightByDesign: ReadonlyMap<string, string>,
76-
): Array<{ designId: string; generationId: string }> {
81+
inFlightByDesign: ReadonlyMap<string, InFlightGeneration>,
82+
): Array<{ designId: string; generationId: string; startedAt: number }> {
7783
return [...inFlightByDesign.entries()]
78-
.map(([designId, generationId]) => ({ designId, generationId }))
84+
.map(([designId, generation]) => ({ designId, ...generation }))
7985
.sort((a, b) => a.designId.localeCompare(b.designId));
8086
}
8187

apps/desktop/src/main/ipc/generate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
469469

470470
/** In-flight requests: generationId → AbortController */
471471
const inFlight = new Map<string, AbortController>();
472-
const inFlightByDesign = new Map<string, string>();
472+
const inFlightByDesign = new Map<string, { generationId: string; startedAt: number }>();
473473

474474
const armTimeout = (id: string, controller: AbortController) =>
475475
armGenerationTimeout(

apps/desktop/src/preload/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ export interface AgentStreamEvent {
263263

264264
export interface GenerationStatusResult {
265265
schemaVersion: 1;
266-
running: Array<{ designId: string; generationId: string }>;
266+
running: Array<{ designId: string; generationId: string; startedAt: number }>;
267267
}
268268

269269
/**

apps/desktop/src/renderer/src/components/Sidebar.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { afterEach, describe, expect, it, vi } from 'vitest';
2-
import { getTextareaLineHeight, shouldSubmitPromptKey } from './chat/PromptInput';
2+
import {
3+
elapsedSecondsSince,
4+
formatElapsedSeconds,
5+
getTextareaLineHeight,
6+
shouldSubmitPromptKey,
7+
} from './chat/PromptInput';
38

49
afterEach(() => {
510
vi.unstubAllGlobals();
@@ -66,3 +71,15 @@ describe('shouldSubmitPromptKey', () => {
6671
expect(shouldSubmitPromptKey({ key: 'Enter' }, true)).toBe(false);
6772
});
6873
});
74+
75+
describe('elapsed generation timer helpers', () => {
76+
it('derives elapsed seconds from the generation start timestamp', () => {
77+
expect(elapsedSecondsSince(10_000, 35_400)).toBe(25);
78+
expect(elapsedSecondsSince(10_000, 9_000)).toBe(0);
79+
});
80+
81+
it('formats elapsed seconds as seconds first and mm:ss after a minute', () => {
82+
expect(formatElapsedSeconds(25)).toBe('25s');
83+
expect(formatElapsedSeconds(65)).toBe('1:05');
84+
});
85+
});

apps/desktop/src/renderer/src/components/chat/PromptInput.tsx

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ export function getTextareaLineHeight(el: HTMLTextAreaElement): number {
6464
return fontSize * leading;
6565
}
6666

67+
export function elapsedSecondsSince(
68+
startedAt: number | null | undefined,
69+
now = Date.now(),
70+
): number {
71+
if (startedAt === null || startedAt === undefined) return 0;
72+
return Math.max(0, Math.floor((now - startedAt) / 1000));
73+
}
74+
75+
export function formatElapsedSeconds(elapsedSec: number): string {
76+
return elapsedSec < 60
77+
? `${elapsedSec}s`
78+
: `${Math.floor(elapsedSec / 60)}:${String(elapsedSec % 60).padStart(2, '0')}`;
79+
}
80+
6781
function resizeTextarea(el: HTMLTextAreaElement): void {
6882
const rowHeight = getTextareaLineHeight(el);
6983
el.style.height = 'auto';
@@ -117,6 +131,12 @@ export const PromptInput = forwardRef<PromptInputHandle, PromptInputProps>(funct
117131
const taRef = useRef<HTMLTextAreaElement>(null);
118132
const compositionActiveRef = useRef(false);
119133
const generationStage = useCodesignStore((s) => s.generationStage);
134+
const generationStartedAt = useCodesignStore((s) => {
135+
const currentDesignId = s.currentDesignId;
136+
return currentDesignId === null
137+
? null
138+
: (s.generationByDesign[currentDesignId]?.startedAt ?? null);
139+
});
120140

121141
const runningLabel = isGenerating
122142
? (() => {
@@ -145,18 +165,15 @@ export const PromptInput = forwardRef<PromptInputHandle, PromptInputProps>(funct
145165
setElapsedSec(0);
146166
return;
147167
}
148-
const start = Date.now();
149-
setElapsedSec(0);
168+
const start = generationStartedAt ?? Date.now();
169+
setElapsedSec(elapsedSecondsSince(start));
150170
const id = setInterval(() => {
151-
setElapsedSec(Math.floor((Date.now() - start) / 1000));
171+
setElapsedSec(elapsedSecondsSince(start));
152172
}, 500);
153173
return () => clearInterval(id);
154-
}, [isGenerating]);
174+
}, [generationStartedAt, isGenerating]);
155175

156-
const elapsedText =
157-
elapsedSec < 60
158-
? `${elapsedSec}s`
159-
: `${Math.floor(elapsedSec / 60)}:${String(elapsedSec % 60).padStart(2, '0')}`;
176+
const elapsedText = formatElapsedSeconds(elapsedSec);
160177

161178
useEffect(() => {
162179
if (taRef.current) resizeTextarea(taRef.current);

apps/desktop/src/renderer/src/store.generationStage.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ describe('generationStage transitions', () => {
113113
codesign: mockCodesignApi({
114114
generationStatus: vi.fn(async () => ({
115115
schemaVersion: 1,
116-
running: [{ designId: DEFAULT_DESIGN.id, generationId: 'gen-main' }],
116+
running: [{ designId: DEFAULT_DESIGN.id, generationId: 'gen-main', startedAt: 1234 }],
117117
})),
118118
}),
119119
setTimeout,
@@ -123,6 +123,7 @@ describe('generationStage transitions', () => {
123123

124124
expect(useCodesignStore.getState().generationByDesign[DEFAULT_DESIGN.id]).toEqual({
125125
generationId: 'gen-main',
126+
startedAt: 1234,
126127
stage: 'thinking',
127128
});
128129
expect(useCodesignStore.getState().isGenerating).toBe(true);

apps/desktop/src/renderer/src/store.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,10 @@ export interface CodesignState {
110110
previewSourceByDesign: Record<string, string>;
111111
/** Most-recent-first list of design ids in the preview pool. */
112112
recentDesignIds: string[];
113-
generationByDesign: Record<string, { generationId: string; stage: GenerationStage }>;
113+
generationByDesign: Record<
114+
string,
115+
{ generationId: string; stage: GenerationStage; startedAt?: number }
116+
>;
114117
isGenerating: boolean;
115118
activeGenerationId: string | null;
116119
/** Design id that owns the in-flight generation. Lets the user switch to

apps/desktop/src/renderer/src/store/slices/generation.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ function startGenerationForDesign(set: SetState, designId: string, generationId:
114114
set((state) => {
115115
const generationByDesign = {
116116
...state.generationByDesign,
117-
[designId]: { generationId, stage: 'sending' as GenerationStage },
117+
[designId]: { generationId, stage: 'sending' as GenerationStage, startedAt: Date.now() },
118118
};
119119
return {
120120
generationByDesign,
@@ -135,12 +135,15 @@ function markGenerationRunningForDesign(
135135
): void {
136136
set((state) => {
137137
const current = state.generationByDesign[designId];
138-
if (current?.generationId === generationId && current.stage === stage) return {};
138+
if (current?.generationId === generationId && current.stage === stage && current.startedAt) {
139+
return {};
140+
}
139141
const generationByDesign = {
140142
...state.generationByDesign,
141143
[designId]: {
142144
generationId,
143145
stage,
146+
startedAt: current?.startedAt ?? Date.now(),
144147
},
145148
};
146149
return {
@@ -156,16 +159,20 @@ function markGenerationRunningForDesign(
156159

157160
function reconcileGenerationStatus(
158161
set: SetState,
159-
running: Array<{ designId: string; generationId: string }>,
162+
running: Array<{ designId: string; generationId: string; startedAt?: number }>,
160163
): void {
161164
set((state) => {
162165
const next: CodesignState['generationByDesign'] = {};
163166
for (const item of running) {
164167
const existing = state.generationByDesign[item.designId];
165168
next[item.designId] =
166169
existing?.generationId === item.generationId
167-
? existing
168-
: { generationId: item.generationId, stage: 'thinking' };
170+
? { ...existing, startedAt: existing.startedAt ?? item.startedAt ?? Date.now() }
171+
: {
172+
generationId: item.generationId,
173+
stage: 'thinking',
174+
startedAt: item.startedAt ?? Date.now(),
175+
};
169176
}
170177
return {
171178
generationByDesign: next,
@@ -199,7 +206,7 @@ function updateGenerationStageById(
199206
if (current?.generationId !== generationId) return {};
200207
const generationByDesign = {
201208
...state.generationByDesign,
202-
[designId]: { generationId, stage },
209+
[designId]: { generationId, stage, startedAt: current.startedAt ?? Date.now() },
203210
};
204211
return {
205212
generationByDesign,

0 commit comments

Comments
 (0)