diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 0f22324e0ca1..e1ce7145d63a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -33,6 +33,9 @@ import { resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, + threadReadLabelPrefix, + threadReadTargetId, + threadReadTargetTitle, type MessagesTimelineRow, resolveTimelineToolPresentation, workEntryDisplayLabel, @@ -91,6 +94,18 @@ describe("expanded tool group scrolling", () => { }); describe("work entry labels", () => { + it("uses live titles only for active thread shells", () => { + const shell = { title: " Review auth flow ", archivedAt: null, deletedAt: null }; + expect(threadReadTargetTitle(shell)).toBe("Review auth flow"); + expect(threadReadTargetTitle({ ...shell, title: "Harden session refresh" })).toBe( + "Harden session refresh", + ); + expect(threadReadTargetTitle({ ...shell, archivedAt: "2026-10-07T12:00:00Z" })).toBeNull(); + expect(threadReadTargetTitle({ ...shell, deletedAt: "2026-10-07T12:00:00Z" })).toBeNull(); + expect(threadReadTargetTitle({ ...shell, title: " " })).toBeNull(); + expect(threadReadTargetTitle(null)).toBeNull(); + }); + const entry = { id: "tool-1", createdAt: "2026-09-01T12:00:00Z", @@ -150,6 +165,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..b619a347255b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -34,7 +34,12 @@ import { type TimelineEntry, type WorkLogEntry, } from "../../session-logic"; -import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; +import { + type ChatMessage, + type ProposedPlan, + type ThreadShell, + type TurnDiffSummary, +} from "../../types"; import { type MessageId, type WorktreeSetupSnapshot, @@ -169,6 +174,36 @@ 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"; + +export function threadReadTargetTitle( + shell: Pick | null, +) { + if (!shell || shell.archivedAt !== null || shell.deletedAt !== null) return null; + return shell.title.trim() || null; +} + +/** + * 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..43f9746a6590 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,9 @@ import { resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, + threadReadLabelPrefix, + threadReadTargetId, + threadReadTargetTitle, toolGroupAction, workEntryDisplayLabel, workEntryReadOutput, @@ -3722,19 +3725,67 @@ 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 shell = useThreadShell(threadId ? scopeThreadRef(environmentId, threadId) : null); + const title = threadReadTargetTitle(shell); + 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 (