| 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 | 7 | - [ ] Selecting folder should instantly create project |
| 8 | 8 | - [ ] New projects should go on top |
| 9 | 9 | - [ ] 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 { |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | 52 | const LAST_EDITOR_KEY = "codething:last-editor"; |
| 53 | const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; | |
| 53 | 54 | |
| 54 | 55 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 55 | 56 | if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; |
| ... | ... | @@ -93,7 +94,7 @@ function derivePendingApprovals( |
| 93 | 94 | events: ProviderEvent[], |
| 94 | 95 | ): PendingApprovalCard[] { |
| 95 | 96 | const pending = new Map<string, PendingApprovalCard>(); |
| 96 | const ordered = [...events].reverse(); | |
| 97 | const ordered = [...events].toReversed(); | |
| 97 | 98 | |
| 98 | 99 | for (const event of ordered) { |
| 99 | 100 | if ( |
| ... | ... | @@ -150,6 +151,9 @@ export default function ChatView() { |
| 150 | 151 | const [respondingRequestIds, setRespondingRequestIds] = useState<string[]>( |
| 151 | 152 | [], |
| 152 | 153 | ); |
| 154 | const [expandedWorkGroups, setExpandedWorkGroups] = useState< | |
| 155 | Record<string, boolean> | |
| 156 | >({}); | |
| 153 | 157 | const [nowTick, setNowTick] = useState(() => Date.now()); |
| 154 | 158 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 155 | 159 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| ... | ... | @@ -169,6 +173,14 @@ export default function ChatView() { |
| 169 | 173 | () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), |
| 170 | 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 | 184 | const pendingApprovals = useMemo( |
| 173 | 185 | () => derivePendingApprovals(activeThread?.events ?? []), |
| 174 | 186 | [activeThread?.events], |
| ... | ... | @@ -190,7 +202,9 @@ export default function ChatView() { |
| 190 | 202 | const completionSummary = useMemo(() => { |
| 191 | 203 | if (!activeThread?.latestTurnStartedAt) return null; |
| 192 | 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 | 209 | if ( |
| 196 | 210 | typeof activeThread.latestTurnDurationMs === "number" && |
| ... | ... | @@ -209,28 +223,36 @@ export default function ChatView() { |
| 209 | 223 | activeThread?.latestTurnStartedAt, |
| 210 | 224 | activeThread?.latestTurnCompletedAt, |
| 211 | 225 | activeThread?.latestTurnDurationMs, |
| 212 | workLogEntries.length, | |
| 226 | latestTurnWorkEntries, | |
| 213 | 227 | ]); |
| 214 | 228 | const completionDividerBeforeEntryId = useMemo(() => { |
| 215 | 229 | if (!activeThread?.latestTurnStartedAt) return null; |
| 216 | 230 | if (!activeThread.latestTurnCompletedAt) return null; |
| 217 | if (workLogEntries.length === 0) return null; | |
| 231 | if (!completionSummary) return null; | |
| 218 | 232 | |
| 219 | 233 | const turnStartedAt = Date.parse(activeThread.latestTurnStartedAt); |
| 234 | const turnCompletedAt = Date.parse(activeThread.latestTurnCompletedAt); | |
| 220 | 235 | if (Number.isNaN(turnStartedAt)) return null; |
| 236 | if (Number.isNaN(turnCompletedAt)) return null; | |
| 221 | 237 | |
| 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; | |
| 225 | 243 | 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; | |
| 229 | 251 | }, [ |
| 230 | activeThread?.latestTurnStartedAt, | |
| 231 | 252 | activeThread?.latestTurnCompletedAt, |
| 253 | activeThread?.latestTurnStartedAt, | |
| 254 | completionSummary, | |
| 232 | 255 | timelineEntries, |
| 233 | workLogEntries.length, | |
| 234 | 256 | ]); |
| 235 | 257 | const runtimeSessionConfig = |
| 236 | 258 | state.runtimeMode === "full-access" |
| ... | ... | @@ -282,6 +304,10 @@ export default function ChatView() { |
| 282 | 304 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
| 283 | 305 | }, [phase, workLogCount]); |
| 284 | 306 | |
| 307 | useEffect(() => { | |
| 308 | setExpandedWorkGroups({}); | |
| 309 | }, [activeThread?.id]); | |
| 310 | ||
| 285 | 311 | // Auto-resize textarea |
| 286 | 312 | useEffect(() => { |
| 287 | 313 | const ta = textareaRef.current; |
| ... | ... | @@ -695,12 +721,123 @@ export default function ChatView() { |
| 695 | 721 | </div> |
| 696 | 722 | ) : ( |
| 697 | 723 | <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 && ( | |
| 704 | 841 | <div className="my-3 flex items-center gap-3"> |
| 705 | 842 | <span className="h-px flex-1 bg-border" /> |
| 706 | 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 | 848 | <span className="h-px flex-1 bg-border" /> |
| 712 | 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 | 851 | <div className="px-1 py-0.5"> |
| 748 | 852 | <ChatMarkdown |
| 749 | 853 | text={ |
| ... | ... | @@ -753,18 +857,6 @@ export default function ChatView() { |
| 753 | 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 | 860 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> |
| 769 | 861 | {formatMessageMeta( |
| 770 | 862 | timelineEntry.message.createdAt, |
| ... | ... | @@ -782,9 +874,9 @@ export default function ChatView() { |
| 782 | 874 | )} |
| 783 | 875 | </p> |
| 784 | 876 | </div> |
| 785 | )} | |
| 786 | </Fragment> | |
| 787 | ))} | |
| 877 | </Fragment> | |
| 878 | ); | |
| 879 | })} | |
| 788 | 880 | {isWorking && ( |
| 789 | 881 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> |
| 790 | 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 | 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 | 138 | const entries = deriveWorkLogEntries( |
| 139 | 139 | [ |
| 140 | 140 | makeEvent({ |
| ... | ... | @@ -154,7 +154,38 @@ describe("deriveWorkLogEntries", () => { |
| 154 | 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 | 191 | it("hides reasoning and agent-message noise from the visible work log", () => { |
| ... | ... | @@ -229,11 +260,45 @@ describe("deriveWorkLogEntries", () => { |
| 229 | 260 | "turn-1", |
| 230 | 261 | ); |
| 231 | 262 | |
| 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"); | |
| 237 | 302 | }); |
| 238 | 303 | |
| 239 | 304 | it("preserves full tool-call detail text without data truncation", () => { |
| ... | ... | @@ -257,6 +322,50 @@ describe("deriveWorkLogEntries", () => { |
| 257 | 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 | 369 | it("can derive work-log entries across all turns when not scoped", () => { |
| 261 | 370 | const entries = deriveWorkLogEntries( |
| 262 | 371 | [ |
apps/renderer/src/session-logic.ts+79-35| ... | ... | @@ -135,15 +135,28 @@ function itemTypeMeta(type: string): { |
| 135 | 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 | 148 | function extractDetail( |
| 139 | 149 | payload: Record<string, unknown> | undefined, |
| 140 | 150 | item: Record<string, unknown> | undefined, |
| 141 | 151 | ): string | undefined { |
| 142 | 152 | const candidates = [ |
| 153 | asString(item?.command), | |
| 154 | asString(item?.tool), | |
| 155 | asString(item?.name), | |
| 143 | 156 | asString(item?.title), |
| 144 | 157 | asString(item?.summary), |
| 145 | 158 | asString(item?.text), |
| 146 | asString(item?.command), | |
| 159 | asString(item?.prompt), | |
| 147 | 160 | asString(payload?.message), |
| 148 | 161 | asString(payload?.prompt), |
| 149 | 162 | asString(payload?.command), |
| ... | ... | @@ -156,7 +169,9 @@ function extractDetail( |
| 156 | 169 | return undefined; |
| 157 | 170 | } |
| 158 | 171 | |
| 159 | function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null { | |
| 172 | function lifecycleCandidateFromItemEvent( | |
| 173 | event: ProviderEvent, | |
| 174 | ): ItemLifecycleCandidate | null { | |
| 160 | 175 | const payload = asObject(event.payload); |
| 161 | 176 | const item = asObject(payload?.item); |
| 162 | 177 | const normalizedType = normalizeItemType(asString(item?.type)); |
| ... | ... | @@ -174,14 +189,16 @@ function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null { |
| 174 | 189 | return null; |
| 175 | 190 | } |
| 176 | 191 | |
| 177 | const label = isCompleted ? `${meta.label} complete` : meta.label; | |
| 178 | 192 | const detail = extractDetail(payload, item); |
| 193 | const itemId = event.itemId ?? asString(item?.id); | |
| 179 | 194 | return { |
| 180 | 195 | id: event.id, |
| 196 | ...(itemId ? { itemId } : {}), | |
| 181 | 197 | createdAt: event.createdAt, |
| 182 | label, | |
| 198 | label: meta.label, | |
| 183 | 199 | ...(detail ? { detail } : {}), |
| 184 | 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 | 241 | }; |
| 225 | 242 | } |
| 226 | 243 | |
| 227 | function entryFromNotification( | |
| 228 | event: ProviderEvent, | |
| 229 | turnStartedAt: number, | |
| 230 | ): WorkLogEntry | null { | |
| 244 | function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { | |
| 231 | 245 | if (event.kind !== "notification") return null; |
| 232 | 246 | if (shouldDropMethod(event.method)) return null; |
| 233 | 247 | if (event.method === "item/agentMessage/delta") return null; |
| ... | ... | @@ -240,34 +254,22 @@ function entryFromNotification( |
| 240 | 254 | const payload = asObject(event.payload); |
| 241 | 255 | const turn = asObject(payload?.turn); |
| 242 | 256 | const status = asString(turn?.status); |
| 257 | if (status !== "failed") { | |
| 258 | return null; | |
| 259 | } | |
| 243 | 260 | const turnError = asObject(turn?.error); |
| 244 | 261 | const turnErrorMessage = asString(turnError?.message); |
| 245 | 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 | 264 | return { |
| 255 | 265 | id: event.id, |
| 256 | 266 | 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", | |
| 263 | 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 | 273 | if (event.method.startsWith("item/")) return null; |
| 272 | 274 | |
| 273 | 275 | return null; |
| ... | ... | @@ -307,19 +309,61 @@ export function deriveWorkLogEntries( |
| 307 | 309 | : undefined; |
| 308 | 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 | 330 | 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 | ) { | |
| 314 | 348 | continue; |
| 315 | 349 | } |
| 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; | |
| 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 | 369 | const fromRequest = entryFromRequest(event); |
| ... | ... | @@ -328,7 +372,7 @@ export function deriveWorkLogEntries( |
| 328 | 372 | continue; |
| 329 | 373 | } |
| 330 | 374 | |
| 331 | const fromNotification = entryFromNotification(event, turnStartedAt); | |
| 375 | const fromNotification = entryFromNotification(event); | |
| 332 | 376 | if (fromNotification) { |
| 333 | 377 | entries.push(fromNotification); |
| 334 | 378 | continue; |
apps/renderer/src/store.test.ts+26-3| ... | ... | @@ -93,7 +93,7 @@ describe("store reducer thread continuity", () => { |
| 93 | 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 | 97 | const state = makeState(makeThread({ codexThreadId: "thr_expected" })); |
| 98 | 98 | const next = reducer(state, { |
| 99 | 99 | type: "APPLY_EVENT", |
| ... | ... | @@ -105,7 +105,30 @@ describe("store reducer thread continuity", () => { |
| 105 | 105 | activeAssistantItemRef: { current: null }, |
| 106 | 106 | }); |
| 107 | 107 | |
| 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"); | |
| 110 | 133 | }); |
| 111 | 134 | }); |
apps/renderer/src/store.ts+38-10| ... | ... | @@ -132,7 +132,37 @@ function getEventTurnId(event: ProviderEvent): string | undefined { |
| 132 | 132 | function getEventThreadId(event: ProviderEvent): string | undefined { |
| 133 | 133 | if (event.threadId) return event.threadId; |
| 134 | 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 | 168 | function durationMs(startIso: string, endIso: string): number | undefined { |
| ... | ... | @@ -226,6 +256,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 226 | 256 | const { event, activeAssistantItemRef } = action; |
| 227 | 257 | const target = findThreadBySessionId(state.threads, event.sessionId); |
| 228 | 258 | if (!target) return state; |
| 259 | if (shouldIgnoreForeignThreadEvent(target, event)) return state; | |
| 229 | 260 | |
| 230 | 261 | return { |
| 231 | 262 | ...state, |
| ... | ... | @@ -233,17 +264,14 @@ export function reducer(state: AppState, action: Action): AppState { |
| 233 | 264 | ...t, |
| 234 | 265 | ...(() => { |
| 235 | 266 | 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"; | |
| 243 | 270 | return { |
| 244 | codexThreadId: t.codexThreadId ?? eventThreadId ?? null, | |
| 271 | codexThreadId: shouldRebindIdentity | |
| 272 | ? eventThreadId ?? t.codexThreadId | |
| 273 | : t.codexThreadId ?? eventThreadId ?? null, | |
| 245 | 274 | error: |
| 246 | threadMismatchError ?? | |
| 247 | 275 | (event.kind === "error" && event.message |
| 248 | 276 | ? event.message |
| 249 | 277 | : t.error), |