authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 17:21:26-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 17:21:26-04:00
log46b5c66406b9942589d7e9132beeafda2434f113
tree275558d8190760e990418f3a6f391414df930e69
parent62c68dc41f9d9d7ecaf5193a6a8d997ebf671a61
signature Signed by PGP key B5690EEEBB952194

fix(chat): show single tool calls without summaries (#9267)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

5 files changed, 104 insertions(+), 38 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+27-4
...@@ -870,6 +870,7 @@ describe("buildThreadFeed", () => {...@@ -870,6 +870,7 @@ describe("buildThreadFeed", () => {
870 status: undefined,870 status: undefined,
871 displayName: "Click in the preview browser",871 displayName: "Click in the preview browser",
872 liveDisplayName: "Clicking in the preview browser",872 liveDisplayName: "Clicking in the preview browser",
873 settledDisplayName: "Clicked in the preview browser",
873 icon: "browser",874 icon: "browser",
874 },875 },
875 {876 {
...@@ -880,11 +881,12 @@ describe("buildThreadFeed", () => {...@@ -880,11 +881,12 @@ describe("buildThreadFeed", () => {
880 status: undefined,881 status: undefined,
881 displayName: "Get delegated task status",882 displayName: "Get delegated task status",
882 liveDisplayName: "Getting delegated task status",883 liveDisplayName: "Getting delegated task status",
884 settledDisplayName: "Got delegated task status",
883 icon: "t3-code",885 icon: "t3-code",
884 },886 },
885 ])(887 ])(
886 "uses friendly row and running labels from $source",888 "uses friendly row and running labels from $source",
887 ({ label, title, item, status, displayName, liveDisplayName, icon }) => {889 ({ label, title, item, status, displayName, liveDisplayName, settledDisplayName, icon }) => {
888 const turnId = TurnId.make("turn-friendly-mcp");890 const turnId = TurnId.make("turn-friendly-mcp");
889 const rawCommand = "node mcp-call.js";891 const rawCommand = "node mcp-call.js";
890 const rawDetail = '{"provider":"raw MCP output"}';892 const rawDetail = '{"provider":"raw MCP output"}';
...@@ -949,6 +951,23 @@ describe("buildThreadFeed", () => {...@@ -949,6 +951,23 @@ describe("buildThreadFeed", () => {
949 live: true,951 live: true,
950 },952 },
951 ]);953 ]);
954 if (settledDisplayName) {
955 const settledRows = deriveThreadFeedPresentation(
956 feed,
957 {
958 ...thread.latestTurn!,
959 state: "completed",
960 completedAt: "2026-04-01T00:00:03.000Z",
961 },
962 new Set([turnId]),
963 new Set(),
964 );
965 expect(settledRows.find((entry) => entry.type === "work-toggle")).toMatchObject({
966 summary: settledDisplayName,
967 summaryToolIcon: icon,
968 live: false,
969 });
970 }
952 },971 },
953 );972 );
954973
...@@ -1041,7 +1060,7 @@ describe("buildThreadFeed", () => {...@@ -1041,7 +1060,7 @@ describe("buildThreadFeed", () => {
1041 hasFailure: true,1060 hasFailure: true,
1042 },1061 },
1043 ])(1062 ])(
1044 "keeps a browser group expanded as its action changes from active to $status",1063 "uses the browser call label once its action settles as $status",
1045 ({ status, displayName, detail, hasFailure }) => {1064 ({ status, displayName, detail, hasFailure }) => {
1046 const turnId = TurnId.make("turn-preview-lifecycle");1065 const turnId = TurnId.make("turn-preview-lifecycle");
1047 const toolCallId = "preview-click";1066 const toolCallId = "preview-click";
...@@ -1176,7 +1195,7 @@ describe("buildThreadFeed", () => {...@@ -1176,7 +1195,7 @@ describe("buildThreadFeed", () => {
1176 groupId,1195 groupId,
1177 hiddenCount: 1,1196 hiddenCount: 1,
1178 expanded: true,1197 expanded: true,
1179 summary: "Used browser 1 time",1198 summary: displayName,
1180 summaryKind: "browser",1199 summaryKind: "browser",
1181 hasFailure,1200 hasFailure,
1182 live: false,1201 live: false,
...@@ -1762,7 +1781,11 @@ describe("buildThreadFeed", () => {...@@ -1762,7 +1781,11 @@ describe("buildThreadFeed", () => {
1762 const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set());1781 const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set());
1763 expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([1782 expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([
1764 { live: false, shimmer: false },1783 { live: false, shimmer: false },
1765 { live: false, shimmer: false },1784 {
1785 live: false,
1786 shimmer: false,
1787 summary: lifecycleStatus === "inProgress" ? "printf done" : command,
1788 },
1766 ]);1789 ]);
17671790
1768 const completedRows = deriveThreadFeedPresentation(1791 const completedRows = deriveThreadFeedPresentation(
apps/mobile/src/lib/threadActivity.ts+21-4
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21 omitSupersededLifecycleMarkers,21 omitSupersededLifecycleMarkers,
22 resolveWorkEntryToolPresentation,22 resolveWorkEntryToolPresentation,
23 summarizeToolGroup,23 summarizeToolGroup,
24 toolGroupAction,
24 toolGroupSummaryKind,25 toolGroupSummaryKind,
25 type ToolGroupSummaryKind,26 type ToolGroupSummaryKind,
26} from "@t3tools/client-runtime/work-log/presentation";27} from "@t3tools/client-runtime/work-log/presentation";
...@@ -837,6 +838,13 @@ function workEntryHeading(workEntry: WorkLogEntry): string {...@@ -837,6 +838,13 @@ function workEntryHeading(workEntry: WorkLogEntry): string {
837 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));838 return capitalizePhrase(normalizeCompactToolLabel(workEntry.toolTitle));
838}839}
839840
841function singleToolCallLabel(activity: ThreadFeedActivity): string {
842 const presentation = resolveWorkEntryToolPresentation(activity.workEntry, "completed");
843 if (presentation) return presentation.displayName;
844 const command = activity.workEntry.command?.trim();
845 return command || activity.summary;
846}
847
840function asRecord(value: unknown): Record<string, unknown> | null {848function asRecord(value: unknown): Record<string, unknown> | null {
841 return value && typeof value === "object" ? (value as Record<string, unknown>) : null;849 return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
842}850}
...@@ -1518,14 +1526,23 @@ function appendToolGroupRows(...@@ -1518,14 +1526,23 @@ function appendToolGroupRows(
1518 const active = latestActiveActivity !== undefined;1526 const active = latestActiveActivity !== undefined;
1519 const live = activeTail || active;1527 const live = activeTail || active;
1520 const latestActivity = latestActiveActivity ?? activities.at(-1)!;1528 const latestActivity = latestActiveActivity ?? activities.at(-1)!;
1529 const singleActivity = activities.length === 1 ? latestActivity : null;
1521 const summary = live1530 const summary = live
1522 ? liveToolActivitySummary(latestActivity, active)1531 ? liveToolActivitySummary(latestActivity, active)
1523 : activities.length === 1 && !activities[0]!.toolLike1532 : singleActivity !== null &&
1524 ? activities[0]!.workEntry.label1533 singleActivity.toolLike &&
1525 : summarizeToolGroup(activities.map((activity) => activity.workEntry));1534 toolGroupAction(singleActivity.workEntry) !== "edit"
1535 ? singleToolCallLabel(singleActivity)
1536 : singleActivity !== null && !singleActivity.toolLike
1537 ? singleActivity.workEntry.label
1538 : summarizeToolGroup(activities.map((activity) => activity.workEntry));
1526 const summaryToolIcon = live1539 const summaryToolIcon = live
1527 ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon1540 ? resolveWorkEntryToolPresentation(latestActivity.workEntry)?.icon
1528 : undefined;1541 : singleActivity !== null &&
1542 singleActivity.toolLike &&
1543 toolGroupAction(singleActivity.workEntry) !== "edit"
1544 ? resolveWorkEntryToolPresentation(singleActivity.workEntry, "completed")?.icon
1545 : undefined;
1529 result.push({1546 result.push({
1530 type: "work-toggle",1547 type: "work-toggle",
1531 id: `${live ? "work-live" : "work-toggle"}:${groupId}`,1548 id: `${live ? "work-live" : "work-toggle"}:${groupId}`,
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+30-25
...@@ -152,30 +152,35 @@ describe("work entry labels", () => {...@@ -152,30 +152,35 @@ describe("work entry labels", () => {
152 },152 },
153 );153 );
154154
155 it("gives a completed browser group its own count and summary icon category", () => {155 it.each([
156 const rows = deriveMessagesTimelineRows({156 ["preview_click", "Clicked in the preview browser", "browser"],
157 timelineEntries: [157 ["task_status", "Got delegated task status", "t3-code"],
158 {158 ] as const)(
159 id: "browser-entry",159 "uses the completed %s call presentation for a settled legacy tool",
160 kind: "work",160 (tool, summary, summaryToolIcon) => {
161 createdAt: entry.createdAt,161 const rows = deriveMessagesTimelineRows({
162 entry: {162 timelineEntries: [
163 ...entry,163 {
164 itemType: "mcp_tool_call",164 id: "browser-entry",
165 toolLifecycleStatus: "completed",165 kind: "work",
166 toolData: { server: "t3-code", tool: "preview_click" },166 createdAt: entry.createdAt,
167 entry: {
168 ...entry,
169 itemType: "mcp_tool_call",
170 toolData: { server: "t3-code", tool },
171 },
167 },172 },
168 },173 ],
169 ],174 isWorking: false,
170 isWorking: false,175 activeTurnStartedAt: null,
171 activeTurnStartedAt: null,176 turnDiffSummaryByAssistantMessageId: new Map(),
172 turnDiffSummaryByAssistantMessageId: new Map(),177 revertTurnCountByUserMessageId: new Map(),
173 revertTurnCountByUserMessageId: new Map(),178 });
174 });179 expect(rows).toMatchObject([
175 expect(rows).toMatchObject([180 { kind: "work-toggle", hiddenCount: 1, summary, summaryToolIcon },
176 { kind: "work-toggle", summary: "Used browser 1 time", summaryKind: "browser" },181 ]);
177 ]);182 },
178 });183 );
179});184});
180185
181describe("shouldPreserveAssistantLineBreaks", () => {186describe("shouldPreserveAssistantLineBreaks", () => {
...@@ -1126,7 +1131,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1126,7 +1131,7 @@ describe("deriveMessagesTimelineRows", () => {
1126 });1131 });
1127 });1132 });
11281133
1129 it("summarizes a tool run after commentary starts a new run", () => {1134 it("labels a single completed tool call without summarizing it", () => {
1130 const rows = deriveMessagesTimelineRows({1135 const rows = deriveMessagesTimelineRows({
1131 timelineEntries: [1136 timelineEntries: [
1132 {1137 {
...@@ -1189,7 +1194,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1189,7 +1194,7 @@ describe("deriveMessagesTimelineRows", () => {
1189 expect(rows.map((row) => row.kind)).toEqual(["working", "work-toggle", "message", "work-live"]);1194 expect(rows.map((row) => row.kind)).toEqual(["working", "work-toggle", "message", "work-live"]);
1190 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({1195 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
1191 hiddenCount: 1,1196 hiddenCount: 1,
1192 summary: "Ran 1 command",1197 summary: "rg toolCall",
1193 });1198 });
1194 });1199 });
11951200
apps/web/src/components/chat/MessagesTimeline.logic.ts+25-4
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 omitSupersededLifecycleMarkers,6 omitSupersededLifecycleMarkers,
7 resolveWorkEntryToolPresentation,7 resolveWorkEntryToolPresentation,
8 summarizeToolGroup,8 summarizeToolGroup,
9 toolGroupAction,
9 toolGroupSummaryKind,10 toolGroupSummaryKind,
10 type ToolGroupSummaryKind,11 type ToolGroupSummaryKind,
11} from "@t3tools/client-runtime/work-log/presentation";12} from "@t3tools/client-runtime/work-log/presentation";
...@@ -34,6 +35,15 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";...@@ -34,6 +35,15 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
34export const TIMELINE_CONTENT_MAX_WIDTH = 768;35export const TIMELINE_CONTENT_MAX_WIDTH = 768;
35export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;36export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;
3637
38function singleToolCallLabel(entry: WorkLogEntry): string {
39 const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed");
40 if (toolPresentation) return toolPresentation.displayName;
41 const command = entry.command?.trim();
42 if (command) return command;
43 const heading = normalizeCompactToolLabel(entry.toolTitle || entry.label);
44 return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`;
45}
46
37export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string | undefined) {47export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string | undefined) {
38 const toolPresentation = resolveWorkEntryToolPresentation(entry);48 const toolPresentation = resolveWorkEntryToolPresentation(entry);
39 if (toolPresentation) return toolPresentation.displayName;49 if (toolPresentation) return toolPresentation.displayName;
...@@ -295,6 +305,7 @@ export type MessagesTimelineRow =...@@ -295,6 +305,7 @@ export type MessagesTimelineRow =
295 expanded: boolean;305 expanded: boolean;
296 summary: string;306 summary: string;
297 summaryKind: ToolGroupSummaryKind;307 summaryKind: ToolGroupSummaryKind;
308 summaryToolIcon?: "browser" | "t3-code";
298 hasFailure: boolean;309 hasFailure: boolean;
299 }310 }
300 | {311 | {
...@@ -840,6 +851,15 @@ export function deriveMessagesTimelineRows(input: {...@@ -840,6 +851,15 @@ export function deriveMessagesTimelineRows(input: {
840 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;851 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
841 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);852 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
842 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);853 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
854 const singleEntry =
855 visibleGroupedEntries.length === 1 ? (visibleGroupedEntries[0] ?? null) : null;
856 const usesSingleToolCallLabel =
857 singleEntry !== null &&
858 workLogEntryIsToolLike(singleEntry) &&
859 toolGroupAction(singleEntry) !== "edit";
860 const summaryToolIcon = usesSingleToolCallLabel
861 ? resolveWorkEntryToolPresentation(singleEntry, "completed")?.icon
862 : undefined;
843 nextRows.push({863 nextRows.push({
844 kind: "work-toggle",864 kind: "work-toggle",
845 id: `work-toggle:${timelineEntry.id}`,865 id: `work-toggle:${timelineEntry.id}`,
...@@ -847,12 +867,13 @@ export function deriveMessagesTimelineRows(input: {...@@ -847,12 +867,13 @@ export function deriveMessagesTimelineRows(input: {
847 groupId,867 groupId,
848 hiddenCount: visibleGroupedEntries.length,868 hiddenCount: visibleGroupedEntries.length,
849 expanded,869 expanded,
850 summary:870 summary: usesSingleToolCallLabel
851 visibleGroupedEntries.length === 1 &&871 ? singleToolCallLabel(singleEntry)
852 !workLogEntryIsToolLike(visibleGroupedEntries[0]!)872 : singleEntry !== null && !workLogEntryIsToolLike(singleEntry)
853 ? visibleGroupedEntries[0]!.label873 ? singleEntry.label
854 : summarizeToolGroup(visibleGroupedEntries),874 : summarizeToolGroup(visibleGroupedEntries),
855 summaryKind,875 summaryKind,
876 ...(summaryToolIcon ? { summaryToolIcon } : {}),
856 hasFailure:877 hasFailure:
857 latestToolEntry !== undefined &&878 latestToolEntry !== undefined &&
858 workEntryDisplayIndicatesToolFailure(latestToolEntry),879 workEntryDisplayIndicatesToolFailure(latestToolEntry),
apps/web/src/components/chat/MessagesTimeline.tsx+1-1
...@@ -1851,7 +1851,7 @@ function WorkGroupToggleTimelineRow({...@@ -1851,7 +1851,7 @@ function WorkGroupToggleTimelineRow({
1851 >1851 >
1852 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">1852 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1853 <WorkEntryIcon1853 <WorkEntryIcon
1854 name={toolGroupSummaryIconName(row.summaryKind)}1854 name={row.summaryToolIcon ?? toolGroupSummaryIconName(row.summaryKind)}
1855 className="size-4 shrink-0 stroke-[1.8] opacity-70"1855 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1856 />1856 />
1857 </span>1857 </span>