| author | |
| committer | |
| log | a9fb6a80635c77a1d4fd988e0018059e13628eff |
| tree | dc5f20633f322bbcbe2a05ec32a4233e209efa08 |
| parent | 0678e4e23d8675ef88f9ac08e1ae90cfe7d6ef2e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 33 | resolveWorkGroupScrollIndex, |
| 34 | 34 | shouldFollowWorkGroupAppend, |
| 35 | 35 | shouldPreserveAssistantLineBreaks, |
| 36 | threadReadLabelPrefix, | |
| 37 | threadReadTargetId, | |
| 38 | threadReadTargetTitle, | |
| 36 | 39 | type MessagesTimelineRow, |
| 37 | 40 | resolveTimelineToolPresentation, |
| 38 | 41 | workEntryDisplayLabel, |
| ... | ... | @@ -91,6 +94,18 @@ describe("expanded tool group scrolling", () => { |
| 91 | 94 | }); |
| 92 | 95 | |
| 93 | 96 | describe("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 | 109 | const entry = { |
| 95 | 110 | id: "tool-1", |
| 96 | 111 | createdAt: "2026-09-01T12:00:00Z", |
| ... | ... | @@ -150,6 +165,37 @@ describe("work entry labels", () => { |
| 150 | 165 | expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label); |
| 151 | 166 | }); |
| 152 | 167 | |
| 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 | 199 | it("uses the active summary state for legacy tools without a lifecycle status", () => { |
| 154 | 200 | const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" }; |
| 155 | 201 | expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe( |
apps/web/src/components/chat/MessagesTimeline.logic.ts+36-1| ... | ... | @@ -34,7 +34,12 @@ import { |
| 34 | 34 | type TimelineEntry, |
| 35 | 35 | type WorkLogEntry, |
| 36 | 36 | } from "../../session-logic"; |
| 37 | import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; | |
| 37 | import { | |
| 38 | type ChatMessage, | |
| 39 | type ProposedPlan, | |
| 40 | type ThreadShell, | |
| 41 | type TurnDiffSummary, | |
| 42 | } from "../../types"; | |
| 38 | 43 | import { |
| 39 | 44 | type MessageId, |
| 40 | 45 | type WorktreeSetupSnapshot, |
| ... | ... | @@ -169,6 +174,36 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string |
| 169 | 174 | return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`; |
| 170 | 175 | } |
| 171 | 176 | |
| 177 | /** The trimmed thread id a `t3_thread_read` call targets, or null for any other entry. */ | |
| 178 | export 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 | ||
| 188 | const THREAD_READ_OBJECT = " a T3 thread"; | |
| 189 | ||
| 190 | export 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 | */ | |
| 201 | export 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 | 207 | /** Inspectable read-file output is the path when we have one, otherwise nothing. */ |
| 173 | 208 | export function workEntryReadOutput( |
| 174 | 209 | entry: Pick< |
apps/web/src/components/chat/MessagesTimeline.tsx+70-3| ... | ... | @@ -28,7 +28,7 @@ import { |
| 28 | 28 | type ServerProvider, |
| 29 | 29 | type ServerProviderSkill, |
| 30 | 30 | type RunId, |
| 31 | type ThreadId, | |
| 31 | ThreadId, | |
| 32 | 32 | type ToolActivityIcon, |
| 33 | 33 | } from "@t3tools/contracts"; |
| 34 | 34 | import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| ... | ... | @@ -208,6 +208,9 @@ import { |
| 208 | 208 | resolveWorkGroupScrollIndex, |
| 209 | 209 | shouldFollowWorkGroupAppend, |
| 210 | 210 | shouldPreserveAssistantLineBreaks, |
| 211 | threadReadLabelPrefix, | |
| 212 | threadReadTargetId, | |
| 213 | threadReadTargetTitle, | |
| 211 | 214 | toolGroupAction, |
| 212 | 215 | workEntryDisplayLabel, |
| 213 | 216 | workEntryReadOutput, |
| ... | ... | @@ -3722,19 +3725,67 @@ function LiveActivityContent({ |
| 3722 | 3725 | ); |
| 3723 | 3726 | } |
| 3724 | 3727 | |
| 3728 | /** The thread a `t3_thread_read` call targets, titled from live shell state so renames show. */ | |
| 3729 | function 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 | ||
| 3737 | function 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. */ | |
| 3743 | function 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 | ||
| 3725 | 3773 | function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) { |
| 3726 | 3774 | const ctx = use(TimelineRowCtx); |
| 3775 | const threadTarget = useThreadReadTarget(row.entry, ctx.activeThreadEnvironmentId); | |
| 3727 | 3776 | const questionHeading = row.entry.questionAnswer |
| 3728 | 3777 | ? getQuestionTextPreview(row.entry.questionAnswer) |
| 3729 | 3778 | : ""; |
| 3730 | 3779 | const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); |
| 3780 | const threadLabel = threadReadLabel(label, threadTarget); | |
| 3781 | ||
| 3731 | 3782 | const failed = workEntryDisplayIndicatesToolFailure(row.entry); |
| 3732 | 3783 | |
| 3733 | 3784 | return ( |
| 3734 | 3785 | <button |
| 3735 | 3786 | type="button" |
| 3736 | 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 | 3789 | aria-expanded={row.expanded} |
| 3739 | 3790 | onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} |
| 3740 | 3791 | > |
| ... | ... | @@ -3756,6 +3807,12 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: " |
| 3756 | 3807 | > |
| 3757 | 3808 | {row.entry.detail ?? label} |
| 3758 | 3809 | </ReactMarkdown> |
| 3810 | ) : threadLabel ? ( | |
| 3811 | <ThreadReadLabel | |
| 3812 | label={threadLabel} | |
| 3813 | environmentId={ctx.activeThreadEnvironmentId} | |
| 3814 | linked={false} | |
| 3815 | /> | |
| 3759 | 3816 | ) : ( |
| 3760 | 3817 | label |
| 3761 | 3818 | ) |
| ... | ... | @@ -5113,6 +5170,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5113 | 5170 | const ctx = use(TimelineRowCtx); |
| 5114 | 5171 | const { threadRef, onImageExpand, timestampFormat } = ctx; |
| 5115 | 5172 | const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx; |
| 5173 | const threadTarget = useThreadReadTarget(workEntry, ctx.activeThreadEnvironmentId); | |
| 5116 | 5174 | const createdThread = |
| 5117 | 5175 | workEntry.projectedItem?.item.type === "thread_created" |
| 5118 | 5176 | ? workEntry.projectedItem.item |
| ... | ... | @@ -5308,7 +5366,10 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5308 | 5366 | : workLogEntryIsToolLike(workEntry) |
| 5309 | 5367 | ? "text-secondary-label" |
| 5310 | 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 | 5373 | const accessibleDisplayText = showFailedIndicator |
| 5313 | 5374 | ? `${accessiblePreview}, tool call failed` |
| 5314 | 5375 | : accessiblePreview; |
| ... | ... | @@ -5365,6 +5426,12 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5365 | 5426 | > |
| 5366 | 5427 | {workEntry.detail ?? previewText} |
| 5367 | 5428 | </ReactMarkdown> |
| 5429 | ) : threadLabel ? ( | |
| 5430 | <ThreadReadLabel | |
| 5431 | label={threadLabel} | |
| 5432 | environmentId={ctx.activeThreadEnvironmentId} | |
| 5433 | linked | |
| 5434 | /> | |
| 5368 | 5435 | ) : ( |
| 5369 | 5436 | previewText |
| 5370 | 5437 | )} |