Skip to content
Open
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
33 changes: 33 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
resolveWorkGroupScrollIndex,
shouldFollowWorkGroupAppend,
shouldPreserveAssistantLineBreaks,
threadReadLabelPrefix,
threadReadTargetId,
type MessagesTimelineRow,
resolveTimelineToolPresentation,
workEntryDisplayLabel,
Expand Down Expand Up @@ -146,6 +148,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
23 changes: 23 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,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<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";

/**
* 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 @@ -27,7 +27,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 @@ -197,6 +197,8 @@ import {
resolveWorkGroupScrollIndex,
shouldFollowWorkGroupAppend,
shouldPreserveAssistantLineBreaks,
threadReadLabelPrefix,
threadReadTargetId,
toolGroupAction,
workEntryDisplayLabel,
workEntryReadOutput,
Expand Down Expand Up @@ -3574,19 +3576,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<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 @@ -3608,6 +3659,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 @@ -4938,6 +4995,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
const { workEntry, workspaceRoot, displayLabel } = props;
const ctx = use(TimelineRowCtx);
const { threadRef, onImageExpand, timestampFormat } = ctx;
const threadTarget = useThreadReadTarget(workEntry, ctx.activeThreadEnvironmentId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium chat/MessagesTimeline.tsx:4998

Archived or deleted threads are rendered with their shell title as a linked ThreadReadLabel, and clicking a deleted thread navigates to the deleted-thread route. useThreadReadTarget must only return a target when shell.archivedAt === null && shell.deletedAt === null; otherwise this row should retain its generic non-link label.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/chat/MessagesTimeline.tsx around line 4998:

Archived or deleted threads are rendered with their shell title as a linked `ThreadReadLabel`, and clicking a deleted thread navigates to the deleted-thread route. `useThreadReadTarget` must only return a target when `shell.archivedAt === null && shell.deletedAt === null`; otherwise this row should retain its generic non-link label.

const createdThread =
workEntry.projectedItem?.item.type === "thread_created"
? workEntry.projectedItem.item
Expand Down Expand Up @@ -5087,7 +5145,10 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
: 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 @@ -5144,6 +5205,12 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
>
{workEntry.detail ?? previewText}
</ReactMarkdown>
) : threadLabel ? (
<ThreadReadLabel
label={threadLabel}
environmentId={ctx.activeThreadEnvironmentId}
linked
/>
) : (
previewText
)}
Expand Down
Loading