| author | |
| committer | |
| log | e7c4292d271f14ace547c135089245825b82e17b |
| tree | 14a9eb6030e66fd35c42b5f4de7c438bc1b9f2e3 |
| parent | 4d3ad5bdc742e0467398210a8dc8dbcffe8a358e |
| signature | Commit is signed but in an unrecognized format. |
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 project | 7 | - [ ] Selecting folder should instantly create project |
| 8 | - [ ] New projects should go on top | 8 | - [ ] New projects should go on top |
| 9 | - [ ] Projects should be sorted by latest thread update | 9 | - [ ] Projects should be sorted by latest thread update |
| 10 | - [ ] "Response" bar should ONLY appear on work completed | 10 | - [x] "Response" bar should ONLY appear on work completed |
| 11 | - [ ] "Response" bar should ONLY appear above the FINAL message in a series | 11 | - [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 | } |
| 51 | 51 | ||
| 52 | const LAST_EDITOR_KEY = "codething:last-editor"; | 52 | const LAST_EDITOR_KEY = "codething:last-editor"; |
| 53 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | ||
| 53 | 54 | ||
| 54 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 55 | function 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(); |
| 97 | 98 | ||
| 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 | } | ||
| 194 | 208 | ||
| 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; |
| 218 | 232 | ||
| 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; | ||
| 221 | 237 | ||
| 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]); |
| 284 | 306 | ||
| 307 | useEffect(() => { | ||
| 308 | setExpandedWorkGroups({}); | ||
| 309 | }, [activeThread?.id]); | ||
| 310 | |||
| 285 | // Auto-resize textarea | 311 | // 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 | <ChatMarkdown | 852 | <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 | }); |
| 136 | 136 | ||
| 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 | ); |
| 156 | 156 | ||
| 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 | }); |
| 159 | 190 | ||
| 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 | ); |
| 231 | 262 | ||
| 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 | }); |
| 238 | 303 | ||
| 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 | }); |
| 259 | 324 | ||
| 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 | } |
| 137 | 137 | ||
| 138 | interface 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 | |||
| 138 | function extractDetail( | 148 | function 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 | } |
| 158 | 171 | ||
| 159 | function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null { | 172 | function 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 | } |
| 176 | 191 | ||
| 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 | } |
| 187 | 204 | ||
| ... | @@ -224,10 +241,7 @@ function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { | ... | @@ -224,10 +241,7 @@ function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { |
| 224 | }; | 241 | }; |
| 225 | } | 242 | } |
| 226 | 243 | ||
| 227 | function entryFromNotification( | 244 | function 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; | ||
| 253 | 263 | ||
| 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 | } |
| 267 | 272 | ||
| 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; |
| 272 | 274 | ||
| 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; |
| 309 | 311 | ||
| 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 | } |
| 316 | 350 | 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 | } |
| 324 | 368 | ||
| 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 | } |
| 330 | 374 | ||
| 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 | }); |
| 95 | 95 | ||
| 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 | }); |
| 107 | 107 | ||
| 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 { |
| 132 | function getEventThreadId(event: ProviderEvent): string | undefined { | 132 | function 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 | |||
| 146 | function 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 | } |
| 137 | 167 | ||
| 138 | function durationMs(startIso: string, endIso: string): number | undefined { | 168 | function 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; | ||
| 229 | 260 | ||
| 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.message | 275 | (event.kind === "error" && event.message |
| 248 | ? event.message | 276 | ? event.message |
| 249 | : t.error), | 277 | : t.error), |