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 @@...@@ -7,6 +7,6 @@
7- [ ] Selecting folder should instantly create project7- [ ] Selecting folder should instantly create project
8- [ ] New projects should go on top8- [ ] New projects should go on top
9- [ ] Projects should be sorted by latest thread update9- [ ] Projects should be sorted by latest thread update
10- [ ] "Response" bar should ONLY appear on work completed10- [x] "Response" bar should ONLY appear on work completed
11- [ ] "Response" bar should ONLY appear above the FINAL message in a series11- [x] "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)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 {...@@ -50,6 +50,7 @@ function editorLabel(editor: (typeof EDITORS)[number]): string {
50}50}
5151
52const LAST_EDITOR_KEY = "codething:last-editor";52const LAST_EDITOR_KEY = "codething:last-editor";
53const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
5354
54function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {55function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
55 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";56 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
...@@ -93,7 +94,7 @@ function derivePendingApprovals(...@@ -93,7 +94,7 @@ function derivePendingApprovals(
93 events: ProviderEvent[],94 events: ProviderEvent[],
94): PendingApprovalCard[] {95): PendingApprovalCard[] {
95 const pending = new Map<string, PendingApprovalCard>();96 const pending = new Map<string, PendingApprovalCard>();
96 const ordered = [...events].reverse();97 const ordered = [...events].toReversed();
9798
98 for (const event of ordered) {99 for (const event of ordered) {
99 if (100 if (
...@@ -150,6 +151,9 @@ export default function ChatView() {...@@ -150,6 +151,9 @@ export default function ChatView() {
150 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>(151 const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>(
151 [],152 [],
152 );153 );
154 const [expandedWorkGroups, setExpandedWorkGroups] = useState<
155 Record<string, boolean>
156 >({});
153 const [nowTick, setNowTick] = useState(() => Date.now());157 const [nowTick, setNowTick] = useState(() => Date.now());
154 const messagesEndRef = useRef<HTMLDivElement>(null);158 const messagesEndRef = useRef<HTMLDivElement>(null);
155 const textareaRef = useRef<HTMLTextAreaElement>(null);159 const textareaRef = useRef<HTMLTextAreaElement>(null);
...@@ -169,6 +173,14 @@ export default function ChatView() {...@@ -169,6 +173,14 @@ export default function ChatView() {
169 () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),173 () => deriveWorkLogEntries(activeThread?.events ?? [], undefined),
170 [activeThread?.events],174 [activeThread?.events],
171 );175 );
176 const latestTurnWorkEntries = useMemo(
177 () =>
178 deriveWorkLogEntries(
179 activeThread?.events ?? [],
180 activeThread?.latestTurnId,
181 ),
182 [activeThread?.events, activeThread?.latestTurnId],
183 );
172 const pendingApprovals = useMemo(184 const pendingApprovals = useMemo(
173 () => derivePendingApprovals(activeThread?.events ?? []),185 () => derivePendingApprovals(activeThread?.events ?? []),
174 [activeThread?.events],186 [activeThread?.events],
...@@ -190,7 +202,9 @@ export default function ChatView() {...@@ -190,7 +202,9 @@ export default function ChatView() {
190 const completionSummary = useMemo(() => {202 const completionSummary = useMemo(() => {
191 if (!activeThread?.latestTurnStartedAt) return null;203 if (!activeThread?.latestTurnStartedAt) return null;
192 if (!activeThread.latestTurnCompletedAt) return null;204 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
195 if (209 if (
196 typeof activeThread.latestTurnDurationMs === "number" &&210 typeof activeThread.latestTurnDurationMs === "number" &&
...@@ -209,28 +223,36 @@ export default function ChatView() {...@@ -209,28 +223,36 @@ export default function ChatView() {
209 activeThread?.latestTurnStartedAt,223 activeThread?.latestTurnStartedAt,
210 activeThread?.latestTurnCompletedAt,224 activeThread?.latestTurnCompletedAt,
211 activeThread?.latestTurnDurationMs,225 activeThread?.latestTurnDurationMs,
212 workLogEntries.length,226 latestTurnWorkEntries,
213 ]);227 ]);
214 const completionDividerBeforeEntryId = useMemo(() => {228 const completionDividerBeforeEntryId = useMemo(() => {
215 if (!activeThread?.latestTurnStartedAt) return null;229 if (!activeThread?.latestTurnStartedAt) return null;
216 if (!activeThread.latestTurnCompletedAt) return null;230 if (!activeThread.latestTurnCompletedAt) return null;
217 if (workLogEntries.length === 0) return null;231 if (!completionSummary) return null;
218232
219 const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt);233 const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt);
234 const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt);
220 if (Number.isNaN(turnStartedAt)) return null;235 if (Number.isNaN(turnStartedAt)) return null;
236 if (Number.isNaN(turnCompletedAt)) return null;
221237
222 const entry = timelineEntries.find((timelineEntry) => {238 let inRangeMatch: string | null = null;
223 if (timelineEntry.kind !== "message") return false;239 let fallbackMatch: string | null = null;
224 if (timelineEntry.message.role !== "assistant") return false;240 for (const timelineEntry of timelineEntries) {
241 if (timelineEntry.kind !== "message") continue;
242 if (timelineEntry.message.role !== "assistant") continue;
225 const messageAt = Date.parse(timelineEntry.message.createdAt);243 const messageAt = Date.parse(timelineEntry.message.createdAt);
226 return !Number.isNaN(messageAt) && messageAt >= turnStartedAt;244 if (Number.isNaN(messageAt) || messageAt < turnStartedAt) continue;
227 });245 fallbackMatch = timelineEntry.id;
228 return entry?.id ?? null;246 if (messageAt <= turnCompletedAt) {
247 inRangeMatch = timelineEntry.id;
248 }
249 }
250 return inRangeMatch ?? fallbackMatch;
229 }, [251 }, [
230 activeThread?.latestTurnStartedAt,
231 activeThread?.latestTurnCompletedAt,252 activeThread?.latestTurnCompletedAt,
253 activeThread?.latestTurnStartedAt,
254 completionSummary,
232 timelineEntries,255 timelineEntries,
233 workLogEntries.length,
234 ]);256 ]);
235 const runtimeSessionConfig =257 const runtimeSessionConfig =
236 state.runtimeMode === "full-access"258 state.runtimeMode === "full-access"
...@@ -282,6 +304,10 @@ export default function ChatView() {...@@ -282,6 +304,10 @@ export default function ChatView() {
282 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });304 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
283 }, [phase, workLogCount]);305 }, [phase, workLogCount]);
284306
307 useEffect(() => {
308 setExpandedWorkGroups({});
309 }, [activeThread?.id]);
310
285 // Auto-resize textarea311 // Auto-resize textarea
286 useEffect(() => {312 useEffect(() => {
287 const ta = textareaRef.current;313 const ta = textareaRef.current;
...@@ -695,12 +721,123 @@ export default function ChatView() {...@@ -695,12 +721,123 @@ export default function ChatView() {
695 </div>721 </div>
696 ) : (722 ) : (
697 <div className="mx-auto max-w-3xl space-y-4">723 <div className="mx-auto max-w-3xl space-y-4">
698 {timelineEntries.map((timelineEntry, index) => (724 {timelineEntries.map((timelineEntry, index) => {
699 <Fragment key={timelineEntry.id}>725 if (
700 {timelineEntry.kind === "message" &&726 timelineEntry.kind === "work" &&
701 timelineEntry.message.role === "assistant" &&727 timelineEntries[index - 1]?.kind === "work"
702 (completionDividerBeforeEntryId === timelineEntry.id ||728 ) {
703 timelineEntries[index - 1]?.kind === "work") && (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 && (
704 <div className="my-3 flex items-center gap-3">841 <div className="my-3 flex items-center gap-3">
705 <span className="h-px flex-1 bg-border" />842 <span className="h-px flex-1 bg-border" />
706 <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80">843 <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() {...@@ -711,39 +848,6 @@ export default function ChatView() {
711 <span className="h-px flex-1 bg-border" />848 <span className="h-px flex-1 bg-border" />
712 </div>849 </div>
713 )}850 )}
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 ) : (
747 <div className="px-1 py-0.5">851 <div className="px-1 py-0.5">
748 <ChatMarkdown852 <ChatMarkdown
749 text={853 text={
...@@ -753,18 +857,6 @@ export default function ChatView() {...@@ -753,18 +857,6 @@ export default function ChatView() {
753 : "(empty response)")857 : "(empty response)")
754 }858 }
755 />859 />
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 )}
768 <p className="mt-1.5 text-[10px] text-muted-foreground/30">860 <p className="mt-1.5 text-[10px] text-muted-foreground/30">
769 {formatMessageMeta(861 {formatMessageMeta(
770 timelineEntry.message.createdAt,862 timelineEntry.message.createdAt,
...@@ -782,9 +874,9 @@ export default function ChatView() {...@@ -782,9 +874,9 @@ export default function ChatView() {
782 )}874 )}
783 </p>875 </p>
784 </div>876 </div>
785 )}877 </Fragment>
786 </Fragment>878 );
787 ))}879 })}
788 {isWorking && (880 {isWorking && (
789 <div className="flex items-center gap-2 py-0.5 pl-1.5">881 <div className="flex items-center gap-2 py-0.5 pl-1.5">
790 <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" />882 <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", () => {...@@ -134,7 +134,7 @@ describe("deriveWorkLogEntries", () => {
134 expect(entries[0]?.label).toBe("Tool call");134 expect(entries[0]?.label).toBe("Tool call");
135 });135 });
136136
137 it("logs turn completion with elapsed time", () => {137 it("does not surface successful turn completion as a work-log row", () => {
138 const entries = deriveWorkLogEntries(138 const entries = deriveWorkLogEntries(
139 [139 [
140 makeEvent({140 makeEvent({
...@@ -154,7 +154,38 @@ describe("deriveWorkLogEntries", () => {...@@ -154,7 +154,38 @@ describe("deriveWorkLogEntries", () => {
154 "turn-1",154 "turn-1",
155 );155 );
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");
158 });189 });
159190
160 it("hides reasoning and agent-message noise from the visible work log", () => {191 it("hides reasoning and agent-message noise from the visible work log", () => {
...@@ -229,11 +260,45 @@ describe("deriveWorkLogEntries", () => {...@@ -229,11 +260,45 @@ describe("deriveWorkLogEntries", () => {
229 "turn-1",260 "turn-1",
230 );261 );
231262
232 expect(entries.map((entry) => entry.label)).toEqual([263 expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]);
233 "Turn complete in 8.0s",264 });
234 "Tool call complete",265
235 "Tool call",266 it("coalesces command start/completed lifecycle into one entry", () => {
236 ]);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");
237 });302 });
238303
239 it("preserves full tool-call detail text without data truncation", () => {304 it("preserves full tool-call detail text without data truncation", () => {
...@@ -257,6 +322,50 @@ describe("deriveWorkLogEntries", () => {...@@ -257,6 +322,50 @@ describe("deriveWorkLogEntries", () => {
257 expect(entries[0]?.detail).toBe(longCommand);322 expect(entries[0]?.detail).toBe(longCommand);
258 });323 });
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
260 it("can derive work-log entries across all turns when not scoped", () => {369 it("can derive work-log entries across all turns when not scoped", () => {
261 const entries = deriveWorkLogEntries(370 const entries = deriveWorkLogEntries(
262 [371 [
apps/renderer/src/session-logic.ts+79-35
...@@ -135,15 +135,28 @@ function itemTypeMeta(type: string): {...@@ -135,15 +135,28 @@ function itemTypeMeta(type: string): {
135 return { label: "Work item", tone: "info" };135 return { label: "Work item", tone: "info" };
136}136}
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
138function extractDetail(148function extractDetail(
139 payload: Record<string, unknown> | undefined,149 payload: Record<string, unknown> | undefined,
140 item: Record<string, unknown> | undefined,150 item: Record<string, unknown> | undefined,
141): string | undefined {151): string | undefined {
142 const candidates = [152 const candidates = [
153 asString(item?.command),
154 asString(item?.tool),
155 asString(item?.name),
143 asString(item?.title),156 asString(item?.title),
144 asString(item?.summary),157 asString(item?.summary),
145 asString(item?.text),158 asString(item?.text),
146 asString(item?.command),159 asString(item?.prompt),
147 asString(payload?.message),160 asString(payload?.message),
148 asString(payload?.prompt),161 asString(payload?.prompt),
149 asString(payload?.command),162 asString(payload?.command),
...@@ -156,7 +169,9 @@ function extractDetail(...@@ -156,7 +169,9 @@ function extractDetail(
156 return undefined;169 return undefined;
157}170}
158171
159function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {172function lifecycleCandidateFromItemEvent(
173 event: ProviderEvent,
174): ItemLifecycleCandidate | null {
160 const payload = asObject(event.payload);175 const payload = asObject(event.payload);
161 const item = asObject(payload?.item);176 const item = asObject(payload?.item);
162 const normalizedType = normalizeItemType(asString(item?.type));177 const normalizedType = normalizeItemType(asString(item?.type));
...@@ -174,14 +189,16 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {...@@ -174,14 +189,16 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null {
174 return null;189 return null;
175 }190 }
176191
177 const label = isCompleted ? `${meta.label} complete` : meta.label;
178 const detail = extractDetail(payload, item);192 const detail = extractDetail(payload, item);
193 const itemId = event.itemId ?? asString(item?.id);
179 return {194 return {
180 id: event.id,195 id: event.id,
196 ...(itemId ? { itemId } : {}),
181 createdAt: event.createdAt,197 createdAt: event.createdAt,
182 label,198 label: meta.label,
183 ...(detail ? { detail } : {}),199 ...(detail ? { detail } : {}),
184 tone: meta.tone,200 tone: meta.tone,
201 phase: isCompleted ? "completed" : "started",
185 };202 };
186}203}
187204
...@@ -224,10 +241,7 @@ function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {...@@ -224,10 +241,7 @@ function entryFromRequest(event: ProviderEvent): WorkLogEntry | null {
224 };241 };
225}242}
226243
227function entryFromNotification(244function entryFromNotification(event: ProviderEvent): WorkLogEntry | null {
228 event: ProviderEvent,
229 turnStartedAt: number,
230): WorkLogEntry | null {
231 if (event.kind !== "notification") return null;245 if (event.kind !== "notification") return null;
232 if (shouldDropMethod(event.method)) return null;246 if (shouldDropMethod(event.method)) return null;
233 if (event.method === "item/agentMessage/delta") return null;247 if (event.method === "item/agentMessage/delta") return null;
...@@ -240,34 +254,22 @@ function entryFromNotification(...@@ -240,34 +254,22 @@ function entryFromNotification(
240 const payload = asObject(event.payload);254 const payload = asObject(event.payload);
241 const turn = asObject(payload?.turn);255 const turn = asObject(payload?.turn);
242 const status = asString(turn?.status);256 const status = asString(turn?.status);
257 if (status !== "failed") {
258 return null;
259 }
243 const turnError = asObject(turn?.error);260 const turnError = asObject(turn?.error);
244 const turnErrorMessage = asString(turnError?.message);261 const turnErrorMessage = asString(turnError?.message);
245 const turnErrorDetail = normalizeDetail(turnErrorMessage);262 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
254 return {264 return {
255 id: event.id,265 id: event.id,
256 createdAt: event.createdAt,266 createdAt: event.createdAt,
257 label:267 label: "Turn failed",
258 status === "failed"
259 ? "Turn failed"
260 : durationMs !== undefined
261 ? `Turn complete in ${formatDuration(durationMs)}`
262 : "Turn complete",
263 ...(turnErrorDetail ? { detail: turnErrorDetail } : {}),268 ...(turnErrorDetail ? { detail: turnErrorDetail } : {}),
264 tone: status === "failed" ? "error" : "info",269 tone: "error",
265 };270 };
266 }271 }
267272
268 const lifecycleEntry = entryFromItemLifecycle(event);
269 if (lifecycleEntry) return lifecycleEntry;
270
271 if (event.method.startsWith("item/")) return null;273 if (event.method.startsWith("item/")) return null;
272274
273 return null;275 return null;
...@@ -307,19 +309,61 @@ export function deriveWorkLogEntries(...@@ -307,19 +309,61 @@ export function deriveWorkLogEntries(
307 : undefined;309 : undefined;
308 const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN;310 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>();
310 for (const event of ordered) {330 for (const event of ordered) {
311 if (turnId) {331 if (!shouldIncludeEvent(event)) continue;
312 const scopedTurnId = eventTurnId(event);332 const candidate = lifecycleCandidateFromItemEvent(event);
313 if (scopedTurnId && scopedTurnId !== turnId) {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 ) {
314 continue;348 continue;
315 }349 }
316350 if (
317 if (!scopedTurnId && !Number.isNaN(turnStartedAt)) {351 lifecycleCandidate.label === "Tool call" &&
318 const eventAt = Date.parse(event.createdAt);352 !lifecycleCandidate.detail
319 if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) {353 ) {
320 continue;354 continue;
321 }
322 }355 }
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;
323 }367 }
324368
325 const fromRequest = entryFromRequest(event);369 const fromRequest = entryFromRequest(event);
...@@ -328,7 +372,7 @@ export function deriveWorkLogEntries(...@@ -328,7 +372,7 @@ export function deriveWorkLogEntries(
328 continue;372 continue;
329 }373 }
330374
331 const fromNotification = entryFromNotification(event, turnStartedAt);375 const fromNotification = entryFromNotification(event);
332 if (fromNotification) {376 if (fromNotification) {
333 entries.push(fromNotification);377 entries.push(fromNotification);
334 continue;378 continue;
apps/renderer/src/store.test.ts+26-3
...@@ -93,7 +93,7 @@ describe("store reducer thread continuity", () => {...@@ -93,7 +93,7 @@ describe("store reducer thread continuity", () => {
93 expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled");93 expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled");
94 });94 });
9595
96 it("surfaces thread id mismatches without overwriting stored identity", () => {96 it("ignores events from a foreign thread within the same session", () => {
97 const state = makeState(makeThread({ codexThreadId: "thr_expected" }));97 const state = makeState(makeThread({ codexThreadId: "thr_expected" }));
98 const next = reducer(state, {98 const next = reducer(state, {
99 type: "APPLY_EVENT",99 type: "APPLY_EVENT",
...@@ -105,7 +105,30 @@ describe("store reducer thread continuity", () => {...@@ -105,7 +105,30 @@ describe("store reducer thread continuity", () => {
105 activeAssistantItemRef: { current: null },105 activeAssistantItemRef: { current: null },
106 });106 });
107107
108 expect(next.threads[0]?.codexThreadId).toBe("thr_expected");108 expect(next).toBe(state);
109 expect(next.threads[0]?.error).toContain("Thread identity mismatch");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");
110 });133 });
111});134});
apps/renderer/src/store.ts+38-10
...@@ -132,7 +132,37 @@ function getEventTurnId(event: ProviderEvent): string | undefined {...@@ -132,7 +132,37 @@ function getEventTurnId(event: ProviderEvent): string | undefined {
132function getEventThreadId(event: ProviderEvent): string | undefined {132function getEventThreadId(event: ProviderEvent): string | undefined {
133 if (event.threadId) return event.threadId;133 if (event.threadId) return event.threadId;
134 const payload = asObject(event.payload);134 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;
136}166}
137167
138function durationMs(startIso: string, endIso: string): number | undefined {168function durationMs(startIso: string, endIso: string): number | undefined {
...@@ -226,6 +256,7 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -226,6 +256,7 @@ export function reducer(state: AppState, action: Action): AppState {
226 const { event, activeAssistantItemRef } = action;256 const { event, activeAssistantItemRef } = action;
227 const target = findThreadBySessionId(state.threads, event.sessionId);257 const target = findThreadBySessionId(state.threads, event.sessionId);
228 if (!target) return state;258 if (!target) return state;
259 if (shouldIgnoreForeignThreadEvent(target, event)) return state;
229260
230 return {261 return {
231 ...state,262 ...state,
...@@ -233,17 +264,14 @@ export function reducer(state: AppState, action: Action): AppState {...@@ -233,17 +264,14 @@ export function reducer(state: AppState, action: Action): AppState {
233 ...t,264 ...t,
234 ...(() => {265 ...(() => {
235 const eventThreadId = getEventThreadId(event);266 const eventThreadId = getEventThreadId(event);
236 const hasThreadMismatch =267 const shouldRebindIdentity =
237 t.codexThreadId !== null &&268 event.method === "thread/started" &&
238 eventThreadId !== undefined &&269 t.session?.status === "connecting";
239 eventThreadId !== t.codexThreadId;
240 const threadMismatchError = hasThreadMismatch
241 ? `Thread identity mismatch: expected ${t.codexThreadId}, received ${eventThreadId}.`
242 : null;
243 return {270 return {
244 codexThreadId: t.codexThreadId ?? eventThreadId ?? null,271 codexThreadId: shouldRebindIdentity
272 ? eventThreadId ?? t.codexThreadId
273 : t.codexThreadId ?? eventThreadId ?? null,
245 error:274 error:
246 threadMismatchError ??
247 (event.kind === "error" && event.message275 (event.kind === "error" && event.message
248 ? event.message276 ? event.message
249 : t.error),277 : t.error),