authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 22:39:09-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 22:39:09-04:00
logec44bc56f598be16ffbc22ed0e4e095447043844
treee125223869b0e3345d20bc620b3ba6b9b33fa5f2
parent922bd692251bc803c12a3fab159efe83c957bb70
signature Signed by PGP key B5690EEEBB952194

fix(chat): keep live tool labels in present tense (#9316)

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

8 files changed, 54 insertions(+), 37 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+8-6
...@@ -868,7 +868,7 @@ describe("buildThreadFeed", () => {...@@ -868,7 +868,7 @@ describe("buildThreadFeed", () => {
868 title: "Call MCP tool",868 title: "Call MCP tool",
869 item: { server: "t3-code", tool: "preview_click" },869 item: { server: "t3-code", tool: "preview_click" },
870 status: undefined,870 status: undefined,
871 displayName: "Click in the preview browser",871 displayName: "Clicking 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 settledDisplayName: "Clicked in the preview browser",
874 icon: "browser",874 icon: "browser",
...@@ -879,7 +879,7 @@ describe("buildThreadFeed", () => {...@@ -879,7 +879,7 @@ describe("buildThreadFeed", () => {
879 title: "Call MCP tool",879 title: "Call MCP tool",
880 item: { server: "t3-code", tool: "task_status" },880 item: { server: "t3-code", tool: "task_status" },
881 status: undefined,881 status: undefined,
882 displayName: "Get delegated task status",882 displayName: "Getting delegated task status",
883 liveDisplayName: "Getting delegated task status",883 liveDisplayName: "Getting delegated task status",
884 settledDisplayName: "Got delegated task status",884 settledDisplayName: "Got delegated task status",
885 icon: "t3-code",885 icon: "t3-code",
...@@ -1039,7 +1039,7 @@ describe("buildThreadFeed", () => {...@@ -1039,7 +1039,7 @@ describe("buildThreadFeed", () => {
1039 ).toMatchObject([1039 ).toMatchObject([
1040 {1040 {
1041 type: "work-toggle",1041 type: "work-toggle",
1042 summary: "Clicked in the preview browser",1042 summary: "Clicking in the preview browser",
1043 summaryToolIcon: "browser",1043 summaryToolIcon: "browser",
1044 live: true,1044 live: true,
1045 },1045 },
...@@ -1050,18 +1050,20 @@ describe("buildThreadFeed", () => {...@@ -1050,18 +1050,20 @@ describe("buildThreadFeed", () => {
1050 {1050 {
1051 status: "completed",1051 status: "completed",
1052 displayName: "Clicked in the preview browser",1052 displayName: "Clicked in the preview browser",
1053 liveDisplayName: "Clicking in the preview browser",
1053 detail: "Clicked Continue",1054 detail: "Clicked Continue",
1054 hasFailure: false,1055 hasFailure: false,
1055 },1056 },
1056 {1057 {
1057 status: "failed",1058 status: "failed",
1058 displayName: "Failed to click in the preview browser",1059 displayName: "Failed to click in the preview browser",
1060 liveDisplayName: "Failed to click in the preview browser",
1059 detail: "Timed out waiting for Continue",1061 detail: "Timed out waiting for Continue",
1060 hasFailure: true,1062 hasFailure: true,
1061 },1063 },
1062 ])(1064 ])(
1063 "uses the browser call label once its action settles as $status",1065 "uses the browser call label once its action settles as $status",
1064 ({ status, displayName, detail, hasFailure }) => {1066 ({ status, displayName, liveDisplayName, detail, hasFailure }) => {
1065 const turnId = TurnId.make("turn-preview-lifecycle");1067 const turnId = TurnId.make("turn-preview-lifecycle");
1066 const toolCallId = "preview-click";1068 const toolCallId = "preview-click";
1067 const groupId = `work-group:tool:${turnId}:${toolCallId}`;1069 const groupId = `work-group:tool:${turnId}:${toolCallId}`;
...@@ -1156,7 +1158,7 @@ describe("buildThreadFeed", () => {...@@ -1156,7 +1158,7 @@ describe("buildThreadFeed", () => {
1156 groupId,1158 groupId,
1157 hiddenCount: 1,1159 hiddenCount: 1,
1158 expanded: true,1160 expanded: true,
1159 summary: displayName,1161 summary: liveDisplayName,
1160 summaryToolIcon: "browser",1162 summaryToolIcon: "browser",
1161 hasFailure,1163 hasFailure,
1162 live: true,1164 live: true,
...@@ -1677,7 +1679,7 @@ describe("buildThreadFeed", () => {...@@ -1677,7 +1679,7 @@ describe("buildThreadFeed", () => {
1677 (1679 (
1678 [1680 [
1679 { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true },1681 { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true },
1680 { lifecycleStatus: "completed", summary: "Ran pnpm", shimmer: false },1682 { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: false },
1681 { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false },1683 { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false },
1682 { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false },1684 { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false },
1683 { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false },1685 { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false },
apps/mobile/src/lib/threadActivity.ts+8-7
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17 commandDetailRepeatsCommand,17 commandDetailRepeatsCommand,
18 extractCommandOutputText,18 extractCommandOutputText,
19 isWorktreeSetupActivity,19 isWorktreeSetupActivity,
20 liveActivityToolStatus,
20 normalizeCompactToolLabel,21 normalizeCompactToolLabel,
21 omitSupersededLifecycleMarkers,22 omitSupersededLifecycleMarkers,
22 resolveWorkEntryToolPresentation,23 resolveWorkEntryToolPresentation,
...@@ -1528,7 +1529,7 @@ function appendToolGroupRows(...@@ -1528,7 +1529,7 @@ function appendToolGroupRows(
1528 const latestActivity = latestActiveActivity ?? activities.at(-1)!;1529 const latestActivity = latestActiveActivity ?? activities.at(-1)!;
1529 const singleActivity = activities.length === 1 ? latestActivity : null;1530 const singleActivity = activities.length === 1 ? latestActivity : null;
1530 const summary = live1531 const summary = live
1531 ? liveToolActivitySummary(latestActivity, active)1532 ? liveToolActivitySummary(latestActivity, live)
1532 : singleActivity !== null &&1533 : singleActivity !== null &&
1533 singleActivity.toolLike &&1534 singleActivity.toolLike &&
1534 toolGroupAction(singleActivity.workEntry) !== "edit"1535 toolGroupAction(singleActivity.workEntry) !== "edit"
...@@ -1580,16 +1581,16 @@ function appendToolGroupRows(...@@ -1580,16 +1581,16 @@ function appendToolGroupRows(
1580 });1581 });
1581}1582}
15821583
1583function liveToolActivitySummary(activity: ThreadFeedActivity, active: boolean): string {1584function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boolean): string {
1584 const presentation = resolveWorkEntryToolPresentation(1585 const status = liveActivityToolStatus(activity.lifecycleStatus, presentTense);
1585 activity.workEntry,1586 const presentation = resolveWorkEntryToolPresentation({
1586 active ? "inProgress" : "completed",1587 ...activity.workEntry,
1587 );1588 toolLifecycleStatus: status,
1589 });
1588 if (presentation) return presentation.displayName;1590 if (presentation) return presentation.displayName;
1589 const command = activity.workEntry.command?.trim();1591 const command = activity.workEntry.command?.trim();
1590 if (command) {1592 if (command) {
1591 const program = commandProgramName(command);1593 const program = commandProgramName(command);
1592 const status = activity.lifecycleStatus ?? (active ? "inProgress" : "completed");
1593 const verb =1594 const verb =
1594 status === "inProgress"1595 status === "inProgress"
1595 ? "Running"1596 ? "Running"
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+13-10
...@@ -99,13 +99,16 @@ describe("work entry labels", () => {...@@ -99,13 +99,16 @@ describe("work entry labels", () => {
99 );99 );
100 });100 });
101101
102 it("does not describe a finished call as still running while the turn continues", () => {102 it("keeps the latest live activity in the present tense after the call completes", () => {
103 const browserEntry = {103 const browserEntry = {
104 ...entry,104 ...entry,
105 toolTitle: "T3-code.preview_click",105 toolTitle: "T3-code.preview_click",
106 toolLifecycleStatus: "completed" as const,106 toolLifecycleStatus: "completed" as const,
107 };107 };
108 expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe(108 expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe(
109 "Clicking in the preview browser",
110 );
111 expect(liveWorkEntryLabel(browserEntry, undefined, false)).toBe(
109 "Clicked in the preview browser",112 "Clicked in the preview browser",
110 );113 );
111 });114 });
...@@ -134,21 +137,21 @@ describe("work entry labels", () => {...@@ -134,21 +137,21 @@ describe("work entry labels", () => {
134 });137 });
135138
136 it.each([139 it.each([
137 ["inProgress", "Running vp"],140 ["inProgress", "Running vp", "Running vp"],
138 ["completed", "Ran vp"],141 ["completed", "Running vp", "Ran vp"],
139 ["failed", "Failed vp"],142 ["failed", "Failed vp", "Failed vp"],
140 ["declined", "Declined vp"],143 ["declined", "Declined vp", "Declined vp"],
141 ["stopped", "Stopped vp"],144 ["stopped", "Stopped vp", "Stopped vp"],
142 ] as const)(145 ] as const)(
143 "uses the command's %s outcome even while the turn continues",146 "uses present tense for a live %s command and the outcome once it is no longer live",
144 (toolLifecycleStatus, label) => {147 (toolLifecycleStatus, liveLabel, settledLabel) => {
145 const commandEntry = {148 const commandEntry = {
146 ...entry,149 ...entry,
147 command: "/bin/bash -lc 'vp test run'",150 command: "/bin/bash -lc 'vp test run'",
148 toolLifecycleStatus,151 toolLifecycleStatus,
149 };152 };
150 expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe(label);153 expect(liveWorkEntryLabel(commandEntry, undefined, true)).toBe(liveLabel);
151 expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe(label);154 expect(liveWorkEntryLabel(commandEntry, undefined, false)).toBe(settledLabel);
152 },155 },
153 );156 );
154157
apps/web/src/components/chat/MessagesTimeline.logic.ts+6-5
...@@ -2,6 +2,7 @@ import * as Equal from "effect/Equal";...@@ -2,6 +2,7 @@ import * as Equal from "effect/Equal";
2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";2import { renderCodexDirectivesForCopy } from "@t3tools/client-runtime/codex-markdown-directives";
3import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";3import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label";
4import {4import {
5 liveActivityToolStatus,
5 normalizeCompactToolLabel,6 normalizeCompactToolLabel,
6 omitSupersededLifecycleMarkers,7 omitSupersededLifecycleMarkers,
7 resolveWorkEntryToolPresentation,8 resolveWorkEntryToolPresentation,
...@@ -65,14 +66,14 @@ export function liveWorkEntryLabel(...@@ -65,14 +66,14 @@ export function liveWorkEntryLabel(
65 workspaceRoot: string | undefined,66 workspaceRoot: string | undefined,
66 active: boolean,67 active: boolean,
67) {68) {
68 const toolPresentation = resolveWorkEntryToolPresentation(69 const status = liveActivityToolStatus(entry.toolLifecycleStatus, active);
69 entry,70 const toolPresentation = resolveWorkEntryToolPresentation({
70 active ? "inProgress" : "completed",71 ...entry,
71 );72 toolLifecycleStatus: status,
73 });
72 if (toolPresentation) return toolPresentation.displayName;74 if (toolPresentation) return toolPresentation.displayName;
73 const command = entry.command?.trim();75 const command = entry.command?.trim();
74 if (command) {76 if (command) {
75 const status = entry.toolLifecycleStatus ?? (active ? "inProgress" : "completed");
76 const verb =77 const verb =
77 status === "inProgress"78 status === "inProgress"
78 ? "Running"79 ? "Running"
apps/web/src/components/chat/MessagesTimeline.test.tsx+3-3
...@@ -1224,7 +1224,7 @@ describe("MessagesTimeline", () => {...@@ -1224,7 +1224,7 @@ describe("MessagesTimeline", () => {
1224 expect(markup).toContain('data-timeline-row-id="live-activity-row"');1224 expect(markup).toContain('data-timeline-row-id="live-activity-row"');
1225 });1225 });
12261226
1227 it("keeps the completed command in the shared activity row with a past-tense label", () => {1227 it("keeps the completed command in the shared activity row with a present-tense label", () => {
1228 const turnId = TurnId.make("turn-live");1228 const turnId = TurnId.make("turn-live");
1229 const markup = renderToStaticMarkup(1229 const markup = renderToStaticMarkup(
1230 <MessagesTimeline1230 <MessagesTimeline
...@@ -1259,10 +1259,10 @@ describe("MessagesTimeline", () => {...@@ -1259,10 +1259,10 @@ describe("MessagesTimeline", () => {
1259 />,1259 />,
1260 );1260 );
12611261
1262 expect(markup).toContain("Ran pnpm");1262 expect(markup).toContain("Running pnpm");
1263 expect(markup).toContain("lucide-terminal");1263 expect(markup).toContain("lucide-terminal");
1264 expect(markup).toContain("live-activity-focus");1264 expect(markup).toContain("live-activity-focus");
1265 expect(markup).not.toContain("Running pnpm");1265 expect(markup).not.toContain("Ran pnpm");
1266 expect(markup).not.toContain("Thinking");1266 expect(markup).not.toContain("Thinking");
1267 expect(markup).not.toContain('data-timeline-row-kind="thinking"');1267 expect(markup).not.toContain('data-timeline-row-kind="thinking"');
1268 });1268 });
docs/user/tool-activity.md+4-1
...@@ -8,7 +8,10 @@ indicate more calls above or below. Short groups use only the space they need....@@ -8,7 +8,10 @@ indicate more calls above or below. Short groups use only the space they need.
8Collapsing and reopening a group preserves your reading position and any open call details.8Collapsing and reopening a group preserves your reading position and any open call details.
99
10Recognized T3 tools use descriptive labels in both the running summary and individual rows.10Recognized T3 tools use descriptive labels in both the running summary and individual rows.
11Labels follow the call's state, such as "Clicking" while running and "Clicked" after success.11The latest live activity stays in the present tense while the turn continues, such as
12"Running vp" or "Clicking in the preview browser", even after that call has completed.
13Expanded rows follow the call's own state, such as "Clicked" after success.
14When a call has not reported a state yet, the label stays in the present tense.
12Failed, declined, and stopped calls say what happened without implying success.15Failed, declined, and stopped calls say what happened without implying success.
13Preview browser actions use a globe icon. Other T3 tools keep the T3 mark.16Preview browser actions use a globe icon. Other T3 tools keep the T3 mark.
14Group summaries count browser actions separately, such as "Used browser 18 times" or17Group summaries count browser actions separately, such as "Used browser 18 times" or
packages/client-runtime/src/work-log/presentation.test.ts+4-4
...@@ -25,7 +25,7 @@ describe("resolveWorkEntryToolPresentation", () => {...@@ -25,7 +25,7 @@ describe("resolveWorkEntryToolPresentation", () => {
25 "preview_click",25 "preview_click",
26 ])("recognizes browser tool names across providers: %s", (label) => {26 ])("recognizes browser tool names across providers: %s", (label) => {
27 expect(resolveWorkEntryToolPresentation({ label })).toEqual({27 expect(resolveWorkEntryToolPresentation({ label })).toEqual({
28 displayName: "Click in the preview browser",28 displayName: "Clicking in the preview browser",
29 icon: "browser",29 icon: "browser",
30 });30 });
31 });31 });
...@@ -37,7 +37,7 @@ describe("resolveWorkEntryToolPresentation", () => {...@@ -37,7 +37,7 @@ describe("resolveWorkEntryToolPresentation", () => {
37 toolTitle: "Inspect the current page",37 toolTitle: "Inspect the current page",
38 toolData: { server: "t3-code", tool: "preview_snapshot", result: { title: "Example" } },38 toolData: { server: "t3-code", tool: "preview_snapshot", result: { title: "Example" } },
39 }),39 }),
40 ).toEqual({ displayName: "Take a snapshot of the preview page", icon: "browser" });40 ).toEqual({ displayName: "Taking a snapshot of the preview page", icon: "browser" });
41 });41 });
4242
43 it.each([43 it.each([
...@@ -46,7 +46,7 @@ describe("resolveWorkEntryToolPresentation", () => {...@@ -46,7 +46,7 @@ describe("resolveWorkEntryToolPresentation", () => {
46 ["failed", "Failed to click in the preview browser"],46 ["failed", "Failed to click in the preview browser"],
47 ["declined", "Declined to click in the preview browser"],47 ["declined", "Declined to click in the preview browser"],
48 ["stopped", "Stopped clicking in the preview browser"],48 ["stopped", "Stopped clicking in the preview browser"],
49 ["unknown", "Click in the preview browser"],49 ["unknown", "Clicking in the preview browser"],
50 ])("describes the tool's own %s state", (toolLifecycleStatus, displayName) => {50 ])("describes the tool's own %s state", (toolLifecycleStatus, displayName) => {
51 expect(51 expect(
52 resolveWorkEntryToolPresentation({52 resolveWorkEntryToolPresentation({
...@@ -115,7 +115,7 @@ describe("resolveWorkEntryToolPresentation", () => {...@@ -115,7 +115,7 @@ describe("resolveWorkEntryToolPresentation", () => {
115 label: "mcp__t3_code__task_status",115 label: "mcp__t3_code__task_status",
116 toolTitle: "Check the child task",116 toolTitle: "Check the child task",
117 }),117 }),
118 ).toEqual({ displayName: "Get delegated task status", icon: "t3-code" });118 ).toEqual({ displayName: "Getting delegated task status", icon: "t3-code" });
119 });119 });
120120
121 it("does not brand unknown tools or another server's matching tool name", () => {121 it("does not brand unknown tools or another server's matching tool name", () => {
packages/client-runtime/src/work-log/presentation.ts+8-1
...@@ -113,7 +113,7 @@ function resolveT3McpToolPresentation(value: string | undefined, status: string...@@ -113,7 +113,7 @@ function resolveT3McpToolPresentation(value: string | undefined, status: string
113 ? `Declined to ${action.toLowerCase()}`113 ? `Declined to ${action.toLowerCase()}`
114 : status === "stopped"114 : status === "stopped"
115 ? `Stopped ${running.toLowerCase()}`115 ? `Stopped ${running.toLowerCase()}`
116 : action;116 : running;
117117
118 return {118 return {
119 displayName: `${verb} ${detail}`,119 displayName: `${verb} ${detail}`,
...@@ -121,6 +121,13 @@ function resolveT3McpToolPresentation(value: string | undefined, status: string...@@ -121,6 +121,13 @@ function resolveT3McpToolPresentation(value: string | undefined, status: string
121 };121 };
122}122}
123123
124/** Latest live activity stays present-tense unless the call itself failed, declined, or stopped. */
125export function liveActivityToolStatus(status: string | undefined, presentTense: boolean) {
126 if (status === "failed" || status === "declined" || status === "stopped") return status;
127 if (presentTense || status === "inProgress") return "inProgress";
128 return "completed";
129}
130
124/** Resolves tool identity before choosing labels or icons in either client. */131/** Resolves tool identity before choosing labels or icons in either client. */
125export function resolveWorkEntryToolPresentation(132export function resolveWorkEntryToolPresentation(
126 entry: Pick<WorkLogPresentationEntry, "label" | "toolTitle" | "toolData" | "toolLifecycleStatus">,133 entry: Pick<WorkLogPresentationEntry, "label" | "toolTitle" | "toolData" | "toolLifecycleStatus">,