Repository navigation
feat(web): add chat settings to compact the work log and hide reasoning #18993
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4763ac8
ebac9e8
e013ca7
a472a61
e851625
0a33370
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
| import { | ||
| applyTimelineDensity, | ||
| deriveMessagesTimelineRows, | ||
| type MessagesTimelineRow, | ||
| } from "./MessagesTimeline.logic"; | ||
|
|
||
| type WorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["groupedEntries"][number]; | ||
| const entry = (id: string, itemType: string) => | ||
| ({ | ||
| id, | ||
| label: id, | ||
| detail: id, | ||
| itemType, | ||
| tone: itemType === "reasoning" ? "thinking" : "tool", | ||
| createdAt: "2026-01-01T00:00:00Z", | ||
| }) as unknown as WorkEntry; | ||
| const work = ( | ||
| id: string, | ||
| entries: WorkEntry[], | ||
| isExpandedToolGroup = false, | ||
| ): MessagesTimelineRow => ({ | ||
| kind: "work", | ||
| id, | ||
| createdAt: "2026-01-01T00:00:00Z", | ||
| groupedEntries: entries, | ||
| isExpandedToolGroup, | ||
| }); | ||
| const assistant = (id: string, showAssistantMeta: boolean) => | ||
| ({ | ||
| kind: "message", | ||
| id, | ||
| createdAt: "2026-01-01T00:00:00Z", | ||
| message: { id, role: "assistant", runId: "run-1" }, | ||
| showAssistantMeta, | ||
| }) as unknown as MessagesTimelineRow; | ||
|
|
||
| describe("applyTimelineDensity", () => { | ||
| const rows = [ | ||
| work("w1", [entry("r1", "reasoning"), entry("c1", "command_execution")]), | ||
| assistant("m1", false), | ||
| work("w2", [entry("c2", "command_execution"), entry("c3", "command_execution")]), | ||
| assistant("m2", true), | ||
| ]; | ||
|
|
||
| it("is identity when both settings are off", () => { | ||
| expect(applyTimelineDensity(rows, { compactWorkLog: false, hideReasoning: false })).toBe(rows); | ||
| }); | ||
|
|
||
| it("hides reasoning and commentary, then compacts to one updating line", () => { | ||
| const result = applyTimelineDensity(rows, { compactWorkLog: true, hideReasoning: true }); | ||
| expect(result.map((row) => row.id)).toEqual(["w2", "m2"]); | ||
| const last = result[0] as Extract<MessagesTimelineRow, { kind: "work" }>; | ||
| expect(last.groupedEntries.map((e) => e.id)).toEqual(["c3"]); | ||
| expect(last.isExpandedToolGroup).toBe(false); | ||
| const entries = [entry("first", "command_execution"), entry("latest", "command_execution")]; | ||
| const derived = deriveMessagesTimelineRows({ | ||
| timelineEntries: entries.map((entry) => ({ | ||
| kind: "work", | ||
| id: entry.id, | ||
| createdAt: entry.createdAt, | ||
| entry, | ||
| })), | ||
| isWorking: false, | ||
| turnDiffSummaries: [], | ||
| supportsConversationRollback: false, | ||
| }); | ||
| expect( | ||
| applyTimelineDensity(derived, { compactWorkLog: true, hideReasoning: false })[0], | ||
| ).toMatchObject({ kind: "work-toggle", summary: "latest", groupedEntries: entries }); | ||
| }); | ||
|
|
||
| it("hideReasoning alone keeps tool rows and the final answer", () => { | ||
| const result = applyTimelineDensity(rows, { compactWorkLog: false, hideReasoning: true }); | ||
| expect(result.map((row) => row.id)).toEqual(["w1", "w2", "m2"]); | ||
| }); | ||
|
|
||
| it("hides live commentary as soon as the next step starts", () => { | ||
| const live = [ | ||
| work("w1", [entry("c1", "command_execution")]), | ||
| assistant("m1", false), | ||
| work("w2", [entry("c2", "command_execution")]), | ||
| ]; | ||
| const result = applyTimelineDensity(live, { compactWorkLog: true, hideReasoning: true }); | ||
| expect(result.map((row) => row.id)).toEqual(["w2"]); | ||
| const steer = { | ||
| kind: "message", | ||
| id: "steer", | ||
| createdAt: "2026-01-01T00:00:00Z", | ||
| message: { id: "steer", role: "user", inputIntent: "steer", runId: "run-1" }, | ||
| } as unknown as MessagesTimelineRow; | ||
| const steered = applyTimelineDensity([live[1]!, steer, live[2]!], { | ||
| compactWorkLog: true, | ||
| hideReasoning: true, | ||
| }); | ||
| expect(steered.map((row) => row.id)).toEqual(["steer", "w2"]); | ||
| }); | ||
|
|
||
| it("keeps a terminal answer whose meta moved below trailing tools", () => { | ||
| const settled = [ | ||
| assistant("m1", false), | ||
| work("w1", [entry("c1", "command_execution")]), | ||
| { | ||
| kind: "assistant-meta", | ||
| id: "assistant-meta:m1", | ||
| createdAt: "2026-01-01T00:00:00Z", | ||
| message: { id: "m1", role: "assistant", runId: "run-1" }, | ||
| } as unknown as MessagesTimelineRow, | ||
| ]; | ||
| const result = applyTimelineDensity(settled, { compactWorkLog: false, hideReasoning: true }); | ||
| expect(result.map((row) => row.id)).toEqual(["m1", "w1", "assistant-meta:m1"]); | ||
| }); | ||
|
|
||
| it("leaves a run the user expanded intact", () => { | ||
| const expanded = [ | ||
| work("w1", [entry("c1", "command_execution"), entry("c2", "command_execution")], true), | ||
| work("w2", [entry("c3", "command_execution")], true), | ||
| ]; | ||
| const result = applyTimelineDensity(expanded, { compactWorkLog: true, hideReasoning: false }); | ||
| expect(result).toEqual(expanded); | ||
| const live: MessagesTimelineRow = { | ||
| kind: "work-live", | ||
| id: "live", | ||
| createdAt: "2026-01-01T00:00:00Z", | ||
| entry: entry("reasoning", "reasoning"), | ||
| groupedEntries: [entry("tool", "command_execution"), entry("reasoning", "reasoning")], | ||
| groupId: "work-group:tool", | ||
| expanded: true, | ||
| active: true, | ||
| }; | ||
| expect( | ||
| applyTimelineDensity([live, ...expanded], { compactWorkLog: true, hideReasoning: true })[0], | ||
| ).toMatchObject({ | ||
| kind: "thinking", | ||
| groupId: "work-group:tool", | ||
| expanded: true, | ||
| }); | ||
| }); | ||
| it("keeps a streaming answer before the synthetic thinking indicator", () => { | ||
| const result = applyTimelineDensity( | ||
| [ | ||
| assistant("streaming-answer", false), | ||
| { kind: "thinking", id: "live-activity", createdAt: null }, | ||
| ], | ||
| { compactWorkLog: false, hideReasoning: true }, | ||
| ); | ||
| expect(result.map((row) => row.id)).toContain("streaming-answer"); | ||
| }); | ||
|
|
||
| it("keeps answered questions as compact work boundaries", () => { | ||
| const question = { | ||
| ...entry("question", "user_input_request"), | ||
| questionAnswer: {}, | ||
| } as WorkEntry; | ||
| const result = applyTimelineDensity( | ||
| [work("question", [question]), work("tool", [entry("command", "command_execution")])], | ||
| { compactWorkLog: true, hideReasoning: false }, | ||
| ); | ||
| expect(result.map((row) => row.id)).toEqual(["question", "tool"]); | ||
| }); | ||
| it("removes reasoning-only disclosures and preserves mixed group identity", () => { | ||
| const reasoning = entry("reasoning", "reasoning"); | ||
| const tools = [entry("read", "command_execution"), entry("test", "command_execution")]; | ||
| const derive = (entries: WorkEntry[]) => | ||
| deriveMessagesTimelineRows({ | ||
| timelineEntries: entries.map((entry) => ({ | ||
| kind: "work", | ||
| id: entry.id, | ||
| createdAt: entry.createdAt, | ||
| entry, | ||
| })), | ||
| isWorking: false, | ||
| turnDiffSummaries: [], | ||
| supportsConversationRollback: false, | ||
| expandedWorkGroupIds: new Set(["work-group:reasoning"]), | ||
| }); | ||
| const options = { compactWorkLog: false, hideReasoning: true }; | ||
| expect(applyTimelineDensity(derive([reasoning]), options)).toEqual([]); | ||
| const mixed = applyTimelineDensity(derive([reasoning, ...tools]), options); | ||
| expect(mixed.find((row) => row.kind === "work-toggle")).toMatchObject({ | ||
| groupId: "work-group:reasoning", | ||
| expanded: true, | ||
| hiddenCount: 2, | ||
| }); | ||
| expect(mixed.find((row) => row.kind === "work")).toMatchObject({ groupedEntries: tools }); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -540,6 +540,7 @@ type MessagesTimelineRowContent = | |
| } | ||
| | { | ||
| kind: "work-toggle"; | ||
| groupedEntries: WorkLogEntry[]; | ||
| id: string; | ||
| createdAt: string; | ||
| runId?: RunId | null; | ||
|
|
@@ -1911,6 +1912,7 @@ export function deriveMessagesTimelineRows(input: { | |
| visibleGroupedEntries.length === 1 ? (visibleGroupedEntries[0] ?? null) : null; | ||
| nextRows.push({ | ||
| kind: "work-toggle", | ||
| groupedEntries: visibleGroupedEntries, | ||
| id: `work-toggle:${timelineEntry.id}`, | ||
| createdAt: timelineEntry.createdAt, | ||
| runId: timelineEntry.entry.runId ?? null, | ||
|
|
@@ -2488,6 +2490,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean | |
| a.createdAt === bw.createdAt && | ||
| a.runId === bw.runId && | ||
| a.groupId === bw.groupId && | ||
| Equal.equals(a.groupedEntries, bw.groupedEntries) && | ||
| a.hiddenCount === bw.hiddenCount && | ||
| a.expanded === bw.expanded && | ||
| a.summary === bw.summary && | ||
|
|
@@ -2567,3 +2570,157 @@ export function sameRowsInput( | |
| } | ||
| return Object.keys(left).length === Object.keys(right).length; | ||
| } | ||
|
|
||
| const isWorkLogTimelineRow = (row: MessagesTimelineRow) => | ||
| (row.kind === "work" && | ||
| row.groupedEntries.every( | ||
| (entry) => | ||
| (workLogEntryIsToolLike(entry) || entry.itemType === "reasoning") && | ||
| entry.tone !== "error" && | ||
| entry.questionAnswer === undefined, | ||
| )) || | ||
| row.kind === "work-live" || | ||
| row.kind === "work-toggle" || | ||
| row.kind === "thinking"; | ||
|
|
||
| const workLogRowIsExpanded = (row: MessagesTimelineRow) => | ||
| row.kind === "work" | ||
| ? row.isExpandedToolGroup | ||
| : (row.kind === "work-live" || row.kind === "work-toggle" || row.kind === "thinking") && | ||
| row.expanded === true; | ||
|
|
||
| /** | ||
| * Display-only pass over derived rows for the `hideReasoning` and | ||
| * `compactWorkLog` client settings. Runs after derivation so the incremental | ||
| * projection state stays untouched. | ||
| */ | ||
| export function applyTimelineDensity( | ||
| rows: MessagesTimelineRow[], | ||
| options: { compactWorkLog: boolean; hideReasoning: boolean }, | ||
| ): MessagesTimelineRow[] { | ||
| if (!options.compactWorkLog && !options.hideReasoning) return rows; | ||
| let result = rows; | ||
|
|
||
| if (options.hideReasoning) { | ||
| // Commentary = a non-terminal assistant message that is followed, within | ||
| // the same turn, by more work or another assistant message. The trailing | ||
| // message (final answer, or the commentary still streaming) stays visible. | ||
| // Terminal answers whose meta moved below trailing tools are still terminal. | ||
| const relocatedMetaMessageIds = new Set( | ||
| result.flatMap((row) => (row.kind === "assistant-meta" ? [row.message.id] : [])), | ||
| ); | ||
| const hiddenCommentaryIds = new Set<string>(); | ||
| let laterActivityInTurn = false; | ||
| for (let index = result.length - 1; index >= 0; index -= 1) { | ||
| const row = result[index]!; | ||
| if (row.kind === "message" && row.message.role === "user") { | ||
| if (!messageIsSteer(row.message)) laterActivityInTurn = false; | ||
| continue; | ||
| } | ||
| if (row.kind === "message" && row.message.role === "assistant") { | ||
| const isTerminal = row.showAssistantMeta || relocatedMetaMessageIds.has(row.message.id); | ||
| if (!isTerminal && laterActivityInTurn) hiddenCommentaryIds.add(row.id); | ||
| laterActivityInTurn = true; | ||
| } else if (isWorkLogTimelineRow(row) && row.kind !== "thinking") { | ||
| laterActivityInTurn = true; | ||
| } | ||
| } | ||
| result = result.flatMap((row): MessagesTimelineRow[] => { | ||
| if (hiddenCommentaryIds.has(row.id)) return []; | ||
| if (row.kind === "work") { | ||
| const groupedEntries = row.groupedEntries.filter((entry) => entry.itemType !== "reasoning"); | ||
| if (groupedEntries.length === 0) return []; | ||
| return groupedEntries.length === row.groupedEntries.length | ||
| ? [row] | ||
| : [{ ...row, groupedEntries }]; | ||
| } | ||
| if (row.kind === "work-toggle") { | ||
| const groupedEntries = row.groupedEntries.filter((entry) => entry.itemType !== "reasoning"); | ||
| if (groupedEntries.length === 0) return []; | ||
| if (groupedEntries.length === row.groupedEntries.length) return [row]; | ||
| return [ | ||
| { | ||
| ...row, | ||
| groupedEntries, | ||
| hiddenCount: groupedEntries.length, | ||
| summary: summarizeToolGroup(groupedEntries).summary, | ||
| summaryKind: toolGroupSummaryKind(groupedEntries), | ||
| }, | ||
| ]; | ||
| } | ||
| if (row.kind === "work-live") { | ||
| const groupedEntries = row.groupedEntries.filter((entry) => entry.itemType !== "reasoning"); | ||
| if (row.entry.itemType === "reasoning") { | ||
| return row.active | ||
| ? [ | ||
| { | ||
| kind: "thinking", | ||
| id: row.id, | ||
| createdAt: row.createdAt, | ||
| ...(groupedEntries.length > 0 | ||
| ? { groupId: row.groupId, expanded: row.expanded } | ||
| : {}), | ||
| }, | ||
| ] | ||
| : []; | ||
| } | ||
| if (row.thought === undefined && groupedEntries.length === row.groupedEntries.length) | ||
| return [row]; | ||
| const { thought: _thought, ...rest } = row; | ||
| return [{ ...rest, groupedEntries }]; | ||
| } | ||
| return [row]; | ||
| }); | ||
| } | ||
|
|
||
| if (options.compactWorkLog) { | ||
| // Each run of consecutive work-log rows collapses to its last row, and a | ||
| // work group to its last entry, so the line updates instead of growing. | ||
| // A run the user expanded stays intact so its disclosure still works. | ||
| const expandedRunRowIds = new Set<string>(); | ||
| let run: MessagesTimelineRow[] = []; | ||
| for (const row of [...result, null]) { | ||
| if (row !== null && isWorkLogTimelineRow(row)) { | ||
| run.push(row); | ||
| continue; | ||
| } | ||
| if (run.some(workLogRowIsExpanded)) | ||
| for (const runRow of run) expandedRunRowIds.add(runRow.id); | ||
| run = []; | ||
| } | ||
| result = result.flatMap((row, index): MessagesTimelineRow[] => { | ||
| if (!isWorkLogTimelineRow(row) || expandedRunRowIds.has(row.id)) return [row]; | ||
| const next = result[index + 1]; | ||
| if (next !== undefined && isWorkLogTimelineRow(next)) return []; | ||
| const compactRow: MessagesTimelineRow = { ...row, continuesWorkLog: false }; | ||
| if (compactRow.kind === "work-toggle") { | ||
| const latest = compactRow.groupedEntries.at(-1); | ||
| if (latest === undefined) return [compactRow]; | ||
| return [ | ||
| { | ||
| ...compactRow, | ||
| summary: | ||
| toolGroupAction(latest) === "edit" | ||
| ? summarizeToolGroup([latest]).summary | ||
| : singleToolCallLabel(latest), | ||
| summaryKind: toolGroupSummaryKind([latest]), | ||
| toolSurface: latest.toolSurface, | ||
| toolIcon: latest.toolIcon ?? latest.toolSource?.icon, | ||
| }, | ||
| ]; | ||
| } | ||
| if (compactRow.kind === "work") { | ||
| return [ | ||
| { | ||
|
Comment on lines
+2693
to
+2714
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ...compactRow, | ||
| groupedEntries: compactRow.groupedEntries.slice(-1), | ||
| isExpandedToolGroup: false, | ||
| }, | ||
| ]; | ||
| } | ||
| return [compactRow]; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| }); | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.