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 {
3333 resolveWorkGroupScrollIndex,
3434 shouldFollowWorkGroupAppend,
3535 shouldPreserveAssistantLineBreaks,
36 threadReadLabelPrefix,
37 threadReadTargetId,
38 threadReadTargetTitle,
3639 type MessagesTimelineRow,
3740 resolveTimelineToolPresentation,
3841 workEntryDisplayLabel,
......@@ -91,6 +94,18 @@ describe("expanded tool group scrolling", () => {
9194});
9295
9396describe("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
94109 const entry = {
95110 id: "tool-1",
96111 createdAt: "2026-09-01T12:00:00Z",
......@@ -150,6 +165,37 @@ describe("work entry labels", () => {
150165 expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label);
151166 });
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
153199 it("uses the active summary state for legacy tools without a lifecycle status", () => {
154200 const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" };
155201 expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe(
apps/web/src/components/chat/MessagesTimeline.logic.ts+36-1
......@@ -34,7 +34,12 @@ import {
3434 type TimelineEntry,
3535 type WorkLogEntry,
3636} 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";
3843import {
3944 type MessageId,
4045 type WorktreeSetupSnapshot,
......@@ -169,6 +174,36 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string
169174 return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`;
170175}
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
172207/** Inspectable read-file output is the path when we have one, otherwise nothing. */
173208export function workEntryReadOutput(
174209 entry: Pick<
apps/web/src/components/chat/MessagesTimeline.tsx+70-3
......@@ -28,7 +28,7 @@ import {
2828 type ServerProvider,
2929 type ServerProviderSkill,
3030 type RunId,
31 type ThreadId,
31 ThreadId,
3232 type ToolActivityIcon,
3333} from "@t3tools/contracts";
3434import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
......@@ -208,6 +208,9 @@ import {
208208 resolveWorkGroupScrollIndex,
209209 shouldFollowWorkGroupAppend,
210210 shouldPreserveAssistantLineBreaks,
211 threadReadLabelPrefix,
212 threadReadTargetId,
213 threadReadTargetTitle,
211214 toolGroupAction,
212215 workEntryDisplayLabel,
213216 workEntryReadOutput,
......@@ -3722,19 +3725,67 @@ function LiveActivityContent({
37223725 );
37233726}
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
37253773function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
37263774 const ctx = use(TimelineRowCtx);
3775 const threadTarget = useThreadReadTarget(row.entry, ctx.activeThreadEnvironmentId);
37273776 const questionHeading = row.entry.questionAnswer
37283777 ? getQuestionTextPreview(row.entry.questionAnswer)
37293778 : "";
37303779 const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active);
3780 const threadLabel = threadReadLabel(label, threadTarget);
3781
37313782 const failed = workEntryDisplayIndicatesToolFailure(row.entry);
37323783
37333784 return (
37343785 <button
37353786 type="button"
37363787 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}
37383789 aria-expanded={row.expanded}
37393790 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
37403791 >
......@@ -3756,6 +3807,12 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
37563807 >
37573808 {row.entry.detail ?? label}
37583809 </ReactMarkdown>
3810 ) : threadLabel ? (
3811 <ThreadReadLabel
3812 label={threadLabel}
3813 environmentId={ctx.activeThreadEnvironmentId}
3814 linked={false}
3815 />
37593816 ) : (
37603817 label
37613818 )
......@@ -5113,6 +5170,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
51135170 const ctx = use(TimelineRowCtx);
51145171 const { threadRef, onImageExpand, timestampFormat } = ctx;
51155172 const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx;
5173 const threadTarget = useThreadReadTarget(workEntry, ctx.activeThreadEnvironmentId);
51165174 const createdThread =
51175175 workEntry.projectedItem?.item.type === "thread_created"
51185176 ? workEntry.projectedItem.item
......@@ -5308,7 +5366,10 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
53085366 : workLogEntryIsToolLike(workEntry)
53095367 ? "text-secondary-label"
53105368 : "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(": ");
53125373 const accessibleDisplayText = showFailedIndicator
53135374 ? `${accessiblePreview}, tool call failed`
53145375 : accessiblePreview;
......@@ -5365,6 +5426,12 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
53655426 >
53665427 {workEntry.detail ?? previewText}
53675428 </ReactMarkdown>
5429 ) : threadLabel ? (
5430 <ThreadReadLabel
5431 label={threadLabel}
5432 environmentId={ctx.activeThreadEnvironmentId}
5433 linked
5434 />
53685435 ) : (
53695436 previewText
53705437 )}