authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-07 23:11:04+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 13:11:04-07:00
loga9fb6a80635c77a1d4fd988e0018059e13628eff
treedc5f20633f322bbcbe2a05ec32a4233e209efa08
parent0678e4e23d8675ef88f9ac08e1ae90cfe7d6ef2e
signature Signed by PGP key B5690EEEBB952194

fix(web): show live names in thread-read activity (#13140)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: PR Batch Tester <agent@local.test>

3 files changed, 152 insertions(+), 4 deletions(-)

apps/web/src/components/chat/MessagesTimeline.logic.test.ts+46
...@@ -33,6 +33,9 @@ import {...@@ -33,6 +33,9 @@ import {
33 resolveWorkGroupScrollIndex,33 resolveWorkGroupScrollIndex,
34 shouldFollowWorkGroupAppend,34 shouldFollowWorkGroupAppend,
35 shouldPreserveAssistantLineBreaks,35 shouldPreserveAssistantLineBreaks,
36 threadReadLabelPrefix,
37 threadReadTargetId,
38 threadReadTargetTitle,
36 type MessagesTimelineRow,39 type MessagesTimelineRow,
37 resolveTimelineToolPresentation,40 resolveTimelineToolPresentation,
38 workEntryDisplayLabel,41 workEntryDisplayLabel,
...@@ -91,6 +94,18 @@ describe("expanded tool group scrolling", () => {...@@ -91,6 +94,18 @@ describe("expanded tool group scrolling", () => {
91});94});
9295
93describe("work entry labels", () => {96describe("work entry labels", () => {
97 it("uses live titles only for active thread shells", () => {
98 const shell = { title: " Review auth flow ", archivedAt: null, deletedAt: null };
99 expect(threadReadTargetTitle(shell)).toBe("Review auth flow");
100 expect(threadReadTargetTitle({ ...shell, title: "Harden session refresh" })).toBe(
101 "Harden session refresh",
102 );
103 expect(threadReadTargetTitle({ ...shell, archivedAt: "2026-10-07T12:00:00Z" })).toBeNull();
104 expect(threadReadTargetTitle({ ...shell, deletedAt: "2026-10-07T12:00:00Z" })).toBeNull();
105 expect(threadReadTargetTitle({ ...shell, title: " " })).toBeNull();
106 expect(threadReadTargetTitle(null)).toBeNull();
107 });
108
94 const entry = {109 const entry = {
95 id: "tool-1",110 id: "tool-1",
96 createdAt: "2026-09-01T12:00:00Z",111 createdAt: "2026-09-01T12:00:00Z",
...@@ -150,6 +165,37 @@ describe("work entry labels", () => {...@@ -150,6 +165,37 @@ describe("work entry labels", () => {
150 expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label);165 expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label);
151 });166 });
152167
168 it.each([
169 ["inProgress", true, "Reading thread"],
170 ["completed", false, "Read thread"],
171 ["failed", false, "Failed to read thread"],
172 ["declined", false, "Declined to read thread"],
173 ["stopped", false, "Stopped reading thread"],
174 ] as const)("names the read thread in the %s label", (toolLifecycleStatus, active, prefix) => {
175 const threadRead = {
176 ...entry,
177 itemType: "dynamic_tool" as const,
178 toolLifecycleStatus,
179 structuredPayload: {
180 type: "dynamic_tool",
181 toolName: "t3-code.t3_thread_read",
182 input: { threadId: " thread-child ", view: "activity" },
183 } as never,
184 };
185 expect(threadReadTargetId(threadRead)).toBe("thread-child");
186 expect(threadReadLabelPrefix(liveWorkEntryLabel(threadRead, undefined, active))).toBe(prefix);
187 expect(threadReadLabelPrefix(workEntryDisplayLabel(threadRead, undefined))).toBe(prefix);
188 });
189
190 it("finds no target for other tools or thread reads without one", () => {
191 const payload = (toolName: string, input: unknown) => ({
192 structuredPayload: { type: "dynamic_tool", toolName, input } as never,
193 });
194 expect(threadReadTargetId(payload("t3-code.t3_thread_wait", { threadId: "t" }))).toBeNull();
195 expect(threadReadTargetId(payload("t3-code.t3_thread_read", { threadId: " " }))).toBeNull();
196 expect(threadReadTargetId(payload("t3-code.t3_thread_read", null))).toBeNull();
197 });
198
153 it("uses the active summary state for legacy tools without a lifecycle status", () => {199 it("uses the active summary state for legacy tools without a lifecycle status", () => {
154 const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" };200 const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" };
155 expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe(201 expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe(
apps/web/src/components/chat/MessagesTimeline.logic.ts+36-1
...@@ -34,7 +34,12 @@ import {...@@ -34,7 +34,12 @@ import {
34 type TimelineEntry,34 type TimelineEntry,
35 type WorkLogEntry,35 type WorkLogEntry,
36} from "../../session-logic";36} from "../../session-logic";
37import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";37import {
38 type ChatMessage,
39 type ProposedPlan,
40 type ThreadShell,
41 type TurnDiffSummary,
42} from "../../types";
38import {43import {
39 type MessageId,44 type MessageId,
40 type WorktreeSetupSnapshot,45 type WorktreeSetupSnapshot,
...@@ -169,6 +174,36 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string...@@ -169,6 +174,36 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string
169 return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`;174 return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`;
170}175}
171176
177/** The trimmed thread id a `t3_thread_read` call targets, or null for any other entry. */
178export function threadReadTargetId(entry: Pick<WorkLogEntry, "structuredPayload">) {
179 const item = entry.structuredPayload;
180 if (item?.type !== "dynamic_tool") return null;
181 if (resolveT3McpToolDefinition(item.toolName)?.summaryAction !== "thread-read") return null;
182 const input = item.input;
183 const threadId =
184 input !== null && typeof input === "object" && "threadId" in input ? input.threadId : null;
185 return typeof threadId === "string" && threadId.trim().length > 0 ? threadId.trim() : null;
186}
187
188const THREAD_READ_OBJECT = " a T3 thread";
189
190export function threadReadTargetTitle(
191 shell: Pick<ThreadShell, "title" | "archivedAt" | "deletedAt"> | null,
192) {
193 if (!shell || shell.archivedAt !== null || shell.deletedAt !== null) return null;
194 return shell.title.trim() || null;
195}
196
197/**
198 * Names the read thread in place of the generic object ("Read a T3 thread" becomes
199 * `Read thread “Title”`), keeping the label's tense. Null keeps the generic label.
200 */
201export function threadReadLabelPrefix(label: string) {
202 return label.endsWith(THREAD_READ_OBJECT)
203 ? `${label.slice(0, -THREAD_READ_OBJECT.length)} thread`
204 : null;
205}
206
172/** Inspectable read-file output is the path when we have one, otherwise nothing. */207/** Inspectable read-file output is the path when we have one, otherwise nothing. */
173export function workEntryReadOutput(208export function workEntryReadOutput(
174 entry: Pick<209 entry: Pick<
apps/web/src/components/chat/MessagesTimeline.tsx+70-3
...@@ -28,7 +28,7 @@ import {...@@ -28,7 +28,7 @@ import {
28 type ServerProvider,28 type ServerProvider,
29 type ServerProviderSkill,29 type ServerProviderSkill,
30 type RunId,30 type RunId,
31 type ThreadId,31 ThreadId,
32 type ToolActivityIcon,32 type ToolActivityIcon,
33} from "@t3tools/contracts";33} from "@t3tools/contracts";
34import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";34import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
...@@ -208,6 +208,9 @@ import {...@@ -208,6 +208,9 @@ import {
208 resolveWorkGroupScrollIndex,208 resolveWorkGroupScrollIndex,
209 shouldFollowWorkGroupAppend,209 shouldFollowWorkGroupAppend,
210 shouldPreserveAssistantLineBreaks,210 shouldPreserveAssistantLineBreaks,
211 threadReadLabelPrefix,
212 threadReadTargetId,
213 threadReadTargetTitle,
211 toolGroupAction,214 toolGroupAction,
212 workEntryDisplayLabel,215 workEntryDisplayLabel,
213 workEntryReadOutput,216 workEntryReadOutput,
...@@ -3722,19 +3725,67 @@ function LiveActivityContent({...@@ -3722,19 +3725,67 @@ function LiveActivityContent({
3722 );3725 );
3723}3726}
37243727
3728/** The thread a `t3_thread_read` call targets, titled from live shell state so renames show. */
3729function useThreadReadTarget(entry: TimelineWorkEntry, environmentId: EnvironmentId) {
3730 const rawThreadId = threadReadTargetId(entry);
3731 const threadId = rawThreadId === null ? null : ThreadId.make(rawThreadId);
3732 const shell = useThreadShell(threadId ? scopeThreadRef(environmentId, threadId) : null);
3733 const title = threadReadTargetTitle(shell);
3734 return threadId && title ? { threadId, title } : null;
3735}
3736
3737function threadReadLabel(label: string, target: ReturnType<typeof useThreadReadTarget>) {
3738 const prefix = target && threadReadLabelPrefix(label);
3739 return prefix ? { ...target, prefix, text: `${prefix} “${target.title}”` } : null;
3740}
3741
3742/** Only settled rows link the title; the live row is itself a button. */
3743function ThreadReadLabel({
3744 label,
3745 environmentId,
3746 linked,
3747}: {
3748 label: NonNullable<ReturnType<typeof threadReadLabel>>;
3749 environmentId: EnvironmentId;
3750 linked: boolean;
3751}) {
3752 return (
3753 <span className="flex min-w-0">
3754 <span className="shrink-0">{label.prefix} “</span>
3755 {linked ? (
3756 <Link
3757 to="/$environmentId/$threadId"
3758 params={{ environmentId, threadId: label.threadId }}
3759 className="min-w-0 truncate rounded-sm text-foreground underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
3760 onClick={stopRowToggle}
3761 onKeyDown={stopRowToggle}
3762 >
3763 {label.title}
3764 </Link>
3765 ) : (
3766 <span className="min-w-0 truncate">{label.title}</span>
3767 )}
3768 <span className="shrink-0">”</span>
3769 </span>
3770 );
3771}
3772
3725function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {3773function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
3726 const ctx = use(TimelineRowCtx);3774 const ctx = use(TimelineRowCtx);
3775 const threadTarget = useThreadReadTarget(row.entry, ctx.activeThreadEnvironmentId);
3727 const questionHeading = row.entry.questionAnswer3776 const questionHeading = row.entry.questionAnswer
3728 ? getQuestionTextPreview(row.entry.questionAnswer)3777 ? getQuestionTextPreview(row.entry.questionAnswer)
3729 : "";3778 : "";
3730 const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active);3779 const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active);
3780 const threadLabel = threadReadLabel(label, threadTarget);
3781
3731 const failed = workEntryDisplayIndicatesToolFailure(row.entry);3782 const failed = workEntryDisplayIndicatesToolFailure(row.entry);
37323783
3733 return (3784 return (
3734 <button3785 <button
3735 type="button"3786 type="button"
3736 className="group/live-work flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"3787 className="group/live-work flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
3737 aria-label={failed ? `${label}, tool call failed` : undefined}3788 aria-label={failed ? `${threadLabel?.text ?? label}, tool call failed` : undefined}
3738 aria-expanded={row.expanded}3789 aria-expanded={row.expanded}
3739 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}3790 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
3740 >3791 >
...@@ -3756,6 +3807,12 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "...@@ -3756,6 +3807,12 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
3756 >3807 >
3757 {row.entry.detail ?? label}3808 {row.entry.detail ?? label}
3758 </ReactMarkdown>3809 </ReactMarkdown>
3810 ) : threadLabel ? (
3811 <ThreadReadLabel
3812 label={threadLabel}
3813 environmentId={ctx.activeThreadEnvironmentId}
3814 linked={false}
3815 />
3759 ) : (3816 ) : (
3760 label3817 label
3761 )3818 )
...@@ -5113,6 +5170,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {...@@ -5113,6 +5170,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
5113 const ctx = use(TimelineRowCtx);5170 const ctx = use(TimelineRowCtx);
5114 const { threadRef, onImageExpand, timestampFormat } = ctx;5171 const { threadRef, onImageExpand, timestampFormat } = ctx;
5115 const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx;5172 const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx;
5173 const threadTarget = useThreadReadTarget(workEntry, ctx.activeThreadEnvironmentId);
5116 const createdThread =5174 const createdThread =
5117 workEntry.projectedItem?.item.type === "thread_created"5175 workEntry.projectedItem?.item.type === "thread_created"
5118 ? workEntry.projectedItem.item5176 ? workEntry.projectedItem.item
...@@ -5308,7 +5366,10 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {...@@ -5308,7 +5366,10 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
5308 : workLogEntryIsToolLike(workEntry)5366 : workLogEntryIsToolLike(workEntry)
5309 ? "text-secondary-label"5367 ? "text-secondary-label"
5310 : "text-foreground/80";5368 : "text-foreground/80";
5311 const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": ");5369 const threadLabel = isReasoning ? null : threadReadLabel(previewText, threadTarget);
5370 const accessiblePreview = [threadLabel?.text ?? previewText, answerPreview]
5371 .filter(Boolean)
5372 .join(": ");
5312 const accessibleDisplayText = showFailedIndicator5373 const accessibleDisplayText = showFailedIndicator
5313 ? `${accessiblePreview}, tool call failed`5374 ? `${accessiblePreview}, tool call failed`
5314 : accessiblePreview;5375 : accessiblePreview;
...@@ -5365,6 +5426,12 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {...@@ -5365,6 +5426,12 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
5365 >5426 >
5366 {workEntry.detail ?? previewText}5427 {workEntry.detail ?? previewText}
5367 </ReactMarkdown>5428 </ReactMarkdown>
5429 ) : threadLabel ? (
5430 <ThreadReadLabel
5431 label={threadLabel}
5432 environmentId={ctx.activeThreadEnvironmentId}
5433 linked
5434 />
5368 ) : (5435 ) : (
5369 previewText5436 previewText
5370 )}5437 )}