authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-09 17:52:28-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-09 17:52:28-08:00
loge7c4292d271f14ace547c135089245825b82e17b
tree14a9eb6030e66fd35c42b5f4de7c438bc1b9f2e3
parent4d3ad5bdc742e0467398210a8dc8dbcffe8a358e
signature Commit is signed but in an unrecognized format.

more ui cleanup


6 files changed, 420 insertions(+), 124 deletions(-)

TODO.md+3-3
......@@ -7,6 +7,6 @@
77- [ ] Selecting folder should instantly create project
88- [ ] New projects should go on top
99- [ ] Projects should be sorted by latest thread update
10- [ ] "Response" bar should ONLY appear on work completed
11- [ ] "Response" bar should ONLY appear above the FINAL message in a series
12- [ ] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc)
10- [x] "Response" bar should ONLY appear on work completed
11- [x] "Response" bar should ONLY appear above the FINAL message in a series
12- [x] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc)
apps/renderer/src/components/ChatView.tsx+158-66
......@@ -50,6 +50,7 @@ function editorLabel(editor: (typeof EDITORS)[number]): string {
5050}
5151
5252const LAST_EDITOR_KEY = "codething:last-editor";
53const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
5354
5455function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
5556 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
......@@ -93,7 +94,7 @@ function derivePendingApprovals(
9394 events: ProviderEvent[],
9495): PendingApprovalCard[] {
9596 const pending = new Map<string, PendingApprovalCard>();
96 const ordered = [...events].reverse();
97 const ordered = [...events].toReversed();
9798
9899 for (const event of ordered) {
99100 if (
......@@ -150,6 +151,9 @@ export default function ChatView() {
150151 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>(
151152 [],
152153 );
154 const [expandedWorkGroups, setExpandedWorkGroups] = useState<
155 Record<string, boolean>
156 >({});
153157 const [nowTick, setNowTick] = useState(() => Date.now());
154158 const messagesEndRef = useRef<HTMLDivElement>(null);
155159 const textareaRef = useRef<HTMLTextAreaElement>(null);
......@@ -169,6 +173,14 @@ export default function ChatView() {
169173 () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
170174 [activeThread?.events],
171175 );
176 const latestTurnWorkEntries = useMemo(
177 () =>
178 deriveWorkLogEntries(
179 activeThread?.events ?? [],
180 activeThread?.latestTurnId,
181 ),
182 [activeThread?.events, activeThread?.latestTurnId],
183 );
172184 const pendingApprovals = useMemo(
173185 () => derivePendingApprovals(activeThread?.events ?? []),
174186 [activeThread?.events],
......@@ -190,7 +202,9 @@ export default function ChatView() {
190202 const completionSummary = useMemo(() => {
191203 if (!activeThread?.latestTurnStartedAt) return null;
192204 if (!activeThread.latestTurnCompletedAt) return null;
193 if (workLogEntries.length === 0) return null;
205 if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) {
206 return null;
207 }
194208
195209 if (
196210 typeof activeThread.latestTurnDurationMs === "number" &&
......@@ -209,28 +223,36 @@ export default function ChatView() {
209223 activeThread?.latestTurnStartedAt,
210224 activeThread?.latestTurnCompletedAt,
211225 activeThread?.latestTurnDurationMs,
212 workLogEntries.length,
226 latestTurnWorkEntries,
213227 ]);
214228 const completionDividerBeforeEntryId = useMemo(() => {
215229 if (!activeThread?.latestTurnStartedAt) return null;
216230 if (!activeThread.latestTurnCompletedAt) return null;
217 if (workLogEntries.length === 0) return null;
231 if (!completionSummary) return null;
218232
219233 const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt);
234 const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt);
220235 if (Number.isNaN(turnStartedAt)) return null;
236 if (Number.isNaN(turnCompletedAt)) return null;
221237
222 const entry = timelineEntries.find((timelineEntry) => {
223 if (timelineEntry.kind !== "message") return false;
224 if (timelineEntry.message.role !== "assistant") return false;
238 let inRangeMatch: string | null = null;
239 let fallbackMatch: string | null = null;
240 for (const timelineEntry of timelineEntries) {
241 if (timelineEntry.kind !== "message") continue;
242 if (timelineEntry.message.role !== "assistant") continue;
225243 const messageAt = Date.parse(timelineEntry.message.createdAt);
226 return !Number.isNaN(messageAt) && messageAt >= turnStartedAt;
227 });
228 return entry?.id ?? null;
244 if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue;
245 fallbackMatch = timelineEntry.id;
246 if (messageAt <= turnCompletedAt) {
247 inRangeMatch = timelineEntry.id;
248 }
249 }
250 return inRangeMatch ?? fallbackMatch;
229251 }, [
230 activeThread?.latestTurnStartedAt,
231252 activeThread?.latestTurnCompletedAt,
253 activeThread?.latestTurnStartedAt,
254 completionSummary,
232255 timelineEntries,
233 workLogEntries.length,
234256 ]);
235257 const runtimeSessionConfig =
236258 state.runtimeMode === "full-access"
......@@ -282,6 +304,10 @@ export default function ChatView() {
282304 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
283305 }, [phase, workLogCount]);
284306
307 useEffect(() => {
308 setExpandedWorkGroups({});
309 }, [activeThread?.id]);
310
285311 // Auto-resize textarea
286312 useEffect(() => {
287313 const ta = textareaRef.current;
......@@ -695,12 +721,123 @@ export default function ChatView() {
695721 </div>
696722 ) : (
697723 <div className="mx-auto max-w-3xl space-y-4">
698 {timelineEntries.map((timelineEntry, index) => (
699 <Fragment key={timelineEntry.id}>
700 {timelineEntry.kind === "message" &&
701 timelineEntry.message.role === "assistant" &&
702 (completionDividerBeforeEntryId === timelineEntry.id ||
703 timelineEntries[index - 1]?.kind === "work") && (
724 {timelineEntries.map((timelineEntry, index) => {
725 if (
726 timelineEntry.kind === "work" &&
727 timelineEntries[index - 1]?.kind === "work"
728 ) {
729 return null;
730 }
731
732 const showCompletionDivider =
733 timelineEntry.kind === "message" &&
734 timelineEntry.message.role === "assistant" &&
735 completionDividerBeforeEntryId === timelineEntry.id;
736
737 if (timelineEntry.kind === "work") {
738 const groupedEntries = [timelineEntry.entry];
739 let cursor = index + 1;
740 while (cursor < timelineEntries.length) {
741 const nextEntry = timelineEntries[cursor];
742 if (!nextEntry || nextEntry.kind !== "work") break;
743 groupedEntries.push(nextEntry.entry);
744 cursor += 1;
745 }
746
747 const groupId = timelineEntry.id;
748 const isExpanded = expandedWorkGroups[groupId] ?? false;
749 const hasOverflow =
750 groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES;
751 const visibleEntries =
752 hasOverflow && !isExpanded
753 ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES)
754 : groupedEntries;
755 const hiddenCount = groupedEntries.length - visibleEntries.length;
756 const onlyToolEntries = groupedEntries.every(
757 (entry) => entry.tone === "tool",
758 );
759 const groupLabel = onlyToolEntries
760 ? groupedEntries.length === 1
761 ? "Tool call"
762 : `Tool calls (${groupedEntries.length})`
763 : groupedEntries.length === 1
764 ? "Work event"
765 : `Work log (${groupedEntries.length})`;
766
767 return (
768 <Fragment key={timelineEntry.id}>
769 <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2">
770 <div className="mb-1.5 flex items-center justify-between gap-3">
771 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
772 {groupLabel}
773 </p>
774 {hasOverflow && (
775 <button
776 type="button"
777 className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80"
778 onClick={() =>
779 setExpandedWorkGroups((existing) => ({
780 ...existing,
781 [groupId]: !existing[groupId],
782 }))
783 }
784 >
785 {isExpanded ? "Show less" : `Show ${hiddenCount} more`}
786 </button>
787 )}
788 </div>
789 <div className="space-y-1">
790 {visibleEntries.map((workEntry) => (
791 <div
792 key={`work-row:${workEntry.id}`}
793 className="flex items-start gap-2 py-0.5"
794 >
795 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
796 <p
797 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`}
798 >
799 {workEntry.detail ? (
800 <>
801 {workEntry.label}
802 <span
803 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
804 title={workEntry.detail}
805 >
806 {workEntry.detail}
807 </span>
808 </>
809 ) : (
810 workEntry.label
811 )}
812 </p>
813 </div>
814 ))}
815 </div>
816 </div>
817 </Fragment>
818 );
819 }
820
821 if (timelineEntry.message.role === "user") {
822 return (
823 <Fragment key={timelineEntry.id}>
824 <div className="flex justify-end">
825 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
826 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground">
827 {timelineEntry.message.text}
828 </pre>
829 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
830 {formatTimestamp(timelineEntry.message.createdAt)}
831 </p>
832 </div>
833 </div>
834 </Fragment>
835 );
836 }
837
838 return (
839 <Fragment key={timelineEntry.id}>
840 {showCompletionDivider && (
704841 <div className="my-3 flex items-center gap-3">
705842 <span className="h-px flex-1 bg-border" />
706843 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">
......@@ -711,39 +848,6 @@ export default function ChatView() {
711848 <span className="h-px flex-1 bg-border" />
712849 </div>
713850 )}
714 {timelineEntry.kind === "work" ? (
715 <div className="flex items-start gap-2 py-0.5 pl-1.5">
716 <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
717 <p
718 className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(timelineEntry.entry.tone)}`}
719 >
720 {timelineEntry.entry.detail ? (
721 <>
722 {timelineEntry.entry.label}
723 <span
724 className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60"
725 title={timelineEntry.entry.detail}
726 >
727 {timelineEntry.entry.detail}
728 </span>
729 </>
730 ) : (
731 timelineEntry.entry.label
732 )}
733 </p>
734 </div>
735 ) : timelineEntry.message.role === "user" ? (
736 <div className="flex justify-end">
737 <div className="max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3">
738 <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-relaxed text-foreground">
739 {timelineEntry.message.text}
740 </pre>
741 <p className="mt-1.5 text-right text-[10px] text-muted-foreground/30">
742 {formatTimestamp(timelineEntry.message.createdAt)}
743 </p>
744 </div>
745 </div>
746 ) : (
747851 <div className="px-1 py-0.5">
748852 <ChatMarkdown
749853 text={
......@@ -753,18 +857,6 @@ export default function ChatView() {
753857 : "(empty response)")
754858 }
755859 />
756 {timelineEntry.message.streaming && (
757 <div className="pt-1.5">
758 <span className="inline-flex items-center gap-2 rounded-full border border-sky-400/25 bg-sky-500/[0.08] px-2 py-0.5 text-[10px] text-sky-100/90">
759 <span className="inline-flex gap-1">
760 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80" />
761 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:150ms]" />
762 <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:300ms]" />
763 </span>
764 <span>Thinking</span>
765 </span>
766 </div>
767 )}
768860 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
769861 {formatMessageMeta(
770862 timelineEntry.message.createdAt,
......@@ -782,9 +874,9 @@ export default function ChatView() {
782874 )}
783875 </p>
784876 </div>
785 )}
786 </Fragment>
787 ))}
877 </Fragment>
878 );
879 })}
788880 {isWorking && (
789881 <div className="flex items-center gap-2 py-0.5 pl-1.5">
790882 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />
apps/renderer/src/session-logic.test.ts+116-7
......@@ -134,7 +134,7 @@ describe("deriveWorkLogEntries", () => {
134134 expect(entries[0]?.label).toBe("Tool call");
135135 });
136136
137 it("logs turn completion with elapsed time", () => {
137 it("does not surface successful turn completion as a work-log row", () => {
138138 const entries = deriveWorkLogEntries(
139139 [
140140 makeEvent({
......@@ -154,7 +154,38 @@ describe("deriveWorkLogEntries", () => {
154154 "turn-1",
155155 );
156156
157 expect(entries[0]?.label).toBe("Turn complete in 10s");
157 expect(entries).toHaveLength(0);
158 });
159
160 it("shows failed turn completion in the work log", () => {
161 const entries = deriveWorkLogEntries(
162 [
163 makeEvent({
164 id: "evt-start",
165 method: "turn/started",
166 turnId: "turn-1",
167 createdAt: "2026-02-08T10:00:00.000Z",
168 }),
169 makeEvent({
170 id: "evt-complete",
171 method: "turn/completed",
172 turnId: "turn-1",
173 createdAt: "2026-02-08T10:00:10.000Z",
174 payload: {
175 turn: {
176 id: "turn-1",
177 status: "failed",
178 error: { message: "sandbox denied" },
179 },
180 },
181 }),
182 ],
183 "turn-1",
184 );
185
186 expect(entries).toHaveLength(1);
187 expect(entries[0]?.label).toBe("Turn failed");
188 expect(entries[0]?.detail).toBe("sandbox denied");
158189 });
159190
160191 it("hides reasoning and agent-message noise from the visible work log", () => {
......@@ -229,11 +260,45 @@ describe("deriveWorkLogEntries", () => {
229260 "turn-1",
230261 );
231262
232 expect(entries.map((entry) => entry.label)).toEqual([
233 "Turn complete in 8.0s",
234 "Tool call complete",
235 "Tool call",
236 ]);
263 expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]);
264 });
265
266 it("coalesces command start/completed lifecycle into one entry", () => {
267 const entries = deriveWorkLogEntries(
268 [
269 makeEvent({
270 id: "evt-command-start",
271 method: "item/started",
272 turnId: "turn-1",
273 createdAt: "2026-02-08T10:00:01.000Z",
274 payload: {
275 item: {
276 id: "item-command",
277 type: "command_execution",
278 command: "git status --short",
279 },
280 },
281 }),
282 makeEvent({
283 id: "evt-command-complete",
284 method: "item/completed",
285 turnId: "turn-1",
286 createdAt: "2026-02-08T10:00:02.000Z",
287 payload: {
288 item: {
289 id: "item-command",
290 type: "command_execution",
291 command: "git status --short",
292 },
293 },
294 }),
295 ],
296 "turn-1",
297 );
298
299 expect(entries).toHaveLength(1);
300 expect(entries[0]?.label).toBe("Command run");
301 expect(entries[0]?.detail).toBe("git status --short");
237302 });
238303
239304 it("preserves full tool-call detail text without data truncation", () => {
......@@ -257,6 +322,50 @@ describe("deriveWorkLogEntries", () => {
257322 expect(entries[0]?.detail).toBe(longCommand);
258323 });
259324
325 it("hides generic tool-call rows that have no identifying detail", () => {
326 const entries = deriveWorkLogEntries(
327 [
328 makeEvent({
329 id: "evt-tool-start",
330 method: "item/started",
331 turnId: "turn-1",
332 createdAt: "2026-02-08T10:00:05.000Z",
333 payload: {
334 item: { id: "item-tool", type: "tool_call" },
335 },
336 }),
337 ],
338 "turn-1",
339 );
340
341 expect(entries).toHaveLength(0);
342 });
343
344 it("includes collab tool-call rows with tool name detail", () => {
345 const entries = deriveWorkLogEntries(
346 [
347 makeEvent({
348 id: "evt-collab-start",
349 method: "item/started",
350 turnId: "turn-1",
351 createdAt: "2026-02-08T10:00:05.000Z",
352 payload: {
353 item: {
354 id: "item-collab",
355 type: "collabAgentToolCall",
356 tool: "spawnAgent",
357 },
358 },
359 }),
360 ],
361 "turn-1",
362 );
363
364 expect(entries).toHaveLength(1);
365 expect(entries[0]?.label).toBe("Tool call");
366 expect(entries[0]?.detail).toBe("spawnAgent");
367 });
368
260369 it("can derive work-log entries across all turns when not scoped", () => {
261370 const entries = deriveWorkLogEntries(
262371 [
apps/renderer/src/session-logic.ts+79-35
......@@ -135,15 +135,28 @@ function itemTypeMeta(type: string): {
135135 return { label: "Work item", tone: "info" };
136136}
137137
138interface ItemLifecycleCandidate {
139 id: string;
140 itemId?: string;
141 createdAt: string;
142 label: string;
143 detail?: string;
144 tone: WorkLogEntry["tone"];
145 phase: "started" | "completed";
146}
147
138148function extractDetail(
139149 payload: Record<string, unknown> | undefined,
140150 item: Record<string, unknown> | undefined,
141151): string | undefined {
142152 const candidates = [
153 asString(item?.command),
154 asString(item?.tool),
155 asString(item?.name),
143156 asString(item?.title),
144157 asString(item?.summary),
145158 asString(item?.text),
146 asString(item?.command),
159 asString(item?.prompt),
147160 asString(payload?.message),
148161 asString(payload?.prompt),
149162 asString(payload?.command),
......@@ -156,7 +169,9 @@ function extractDetail(
156169 return undefined;
157170}
158171
159function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {
172function lifecycleCandidateFromItemEvent(
173 event: ProviderEvent,
174): ItemLifecycleCandidate | null {
160175 const payload = asObject(event.payload);
161176 const item = asObject(payload?.item);
162177 const normalizedType = normalizeItemType(asString(item?.type));
......@@ -174,14 +189,16 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {
174189 return null;
175190 }
176191
177 const label = isCompleted ? `${meta.label} complete` : meta.label;
178192 const detail = extractDetail(payload, item);
193 const itemId = event.itemId ?? asString(item?.id);
179194 return {
180195 id: event.id,
196 ...(itemId ? { itemId } : {}),
181197 createdAt: event.createdAt,
182 label,
198 label: meta.label,
183199 ...(detail ? { detail } : {}),
184200 tone: meta.tone,
201 phase: isCompleted ? "completed" : "started",
185202 };
186203}
187204
......@@ -224,10 +241,7 @@ function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {
224241 };
225242}
226243
227function entryFromNotification(
228 event: ProviderEvent,
229 turnStartedAt: number,
230): WorkLogEntry | null {
244function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {
231245 if (event.kind !== "notification") return null;
232246 if (shouldDropMethod(event.method)) return null;
233247 if (event.method === "item/agentMessage/delta") return null;
......@@ -240,34 +254,22 @@ function entryFromNotification(
240254 const payload = asObject(event.payload);
241255 const turn = asObject(payload?.turn);
242256 const status = asString(turn?.status);
257 if (status !== "failed") {
258 return null;
259 }
243260 const turnError = asObject(turn?.error);
244261 const turnErrorMessage = asString(turnError?.message);
245262 const turnErrorDetail = normalizeDetail(turnErrorMessage);
246 const eventAt = Date.parse(event.createdAt);
247 const durationMs =
248 Number.isNaN(turnStartedAt) ||
249 Number.isNaN(eventAt) ||
250 eventAt < turnStartedAt
251 ? undefined
252 : eventAt - turnStartedAt;
253263
254264 return {
255265 id: event.id,
256266 createdAt: event.createdAt,
257 label:
258 status === "failed"
259 ? "Turn failed"
260 : durationMs !== undefined
261 ? `Turn complete in ${formatDuration(durationMs)}`
262 : "Turn complete",
267 label: "Turn failed",
263268 ...(turnErrorDetail ? { detail: turnErrorDetail } : {}),
264 tone: status === "failed" ? "error" : "info",
269 tone: "error",
265270 };
266271 }
267272
268 const lifecycleEntry = entryFromItemLifecycle(event);
269 if (lifecycleEntry) return lifecycleEntry;
270
271273 if (event.method.startsWith("item/")) return null;
272274
273275 return null;
......@@ -307,19 +309,61 @@ export function deriveWorkLogEntries(
307309 : undefined;
308310 const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN;
309311
312 const shouldIncludeEvent = (event: ProviderEvent): boolean => {
313 if (!turnId) return true;
314 const scopedTurnId = eventTurnId(event);
315 if (scopedTurnId && scopedTurnId !== turnId) {
316 return false;
317 }
318
319 if (!scopedTurnId && !Number.isNaN(turnStartedAt)) {
320 const eventAt = Date.parse(event.createdAt);
321 if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) {
322 return false;
323 }
324 }
325
326 return true;
327 };
328
329 const completedLifecycleItemIds = new Set<string>();
310330 for (const event of ordered) {
311 if (turnId) {
312 const scopedTurnId = eventTurnId(event);
313 if (scopedTurnId && scopedTurnId !== turnId) {
331 if (!shouldIncludeEvent(event)) continue;
332 const candidate = lifecycleCandidateFromItemEvent(event);
333 if (candidate?.phase === "completed" && candidate.itemId) {
334 completedLifecycleItemIds.add(candidate.itemId);
335 }
336 }
337
338 for (const event of ordered) {
339 if (!shouldIncludeEvent(event)) continue;
340
341 const lifecycleCandidate = lifecycleCandidateFromItemEvent(event);
342 if (lifecycleCandidate) {
343 if (
344 lifecycleCandidate.phase === "started" &&
345 lifecycleCandidate.itemId &&
346 completedLifecycleItemIds.has(lifecycleCandidate.itemId)
347 ) {
314348 continue;
315349 }
316
317 if (!scopedTurnId && !Number.isNaN(turnStartedAt)) {
318 const eventAt = Date.parse(event.createdAt);
319 if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) {
320 continue;
321 }
350 if (
351 lifecycleCandidate.label === "Tool call" &&
352 !lifecycleCandidate.detail
353 ) {
354 continue;
322355 }
356
357 entries.push({
358 id: lifecycleCandidate.id,
359 createdAt: lifecycleCandidate.createdAt,
360 label: lifecycleCandidate.label,
361 ...(lifecycleCandidate.detail
362 ? { detail: lifecycleCandidate.detail }
363 : {}),
364 tone: lifecycleCandidate.tone,
365 });
366 continue;
323367 }
324368
325369 const fromRequest = entryFromRequest(event);
......@@ -328,7 +372,7 @@ export function deriveWorkLogEntries(
328372 continue;
329373 }
330374
331 const fromNotification = entryFromNotification(event, turnStartedAt);
375 const fromNotification = entryFromNotification(event);
332376 if (fromNotification) {
333377 entries.push(fromNotification);
334378 continue;
apps/renderer/src/store.test.ts+26-3
......@@ -93,7 +93,7 @@ describe("store reducer thread continuity", () => {
9393 expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled");
9494 });
9595
96 it("surfaces thread id mismatches without overwriting stored identity", () => {
96 it("ignores events from a foreign thread within the same session", () => {
9797 const state = makeState(makeThread({ codexThreadId: "thr_expected" }));
9898 const next = reducer(state, {
9999 type: "APPLY_EVENT",
......@@ -105,7 +105,30 @@ describe("store reducer thread continuity", () => {
105105 activeAssistantItemRef: { current: null },
106106 });
107107
108 expect(next.threads[0]?.codexThreadId).toBe("thr_expected");
109 expect(next.threads[0]?.error).toContain("Thread identity mismatch");
108 expect(next).toBe(state);
109 });
110
111 it("rebases thread identity on thread/started during connect", () => {
112 const state = makeState(
113 makeThread({
114 codexThreadId: "thr_old",
115 session: makeSession({
116 status: "connecting",
117 threadId: "thr_old",
118 }),
119 }),
120 );
121 const next = reducer(state, {
122 type: "APPLY_EVENT",
123 event: makeEvent({
124 method: "thread/started",
125 threadId: "thr_new",
126 payload: { thread: { id: "thr_new" } },
127 }),
128 activeAssistantItemRef: { current: null },
129 });
130
131 expect(next.threads[0]?.codexThreadId).toBe("thr_new");
132 expect(next.threads[0]?.session?.threadId).toBe("thr_new");
110133 });
111134});
apps/renderer/src/store.ts+38-10
......@@ -132,7 +132,37 @@ function getEventTurnId(event: ProviderEvent): string | undefined {
132132function getEventThreadId(event: ProviderEvent): string | undefined {
133133 if (event.threadId) return event.threadId;
134134 const payload = asObject(event.payload);
135 return asString(payload?.threadId) ?? asString(asObject(payload?.thread)?.id);
135 const payloadThread = asObject(payload?.thread);
136 const payloadMessage = asObject(payload?.msg);
137 return (
138 asString(payload?.threadId) ??
139 asString(payloadThread?.id) ??
140 asString(payload?.conversationId) ??
141 asString(payload?.thread_id) ??
142 asString(payloadMessage?.thread_id)
143 );
144}
145
146function shouldIgnoreForeignThreadEvent(
147 thread: Thread,
148 event: ProviderEvent,
149): boolean {
150 const eventThreadId = getEventThreadId(event);
151 if (!eventThreadId) {
152 return false;
153 }
154
155 const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId;
156 if (!expectedThreadId || eventThreadId === expectedThreadId) {
157 return false;
158 }
159
160 // During connect, accept a thread/started notification as an identity rebind.
161 if (event.method === "thread/started" && thread.session?.status === "connecting") {
162 return false;
163 }
164
165 return true;
136166}
137167
138168function durationMs(startIso: string, endIso: string): number | undefined {
......@@ -226,6 +256,7 @@ export function reducer(state: AppState, action: Action): AppState {
226256 const { event, activeAssistantItemRef } = action;
227257 const target = findThreadBySessionId(state.threads, event.sessionId);
228258 if (!target) return state;
259 if (shouldIgnoreForeignThreadEvent(target, event)) return state;
229260
230261 return {
231262 ...state,
......@@ -233,17 +264,14 @@ export function reducer(state: AppState, action: Action): AppState {
233264 ...t,
234265 ...(() => {
235266 const eventThreadId = getEventThreadId(event);
236 const hasThreadMismatch =
237 t.codexThreadId !== null &&
238 eventThreadId !== undefined &&
239 eventThreadId !== t.codexThreadId;
240 const threadMismatchError = hasThreadMismatch
241 ? `Thread identity mismatch: expected ${t.codexThreadId}, received ${eventThreadId}.`
242 : null;
267 const shouldRebindIdentity =
268 event.method === "thread/started" &&
269 t.session?.status === "connecting";
243270 return {
244 codexThreadId: t.codexThreadId ?? eventThreadId ?? null,
271 codexThreadId: shouldRebindIdentity
272 ? eventThreadId ?? t.codexThreadId
273 : t.codexThreadId ?? eventThreadId ?? null,
245274 error:
246 threadMismatchError ??
247275 (event.kind === "error" && event.message
248276 ? event.message
249277 : t.error),