Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ import {
resolveWorkGroupScrollIndex,
shouldFollowWorkGroupAppend,
shouldPreserveAssistantLineBreaks,
threadReadLabelPrefix,
threadReadTargetId,
threadReadTargetTitle,
type MessagesTimelineRow,
resolveTimelineToolPresentation,
workEntryDisplayLabel,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
37 changes: 36 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<WorkLogEntry, "structuredPayload">) {
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<ThreadShell, "title" | "archivedAt" | "deletedAt"> | 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<
Expand Down
73 changes: 70 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -208,6 +208,9 @@ import {
resolveWorkGroupScrollIndex,
shouldFollowWorkGroupAppend,
shouldPreserveAssistantLineBreaks,
threadReadLabelPrefix,
threadReadTargetId,
threadReadTargetTitle,
toolGroupAction,
workEntryDisplayLabel,
workEntryReadOutput,
Expand Down Expand Up @@ -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<typeof useThreadReadTarget>) {
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<ReturnType<typeof threadReadLabel>>;
environmentId: EnvironmentId;
linked: boolean;
}) {
return (
<span className="flex min-w-0">
<span className="shrink-0">{label.prefix} “</span>
{linked ? (
<Link
to="/$environmentId/$threadId"
params={{ environmentId, threadId: label.threadId }}
className="min-w-0 truncate rounded-sm text-foreground underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-ring"
onClick={stopRowToggle}
onKeyDown={stopRowToggle}
>
{label.title}
</Link>
) : (
<span className="min-w-0 truncate">{label.title}</span>
)}
<span className="shrink-0">”</span>
</span>
);
}

function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
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 (
<button
type="button"
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"
aria-label={failed ? `${label}, tool call failed` : undefined}
aria-label={failed ? `${threadLabel?.text ?? label}, tool call failed` : undefined}
aria-expanded={row.expanded}
onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
>
Expand All @@ -3756,6 +3807,12 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
>
{row.entry.detail ?? label}
</ReactMarkdown>
) : threadLabel ? (
<ThreadReadLabel
label={threadLabel}
environmentId={ctx.activeThreadEnvironmentId}
linked={false}
/>
) : (
label
)
Expand Down Expand Up @@ -5113,6 +5170,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
const ctx = use(TimelineRowCtx);
const { threadRef, onImageExpand, timestampFormat } = ctx;
const { retryableWorkspacePreparationRunIds, onRetryWorkspacePreparation } = ctx;
const threadTarget = useThreadReadTarget(workEntry, ctx.activeThreadEnvironmentId);
Comment thread
Bil0000 marked this conversation as resolved.
const createdThread =
workEntry.projectedItem?.item.type === "thread_created"
? workEntry.projectedItem.item
Expand Down Expand Up @@ -5308,7 +5366,10 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
: workLogEntryIsToolLike(workEntry)
? "text-secondary-label"
: "text-foreground/80";
const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": ");
const threadLabel = isReasoning ? null : threadReadLabel(previewText, threadTarget);
const accessiblePreview = [threadLabel?.text ?? previewText, answerPreview]
.filter(Boolean)
.join(": ");
const accessibleDisplayText = showFailedIndicator
? `${accessiblePreview}, tool call failed`
: accessiblePreview;
Expand Down Expand Up @@ -5365,6 +5426,12 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
>
{workEntry.detail ?? previewText}
</ReactMarkdown>
) : threadLabel ? (
<ThreadReadLabel
label={threadLabel}
environmentId={ctx.activeThreadEnvironmentId}
linked
/>
) : (
previewText
)}
Expand Down
Loading