From 6a1cbed6c0bd28e9029d681c6bd9379058d4b0ac Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:49:50 -0700 Subject: [PATCH 1/2] fix(web): show live names in thread-read activity Replay the contribution onto current main and resolve integration changes. Co-authored-by: Julius Marminge Co-authored-by: PR Batch Tester --- .../chat/MessagesTimeline.logic.test.ts | 33 +++++++++ .../components/chat/MessagesTimeline.logic.ts | 23 ++++++ .../src/components/chat/MessagesTimeline.tsx | 73 ++++++++++++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 0f22324e0ca1..7107f1ed468b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -33,6 +33,8 @@ import { resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, + threadReadLabelPrefix, + threadReadTargetId, type MessagesTimelineRow, resolveTimelineToolPresentation, workEntryDisplayLabel, @@ -150,6 +152,37 @@ describe("work entry labels", () => { expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label); }); + it.each([ + ["inProgress", true, "Reading thread"], + ["completed", false, "Read thread"], + ["failed", false, "Failed to read thread"], + ["declined", false, "Declined to read thread"], + ["stopped", false, "Stopped reading thread"], + ] as const)("names the read thread in the %s label", (toolLifecycleStatus, active, prefix) => { + const threadRead = { + ...entry, + itemType: "dynamic_tool" as const, + toolLifecycleStatus, + structuredPayload: { + type: "dynamic_tool", + toolName: "t3-code.t3_thread_read", + input: { threadId: " thread-child ", view: "activity" }, + } as never, + }; + expect(threadReadTargetId(threadRead)).toBe("thread-child"); + expect(threadReadLabelPrefix(liveWorkEntryLabel(threadRead, undefined, active))).toBe(prefix); + expect(threadReadLabelPrefix(workEntryDisplayLabel(threadRead, undefined))).toBe(prefix); + }); + + it("finds no target for other tools or thread reads without one", () => { + const payload = (toolName: string, input: unknown) => ({ + structuredPayload: { type: "dynamic_tool", toolName, input } as never, + }); + expect(threadReadTargetId(payload("t3-code.t3_thread_wait", { threadId: "t" }))).toBeNull(); + expect(threadReadTargetId(payload("t3-code.t3_thread_read", { threadId: " " }))).toBeNull(); + expect(threadReadTargetId(payload("t3-code.t3_thread_read", null))).toBeNull(); + }); + it("uses the active summary state for legacy tools without a lifecycle status", () => { const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" }; expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 5a2259e961f4..ea1f4c61b3e7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -169,6 +169,29 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`; } +/** The trimmed thread id a `t3_thread_read` call targets, or null for any other entry. */ +export function threadReadTargetId(entry: Pick) { + const item = entry.structuredPayload; + if (item?.type !== "dynamic_tool") return null; + if (resolveT3McpToolDefinition(item.toolName)?.summaryAction !== "thread-read") return null; + const input = item.input; + const threadId = + input !== null && typeof input === "object" && "threadId" in input ? input.threadId : null; + return typeof threadId === "string" && threadId.trim().length > 0 ? threadId.trim() : null; +} + +const THREAD_READ_OBJECT = " a T3 thread"; + +/** + * Names the read thread in place of the generic object ("Read a T3 thread" becomes + * `Read thread “Title”`), keeping the label's tense. Null keeps the generic label. + */ +export function threadReadLabelPrefix(label: string) { + return label.endsWith(THREAD_READ_OBJECT) + ? `${label.slice(0, -THREAD_READ_OBJECT.length)} thread` + : null; +} + /** Inspectable read-file output is the path when we have one, otherwise nothing. */ export function workEntryReadOutput( entry: Pick< diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 463fbfd30b87..d58025960acf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -28,7 +28,7 @@ import { type ServerProvider, type ServerProviderSkill, type RunId, - type ThreadId, + ThreadId, type ToolActivityIcon, } from "@t3tools/contracts"; import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -208,6 +208,8 @@ import { resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, + threadReadLabelPrefix, + threadReadTargetId, toolGroupAction, workEntryDisplayLabel, workEntryReadOutput, @@ -3722,19 +3724,68 @@ function LiveActivityContent({ ); } +/** The thread a `t3_thread_read` call targets, titled from live shell state so renames show. */ +function useThreadReadTarget(entry: TimelineWorkEntry, environmentId: EnvironmentId) { + const rawThreadId = threadReadTargetId(entry); + const threadId = rawThreadId === null ? null : ThreadId.make(rawThreadId); + const title = useThreadShell( + threadId ? scopeThreadRef(environmentId, threadId) : null, + )?.title.trim(); + return threadId && title ? { threadId, title } : null; +} + +function threadReadLabel(label: string, target: ReturnType) { + const prefix = target && threadReadLabelPrefix(label); + return prefix ? { ...target, prefix, text: `${prefix} “${target.title}”` } : null; +} + +/** Only settled rows link the title; the live row is itself a button. */ +function ThreadReadLabel({ + label, + environmentId, + linked, +}: { + label: NonNullable>; + environmentId: EnvironmentId; + linked: boolean; +}) { + return ( + + {label.prefix} “ + {linked ? ( + + {label.title} + + ) : ( + {label.title} + )} + ” + + ); +} + function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const threadTarget = useThreadReadTarget(row.entry, ctx.activeThreadEnvironmentId); const questionHeading = row.entry.questionAnswer ? getQuestionTextPreview(row.entry.questionAnswer) : ""; const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); + const threadLabel = threadReadLabel(label, threadTarget); + const failed = workEntryDisplayIndicatesToolFailure(row.entry); return (