Skip to content

Commit 137dc96

Browse files
committed
Fix desktop optimistic user echo
1 parent 9f1ae99 commit 137dc96

5 files changed

Lines changed: 422 additions & 11 deletions

File tree

desktop/garyx-desktop/src/renderer/src/app-shell/AppShell.tsx

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5973,16 +5973,27 @@ export function AppShell() {
59735973
});
59745974
updateMessagesByThread((current) => {
59755975
const existing = current[threadId] || [];
5976-
let updated = false;
5976+
let assistantUpdated = false;
59775977
const nextEntries = existing.map((entry) => {
5978+
if (
5979+
entry.role === "user" &&
5980+
entry.intentId === intentId &&
5981+
entry.localState !== "remote_final"
5982+
) {
5983+
return {
5984+
...entry,
5985+
error: true,
5986+
localState: "error" as TranscriptEntryState,
5987+
};
5988+
}
59785989
if (
59795990
entry.role !== "assistant" ||
59805991
entry.intentId !== intentId ||
59815992
(!entry.pending && !entry.error)
59825993
) {
59835994
return entry;
59845995
}
5985-
updated = true;
5996+
assistantUpdated = true;
59865997
return {
59875998
...entry,
59885999
pending: false,
@@ -5991,7 +6002,7 @@ export function AppShell() {
59916002
text: entry.pending ? message : entry.text || message,
59926003
};
59936004
});
5994-
if (updated) {
6005+
if (assistantUpdated) {
59956006
return {
59966007
...current,
59976008
[threadId]: nextEntries,
@@ -8421,8 +8432,19 @@ export function AppShell() {
84218432
...current,
84228433
[threadId]: (() => {
84238434
const existing = current[threadId] || [];
8424-
let updated = false;
8435+
let assistantUpdated = false;
84258436
const next = existing.map((entry) => {
8437+
if (
8438+
entry.role === "user" &&
8439+
entry.intentId === failedIntentId &&
8440+
entry.localState !== "remote_final"
8441+
) {
8442+
return {
8443+
...entry,
8444+
error: true,
8445+
localState: errorState,
8446+
};
8447+
}
84268448
const isTargetAssistant =
84278449
entry.role === "assistant" &&
84288450
entry.intentId === failedIntentId &&
@@ -8432,7 +8454,7 @@ export function AppShell() {
84328454
if (!isTargetAssistant) {
84338455
return entry;
84348456
}
8435-
updated = true;
8457+
assistantUpdated = true;
84368458
return {
84378459
...entry,
84388460
pending: false,
@@ -8444,7 +8466,7 @@ export function AppShell() {
84448466
: entry.text || message,
84458467
};
84468468
});
8447-
if (updated) {
8469+
if (assistantUpdated) {
84488470
return next;
84498471
}
84508472
return [
@@ -9070,6 +9092,18 @@ export function AppShell() {
90709092
updateMessagesByThread((current) => ({
90719093
...current,
90729094
[threadId]: (current[threadId] || []).map((entry) => {
9095+
if (
9096+
entry.role === "user" &&
9097+
entry.intentId &&
9098+
interruptedIntentIds.has(entry.intentId) &&
9099+
entry.localState !== "remote_final"
9100+
) {
9101+
return {
9102+
...entry,
9103+
error: true,
9104+
localState: "interrupted",
9105+
};
9106+
}
90739107
if (entry.role !== "assistant") {
90749108
return entry;
90759109
}

desktop/garyx-desktop/src/renderer/src/app-shell/components/ThreadPage.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import { parseTaskNotificationText } from "../../task-notification";
4646
import { deriveThreadTeamView } from "../../thread-model";
4747
import {
4848
buildThreadViewBlocks,
49-
buildThreadViewRows,
49+
buildThreadViewRowsWithLocalUsers,
5050
type MessagesBySeq,
5151
type RenderTranscriptBlock,
5252
type TurnRow,
@@ -550,8 +550,14 @@ export function ThreadPage({
550550
// block list so per-agent speaker headers can be interleaved.
551551
const turnRows = useMemo(
552552
() =>
553-
teamView.isTeam ? [] : buildThreadViewRows(renderState, messagesBySeq),
554-
[renderState, messagesBySeq, teamView.isTeam],
553+
teamView.isTeam
554+
? []
555+
: buildThreadViewRowsWithLocalUsers(
556+
renderState,
557+
messagesBySeq,
558+
activeMessages,
559+
),
560+
[renderState, messagesBySeq, activeMessages, teamView.isTeam],
555561
);
556562
const teamBlocks = useMemo(
557563
() =>

desktop/garyx-desktop/src/renderer/src/render-view-model.test.mjs

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import test from 'node:test';
22
import assert from 'node:assert/strict';
33
import { readFileSync } from 'node:fs';
44

5-
import {
5+
import * as renderViewModel from './render-view-model.ts';
6+
7+
const {
68
buildThreadViewBlocks,
79
buildThreadViewRows,
8-
} from './render-view-model.ts';
10+
buildThreadViewRowsWithLocalUsers,
11+
} = renderViewModel;
912

1013
const renderFixture = JSON.parse(
1114
readFileSync(
@@ -239,6 +242,128 @@ test('origin user ids remain stable while the body resolves by seq', () => {
239242
assert.equal(rows[0].userBlock.entry.message.text, 'hello');
240243
});
241244

245+
test('local optimistic user row renders before committed render_state includes it', () => {
246+
const localUser = {
247+
id: 'origin:intent-optimistic-1',
248+
role: 'user',
249+
text: 'Please run the desktop smoke test.',
250+
timestamp: '2026-06-23T00:00:00.000Z',
251+
intentId: 'intent-optimistic-1',
252+
localState: 'optimistic',
253+
};
254+
const renderState = {
255+
based_on_seq: 0,
256+
rows: [],
257+
tailActivity: 'none',
258+
activeToolGroupId: null,
259+
progress_locus: 'none',
260+
visibleMessageIds: [],
261+
filtered_placeholders: [],
262+
};
263+
264+
const rows = buildThreadViewRowsWithLocalUsers(renderState, new Map(), [
265+
localUser,
266+
]);
267+
268+
assert.equal(rows.length, 1);
269+
assert.equal(rows[0].kind, 'user_turn');
270+
assert.equal(rows[0].key, `user-turn:${localUser.id}`);
271+
assert.equal(rows[0].userBlock.entry.message.text, localUser.text);
272+
});
273+
274+
test('local optimistic user row dedupes once render_state represents its origin id', () => {
275+
const originId = 'origin:intent-optimistic-2';
276+
const renderState = {
277+
based_on_seq: 3,
278+
rows: [
279+
{
280+
kind: 'user_turn',
281+
id: `user_turn:${originId}`,
282+
user: { id: originId, seq: 3, role: 'user' },
283+
activity: [],
284+
started_at: null,
285+
finished_at: null,
286+
},
287+
],
288+
tailActivity: 'none',
289+
activeToolGroupId: null,
290+
progress_locus: 'none',
291+
visibleMessageIds: [originId],
292+
filtered_placeholders: [],
293+
};
294+
const committed = {
295+
id: originId,
296+
seq: 3,
297+
role: 'user',
298+
text: 'Run the smoke test.',
299+
localState: 'remote_final',
300+
};
301+
const optimistic = {
302+
...committed,
303+
seq: undefined,
304+
localState: 'optimistic',
305+
intentId: 'intent-optimistic-2',
306+
};
307+
308+
const rows = buildThreadViewRowsWithLocalUsers(
309+
renderState,
310+
new Map([[3, committed]]),
311+
[optimistic],
312+
);
313+
314+
assert.equal(rows.length, 1);
315+
assert.equal(rows[0].kind, 'user_turn');
316+
assert.equal(rows[0].userBlock.entry.message.text, committed.text);
317+
});
318+
319+
test('local failed user row remains visible for retry chrome', () => {
320+
const failedUser = {
321+
id: 'origin:intent-failed-1',
322+
role: 'user',
323+
text: 'Deploy the staging build.',
324+
timestamp: '2026-06-23T00:00:00.000Z',
325+
intentId: 'intent-failed-1',
326+
localState: 'error',
327+
error: true,
328+
};
329+
330+
const rows = buildThreadViewRowsWithLocalUsers(null, new Map(), [failedUser]);
331+
332+
assert.equal(rows.length, 1);
333+
assert.equal(rows[0].kind, 'user_turn');
334+
assert.equal(rows[0].userBlock.entry.message.error, true);
335+
assert.equal(rows[0].userBlock.entry.message.localState, 'error');
336+
});
337+
338+
test('local assistant error row does not render through the user overlay', () => {
339+
const optimisticUser = {
340+
id: 'origin:intent-failed-2',
341+
role: 'user',
342+
text: 'Deploy the staging build.',
343+
timestamp: '2026-06-23T00:00:00.000Z',
344+
intentId: 'intent-failed-2',
345+
localState: 'optimistic',
346+
};
347+
const assistantError = {
348+
id: 'assistant:error:intent-failed-2:synthetic',
349+
role: 'assistant',
350+
text: 'Gateway rejected the request.',
351+
timestamp: '2026-06-23T00:00:01.000Z',
352+
intentId: 'intent-failed-2',
353+
localState: 'error',
354+
error: true,
355+
};
356+
357+
const rows = buildThreadViewRowsWithLocalUsers(null, new Map(), [
358+
optimisticUser,
359+
assistantError,
360+
]);
361+
362+
assert.equal(rows.length, 1);
363+
assert.equal(rows[0].kind, 'user_turn');
364+
assert.equal(rows[0].userBlock.entry.message.id, optimisticUser.id);
365+
});
366+
242367
test('unloaded committed window: rows whose bodies are absent are skipped', () => {
243368
const fixtureCase = renderFixture.cases.find(
244369
(c) => c.name === 'final answer completed',

desktop/garyx-desktop/src/renderer/src/render-view-model.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ export interface UserTurnRow {
7979

8080
export type TurnRenderRow = FlatRow | TurnRow | UserTurnRow;
8181

82+
type LocalTranscriptMessage = TranscriptMessage & {
83+
localState?: string;
84+
};
85+
8286
/**
8387
* Committed messages keyed by their raw transcript record `seq` (1-based),
8488
* stamped at the wire boundary (`TranscriptMessage.seq`). The message id is NOT
@@ -106,6 +110,69 @@ function messageBlock(message: TranscriptMessage): RenderTranscriptBlock {
106110
};
107111
}
108112

113+
function collectBlockMessageIds(
114+
block: RenderTranscriptBlock | null | undefined,
115+
ids: Set<string>,
116+
) {
117+
if (!block) {
118+
return;
119+
}
120+
if (block.kind === 'message') {
121+
ids.add(block.entry.message.id);
122+
return;
123+
}
124+
for (const entry of block.entries) {
125+
if (entry.toolUse) {
126+
ids.add(entry.toolUse.id);
127+
}
128+
if (entry.toolResult) {
129+
ids.add(entry.toolResult.id);
130+
}
131+
}
132+
}
133+
134+
function collectActivityRowMessageIds(
135+
row: UserTurnActivityRow,
136+
ids: Set<string>,
137+
) {
138+
if (row.kind === 'flat') {
139+
collectBlockMessageIds(row.block, ids);
140+
return;
141+
}
142+
for (const block of row.steps) {
143+
collectBlockMessageIds(block, ids);
144+
}
145+
collectBlockMessageIds(row.finalBlock, ids);
146+
}
147+
148+
function representedMessageIdsForRows(rows: TurnRenderRow[]): Set<string> {
149+
const ids = new Set<string>();
150+
for (const row of rows) {
151+
if (row.kind === 'flat') {
152+
collectBlockMessageIds(row.block, ids);
153+
continue;
154+
}
155+
if (row.kind === 'turn') {
156+
collectActivityRowMessageIds(row, ids);
157+
continue;
158+
}
159+
collectBlockMessageIds(row.userBlock, ids);
160+
for (const activityRow of row.activityRows) {
161+
collectActivityRowMessageIds(activityRow, ids);
162+
}
163+
}
164+
return ids;
165+
}
166+
167+
function isLocalUserMessage(message: LocalTranscriptMessage): boolean {
168+
return (
169+
message.role === 'user' &&
170+
Boolean(message.localState) &&
171+
message.localState !== 'remote_final' &&
172+
!(Boolean(message.internal) && message.internalKind === 'loop_continuation')
173+
);
174+
}
175+
109176
function toolEntry(
110177
entry: RenderToolEntry,
111178
messages: MessagesBySeq,
@@ -229,6 +296,37 @@ export function buildThreadViewRows(
229296
return rows;
230297
}
231298

299+
export function buildThreadViewRowsWithLocalUsers(
300+
renderState: RenderState | null | undefined,
301+
messages: MessagesBySeq,
302+
activeMessages: readonly LocalTranscriptMessage[],
303+
): TurnRenderRow[] {
304+
const rows = buildThreadViewRows(renderState, messages);
305+
const representedMessageIds = representedMessageIdsForRows(rows);
306+
const committedMessageIds = new Set(
307+
[...messages.values()].map((message) => message.id),
308+
);
309+
const localRows: UserTurnRow[] = [];
310+
for (const message of activeMessages) {
311+
if (!isLocalUserMessage(message)) {
312+
continue;
313+
}
314+
if (
315+
representedMessageIds.has(message.id) ||
316+
committedMessageIds.has(message.id)
317+
) {
318+
continue;
319+
}
320+
localRows.push({
321+
kind: 'user_turn',
322+
key: `user-turn:${message.id}`,
323+
userBlock: messageBlock(message),
324+
activityRows: [],
325+
});
326+
}
327+
return localRows.length ? [...rows, ...localRows] : rows;
328+
}
329+
232330
/**
233331
* Deterministic flatten of `render_state.rows` → ordered blocks for team mode,
234332
* which renders blocks linearly with per-agent speaker headers instead of

0 commit comments

Comments
 (0)