feat(web): collapse tool activity into one line#7152

merged7
main
ui-refresh/tool-activity-web
openedAug 16, 2026byMmaria
View on GitHubTwo-way sync enabledPR last imported 3d ago
36 unresolved threads · Jump to file
Selected thread: apps/web/src/components/chat/MessagesTimeline.tsx:1116 — `WorkingTimelineRow` now renders the same shell as this fold header (`border-b border-border/60 pb-2 pt-1` wrapper, `px-1 text-sm leading-relaxed … tabular-nums` label), so "Working for 12s" and "Worked for 12s" are visual twins that swap in place when a turn settles — but they resolve different tokens: this row stays `text-muted-foreground` while the working header (line 1306) and every activity line this fold reveals (`LiveActivityContent` line 1465, the tool-group summary line 1562, `PlainWorkEntryRow` line 2605) use `text-secondary-label`. `--secondary-label` only aliases `--muted-foreground` in the default palette; imported themes derive it from a different palette entry (`themePalette.ts` line 1185 `textMuted` vs line 1183 `mutedForeground`), so under a custom theme the header visibly changes color the moment the turn completes. Suggest using the same token for both headers. ```suggestion className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-secondary-label tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70" ``` <sup>Posted via Macroscope — <a href="https://github.com/pingdotgg/t3code/runs/95240830948">UI Consistency</a></sup>
0 / 8 viewed8 files changedLine totals unavailable

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision aa78a62b · right 1505

Original diff
@@ -1370,19 +1397,172 @@ const WorkGroupSection = memo(function WorkGroupSection({
             key={workEntry.id}
             workEntry={workEntry}
             workspaceRoot={workspaceRoot}
+            isExpandedToolGroupEntry={isExpandedToolGroupEntry}
           />
         ))}
       </div>
-    </section>
+    </GroupContainer>
   );
 });
 
+function LiveActivityRow({
+  label,
+  iconName,
+  failed = false,
+}: {
+  label: string;
+  iconName?: WorkEntryIconName;
+  failed?: boolean;
+}) {
+  return (
+    <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
+      <LiveActivityContent
+        label={label}
+        iconName={iconName}
+        failed={failed}
+        announceFailure={failed}
+      />
+      <div
+        aria-hidden
+        className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
+      >
+        <div className="live-activity-focus-counter">
+          <div className="live-activity-focus-aligned">
+            <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ThinkingActivityRow() {
+  const { workingStepLabel } = use(TimelineRowActivityCtx);
+  return (
+    <LiveActivityRow label={workingStepLabel ? `Thinking · ${workingStepLabel}` : "Thinking"} />
+  );
+}
+
+function LiveActivityContent({
+  label,
+  iconName,
+  failed = false,
+  announceFailure = false,
+  highlighted = false,
+}: {
+  label: string;
+  iconName: WorkEntryIconName | undefined;
+  failed?: boolean;
+  announceFailure?: boolean;
+  highlighted?: boolean;
+}) {
+  const resolvedIconName = failed ? "x" : iconName;
+
+  return (
+    <div
+      className={cn(
+        "flex min-h-6 min-w-0 items-center gap-1.5 px-0.5 py-0.5",
+        highlighted ? "text-foreground" : "text-secondary-label",
+      )}
+    >
+      {resolvedIconName ? (
+        <span
+          className={cn(
+            "flex size-6 shrink-0 items-center justify-center",
+            failed ? "text-destructive" : highlighted ? "text-foreground" : "text-icon-muted",
+          )}
+          role={announceFailure ? "img" : undefined}
+          aria-label={announceFailure ? "Tool call failed" : undefined}
+        >
+          <WorkEntryIconSvg
+            name={resolvedIconName}
+            className="block size-4 shrink-0 stroke-[1.8] opacity-70"
+          />
+        </span>
+      ) : (
+        <span aria-hidden className="size-6 shrink-0" />
+      )}
+      <span className="min-w-0 flex-1 truncate">{label}</span>
+    </div>
+  );
+}
+
+function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
+  const ctx = use(TimelineRowCtx);
+  const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot);
+  const failed = workEntryIndicatesToolFailure(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-expanded={row.expanded}
+      onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
+    >
+      <LiveActivityRow label={label} iconName={workEntryIconName(row.entry)} failed={failed} />
+    </button>
+  );
C
cursorBotAug 17, 2026

Live row ignores earlier failures

Medium Severity

LiveWorkEntryTimelineRow only runs workEntryIndicatesToolFailure on the latest live entry. Completed tool groups set hasFailure from every grouped entry. If an earlier call failed and a newer parallel call succeeded, the live pill drops the failure affordance even though the expanded batch still contains the failed call.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit aa78a62bed257493ded9c337ca485de30fdfceee. Configure here.

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision caca860e · right 1467–1468

Original diff
@@ -1370,19 +1395,167 @@ const WorkGroupSection = memo(function WorkGroupSection({
             key={workEntry.id}
             workEntry={workEntry}
             workspaceRoot={workspaceRoot}
+            isExpandedToolGroupEntry={isExpandedToolGroupEntry}
           />
         ))}
       </div>
     </section>
   );
 });
 
+function LiveActivityRow({
+  label,
+  iconName,
+  failed = false,
+}: {
+  label: string;
+  iconName?: WorkEntryIconName;
+  failed?: boolean;
+}) {
+  return (
+    <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
+      <LiveActivityContent label={label} iconName={iconName} failed={failed} announceFailure />
+      <div
+        aria-hidden
+        className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
+      >
+        <div className="live-activity-focus-counter">
+          <div className="live-activity-focus-aligned">
+            <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ThinkingActivityRow() {
+  const { workingStepLabel } = use(TimelineRowActivityCtx);
+  return (
+    <LiveActivityRow label={workingStepLabel ? `Thinking · ${workingStepLabel}` : "Thinking"} />
+  );
+}
+
+function LiveActivityContent({
+  label,
+  iconName,
+  failed = false,
+  announceFailure = false,
+  highlighted = false,
+}: {
+  label: string;
+  iconName: WorkEntryIconName | undefined;
+  failed?: boolean;
+  announceFailure?: boolean;
+  highlighted?: boolean;
+}) {
+  const resolvedIconName = failed ? "x" : iconName;
+
+  return (
+    <div
+      className={cn(
+        "flex min-h-6 min-w-0 items-center gap-1.5 px-0.5 py-0.5",
+        highlighted ? "text-foreground" : "text-secondary-label",
+      )}
+    >
+      {resolvedIconName ? (
+        <span
+          className={cn(
+            "flex size-6 shrink-0 items-center justify-center",
+            failed ? "text-destructive" : highlighted ? "text-foreground" : "text-icon-muted",
+          )}
+          role={announceFailure ? "img" : undefined}
+          aria-label={announceFailure ? "Tool call failed" : undefined}

apps/web/src/components/chat/MessagesTimeline.tsx · right 1467–1468

M
macroscopeappBotAug 16, 2026

chat/MessagesTimeline.tsx:1468

announceFailure is passed unconditionally by LiveActivityRow (line 1417), and it is not gated on failed here, so the icon slot gets role="img" aria-label="Tool call failed" on every live tool row — including healthy inProgress ones. LiveWorkEntryTimelineRow only sets its own aria-label when the entry failed, so a running row's accessible name is computed from its contents and comes out as "Tool call failed Running psql". Suggest gating the announcement on the failure state so it only fires for the x icon.

suggestion
1 role={announceFailure && failed ? "img" : undefined}
2 aria-label={announceFailure && failed ? "Tool call failed" : undefined}

No longer relevant as of b06fab9f30e3227956de80097ee960bd400e6f1f

apps/web/src/components/chat/MessagesTimeline.logic.ts · Earlier revision d4e20531 · right 832

Original diff
@@ -523,15 +812,48 @@ export function deriveMessagesTimelineRows(input: {
         (entry) => !workEntryIndicatesToolNeutralStatus(entry),
       );
       if (visibleGroupedEntries.length > 0) {
-        if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
+        const onlyToolEntries = visibleGroupedEntries.every(
+          (entry) => workLogEntryIsToolLike(entry) && entry.agentSpawn === undefined,
+        );
+        if (onlyToolEntries) {
+          const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
+          const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
+          const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
+          nextRows.push({
+            kind: "work-toggle",
+            id: `work-toggle:${timelineEntry.id}`,
+            createdAt: timelineEntry.createdAt,
+            groupId,
+            hiddenCount: visibleGroupedEntries.length,
+            expanded,
+            onlyToolEntries: true,
+            summary: summarizeToolGroup(visibleGroupedEntries),
+            summaryKind,
+            hasFailure: visibleGroupedEntries.some((entry) => workEntryIndicatesToolFailure(entry)),
C
cursorBotAug 17, 2026

Summary failure ignores command strip

Low Severity

Collapsed tool-group hasFailure calls workEntryIndicatesToolFailure on the full entry, including command. The live row strips command before the same check so search invocations like rg 'command not found' do not look failed. Settled summaries can still show failure chrome from benign command text.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit d4e205311888b825f7796b07c672a484cda751b5. Configure here.

apps/web/src/components/chat/MessagesTimeline.logic.ts · Earlier revision 9ae5f817 · right 711–717

Original diff
@@ -471,38 +657,141 @@ export function deriveMessagesTimelineRows(input: {
     terminalAssistantMessageIds,
     latestTurn: input.latestTurn ?? null,
     unsettledTurnId,
+    expandedTurnIds: input.expandedTurnIds,
   });
   const collapsedEntryIds = new Set<string>();
-  for (const fold of foldsByAnchorEntryId.values()) {
-    if (!input.expandedTurnIds?.has(fold.turnId)) {
-      for (const entryId of fold.hiddenEntryIds) {
-        collapsedEntryIds.add(entryId);
+  for (const folds of foldsByAnchorEntryId.values()) {
+    for (const fold of folds) {
+      if (!input.expandedTurnIds?.has(fold.turnId)) {
+        for (const entryId of fold.hiddenEntryIds) {
+          collapsedEntryIds.add(entryId);
+        }
       }
     }
   }
 
+  let activeTurnHeaderIndex = input.timelineEntries.length;
+  if (input.isWorking) {
+    const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries);
+    const firstOwnedAfterUser =
+      unsettledTurnId === null
+        ? -1
+        : input.timelineEntries.findIndex(
+            (entry, index) =>
+              index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId,
+          );
+    activeTurnHeaderIndex =
+      firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1;
+  }
+  const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) =>
+    input.isWorking &&
+    index >= activeTurnHeaderIndex &&
+    (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId);
+  const isVisibleActiveToolEntry = (entry: WorkLogEntry) =>
+    workLogEntryIsToolLike(entry) &&
+    (entry.toolLifecycleStatus === "inProgress" || !workEntryIndicatesToolNeutralStatus(entry));
+  const activeEntries = input.isWorking
+    ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
+    : [];
+  const activeTurnHasVisibleContent = activeEntries.some((entry) => {
+    if (entry.kind === "message") {
+      return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0;
+    }
+    if (entry.kind === "work") {
+      return (
+        entry.entry.agentSpawn === undefined &&
+        workLogEntryIsToolLike(entry.entry) &&
+        entry.entry.toolLifecycleStatus === "inProgress"
+      );
+    }
+    if (entry.kind === "proposed-plan" || entry.kind === "turn-plan") return true;
+    return false;
+  });
+
+  const activeToolEntries = activeEntries.flatMap((entry) =>
+    entry.kind === "work" &&
+    entry.entry.agentSpawn === undefined &&
+    workLogEntryIsToolLike(entry.entry)
+      ? [entry]
+      : [],
+  );

apps/web/src/components/chat/MessagesTimeline.logic.ts · right 711–717

M
macroscopeappBotAug 17, 2026

chat/MessagesTimeline.logic.ts:717

The settled grouping path deliberately keeps tone === "error" entries out of tool-group summarization (line 816-821, covered by the new "keeps error entries visible instead of summarizing them as tools" test), but this active-turn collector only excludes agentSpawn. workLogEntryIsToolLike returns true for tone error (session-logic.ts:168), and runtime.error / tool.denied activities are emitted with the turn's turnId (ProviderRuntimeIngestion.ts:433-439), so while the turn is running such an entry is added to activeWorkEntryIds and skipped as its own row (line 791-793).

Result: a provider error or denied tool call is visible only if it happens to be the tail of the live batch — any later tool call hides it behind the collapsed live row until the turn settles, at which point it pops back out as a standalone destructive row. Even as the tail it renders through LiveActivityContent, which has no equivalent of PlainWorkEntryRow's destructive label treatment. Suggest applying the same exclusion here so error rows stay outside the live batch in both states.

suggestion
1 const activeToolEntries = activeEntries.flatMap((entry) =>
2 entry.kind === "work" &&
3 entry.entry.agentSpawn === undefined &&
4 entry.entry.tone !== "error" &&
5 workLogEntryIsToolLike(entry.entry)
6 ? [entry]
7 : [],
8 );

No longer relevant as of 4dcdd9652028633c90a77a8adc340a8943583457

apps/web/src/components/chat/MessagesTimeline.logic.ts · Earlier revision aa78a62b · right 358

Original diff
@@ -242,6 +262,106 @@ export function normalizeCompactToolLabel(value: string): string {
   return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
 }
 
+type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other";
+
+export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {
+  return (
+    entry.itemType === "web_search" &&
+    /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))
+  );
+}
+
+function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
+  if (
+    entry.requestKind === "file-read" ||
+    entry.itemType === "image_view" ||
+    (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File")
+  ) {
+    return "read";
+  }
+  if (
+    entry.requestKind === "file-change" ||
+    entry.itemType === "file_change" ||
+    (entry.changedFiles?.length ?? 0) > 0
+  ) {
+    return "edit";
+  }
+  if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) {
+    return "command";
+  }
+  if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
+  if (entry.itemType === "web_search") return "search";
+  return "other";
+}
+
+function toolGroupActionCount(
+  action: ToolGroupAction,
+  entries: ReadonlyArray<WorkLogEntry>,
+): number {
+  if (action !== "edit") return entries.length;
+
+  const changedFiles = new Set<string>();
+  let editsWithoutFileDetails = 0;
+  for (const entry of entries) {
+    if (!entry.changedFiles || entry.changedFiles.length === 0) {
+      editsWithoutFileDetails += 1;
+      continue;
+    }
+    for (const file of entry.changedFiles) changedFiles.add(file);
+  }
+  return changedFiles.size + editsWithoutFileDetails;
+}
+
+function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
+  switch (action) {
+    case "read":
+      return `Read ${count} ${count === 1 ? "file" : "files"}`;
+    case "edit":
+      return `Changed ${count} ${count === 1 ? "file" : "files"}`;
+    case "command":
+      return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
+    case "search":
+      return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
+    case "code-search":
+      return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
+    case "other":
+      return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
+  }
+}
+
+/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */
+export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): string {
+  const groupedEntries = new Map<ToolGroupAction, WorkLogEntry[]>();
+  for (const entry of entries) {
+    const action = toolGroupAction(entry);
+    const group = groupedEntries.get(action);
+    if (group) group.push(entry);
+    else groupedEntries.set(action, [entry]);
+  }
+  const labels = [...groupedEntries].map(([action, actionEntries]) =>
+    toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)),
+  );
+  const sentenceLabels = labels.map((label, index) =>
+    index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
+  );
+  if (sentenceLabels.length < 2) return sentenceLabels[0] ?? "";
+  if (sentenceLabels.length === 2) return sentenceLabels.join(" and ");
+  return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
+}
+
+function toolGroupSummaryKind(entries: ReadonlyArray<WorkLogEntry>): ToolGroupAction | "mixed" {
+  const actions = new Set(entries.map(toolGroupAction));
+  return actions.size === 1 ? actions.values().next().value! : "mixed";
+}
+
+function workGroupIdentity(timelineEntryId: string, entry: WorkLogEntry): string {
+  return entry.toolCallId ? `tool:${entry.toolCallId}` : timelineEntryId;
M
macroscopeappBotAug 17, 2026

🟡 Medium chat/MessagesTimeline.logic.ts:358

The live group's identity changes when its first tool call becomes neutral, so an expanded group loses its expandedWorkGroupIds match and remounts as the next call's group. activeWorkAnchor is selected from visibleActiveToolEntries, causing this function to receive a different toolCallId; derive the group key from a stable underlying entry (for example, the first activeToolEntries item) while keeping the latest visible entry for display.

✅ Resolved in 50b35d866d9d43c5cf2cfa3dbaa4907c8e0716d9

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision 65317530 · right 1441–1453

Original diff
@@ -1370,19 +1397,141 @@ const WorkGroupSection = memo(function WorkGroupSection({
             key={workEntry.id}
             workEntry={workEntry}
             workspaceRoot={workspaceRoot}
+            isExpandedToolGroupEntry={isExpandedToolGroupEntry}
           />
         ))}
       </div>
     </section>
   );
 });
 
+function LiveActivityRow({ label, iconName }: { label: string; iconName?: WorkEntryIconName }) {
+  return (
+    <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
+      <LiveActivityContent label={label} iconName={iconName} />
+      <div aria-hidden className="live-activity-focus pointer-events-none absolute inset-y-0">
+        <div className="live-activity-focus-counter">
+          <div className="live-activity-focus-aligned">
+            <LiveActivityContent label={label} iconName={iconName} highlighted />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ThinkingActivityRow() {
+  const { workingStepLabel } = use(TimelineRowActivityCtx);
+  return (
+    <LiveActivityRow label={workingStepLabel ? `Thinking · ${workingStepLabel}` : "Thinking"} />
+  );
+}
+
+function LiveActivityContent({
+  label,
+  iconName,
+  highlighted = false,
+}: {
+  label: string;
+  iconName: WorkEntryIconName | undefined;
+  highlighted?: boolean;
+}) {
+  return (
+    <div
+      className={cn(
+        "flex min-h-6 min-w-0 items-center gap-1.5 px-1 py-0.5",
+        highlighted ? "text-foreground" : "text-secondary-label",
+      )}
+    >
+      {iconName ? (
+        <span
+          className={cn(
+            "flex size-6 shrink-0 items-center justify-center",
+            highlighted ? "text-foreground" : "text-icon-muted",
+          )}
+        >
+          <WorkEntryIconSvg name={iconName} className="block size-4 shrink-0 stroke-[1.8]" />

apps/web/src/components/chat/MessagesTimeline.tsx · right 1441–1453

M
macroscopeappBotAug 16, 2026

The non-highlighted copy of this row is meant to read like the static activity rows it stacks with, but it differs from them in two ways:

  • The icon drops opacity-70. WorkGroupToggleTimelineRow's summary icon (line 1527) and PlainWorkEntryRow's icon (line 2602) both render text-icon-muted + opacity-70, so the live row shows the same icon set at a visibly darker tone than the rows directly above and below it, even though its label uses the identical text-secondary-label.
  • The content inset is px-1 rather than the px-0.5 those rows use, so the live row's icon/label column sits 2px right of the tool-group summary row and of its own expanded children (which are rendered through PlainWorkEntryRow), giving one disclosure group a ragged left edge.

Suggest restoring the muted icon tone here, and using px-0.5 when an icon is present while keeping px-1 for the icon-less Thinking row so it stays aligned with the "Working for" label above it.

suggestion
1 <WorkEntryIconSvg
2 name={iconName}
3 className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}
4 />

Posted via Macroscope — UI Consistency

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision b99f4107 · right 1455–1455

Original diff
@@ -1370,19 +1391,167 @@ const WorkGroupSection = memo(function WorkGroupSection({
             key={workEntry.id}
             workEntry={workEntry}
             workspaceRoot={workspaceRoot}
+            isExpandedToolGroupEntry={isExpandedToolGroupEntry}
           />
         ))}
       </div>
-    </section>
+    </GroupContainer>
   );
 });
 
+function LiveActivityRow({
+  label,
+  iconName,
+  failed = false,
+}: {
+  label: string;
+  iconName?: WorkEntryIconName;
+  failed?: boolean;
+}) {
+  return (
+    <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
+      <LiveActivityContent
+        label={label}
+        iconName={iconName}
+        failed={failed}
+        announceFailure={failed}
+      />
+      <div
+        aria-hidden
+        className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
+      >
+        <div className="live-activity-focus-counter">
+          <div className="live-activity-focus-aligned">
+            <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ThinkingActivityRow() {
+  return <LiveActivityRow label="Thinking" />;
+}
+
+function LiveActivityContent({
+  label,
+  iconName,
+  failed = false,
+  announceFailure = false,
+  highlighted = false,
+}: {
+  label: string;
+  iconName: WorkEntryIconName | undefined;
+  failed?: boolean;
+  announceFailure?: boolean;
+  highlighted?: boolean;
+}) {
+  const resolvedIconName = failed ? "x" : iconName;
+
+  return (
+    <div
+      className={cn(
+        "flex min-h-6 min-w-0 items-center gap-1.5 px-1 py-0.5",

apps/web/src/components/chat/MessagesTimeline.tsx · right 1455–1455

M
macroscopeappBotAug 17, 2026

chat/MessagesTimeline.tsx:1455

The new live rows inset at px-1, but every settled row in the same activity column insets at px-0.5: the tool-group summary button (line 1533) and PlainWorkEntryRow (line 2646, whose WorkGroupSection wrapper is -mx-1 px-1, i.e. net zero). So the running tool's icon and label sit 2px right of the identical call once it settles into Used 1 tool, and expanding the live row indents its own children 2px left of the header that discloses them — the column this PR unifies to size-6/size-4/text-sm ends up with two x-origins.

Suggest matching the column's px-0.5 here; the shimmer geometry comes from the live-activity-focus* utilities, so it is unaffected. The gap-1.5 px-1 py-0.5 assertion in MessagesTimeline.test.tsx needs the same update.

suggestion
1 "flex min-h-6 min-w-0 items-center gap-1.5 px-0.5 py-0.5",

No longer relevant as of 4816e2c5c929842a3f13ba60496401d64b476023

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision c2f4f354 · right 1116–1116

Original diff
@@ -1093,7 +1113,7 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
         aria-expanded={row.expanded}
         data-scroll-anchor-ignore
         onClick={() => ctx.onToggleTurnFold(row.turnId)}
-        className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-xs text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
+        className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"

apps/web/src/components/chat/MessagesTimeline.tsx · right 1116–1116

M
macroscopeappBotAug 16, 2026

WorkingTimelineRow now renders the same shell as this fold header (border-b border-border/60 pb-2 pt-1 wrapper, px-1 text-sm leading-relaxed … tabular-nums label), so "Working for 12s" and "Worked for 12s" are visual twins that swap in place when a turn settles — but they resolve different tokens: this row stays text-muted-foreground while the working header (line 1306) and every activity line this fold reveals (LiveActivityContent line 1465, the tool-group summary line 1562, PlainWorkEntryRow line 2605) use text-secondary-label. --secondary-label only aliases --muted-foreground in the default palette; imported themes derive it from a different palette entry (themePalette.ts line 1185 textMuted vs line 1183 mutedForeground), so under a custom theme the header visibly changes color the moment the turn completes. Suggest using the same token for both headers.

suggestion
1 className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-secondary-label tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"

Posted via Macroscope — UI Consistency

apps/web/src/index.css · Earlier revision d3d2a2df · right 142–143

Original diff
@@ -139,6 +139,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
   /* Duty-cycled indicator animations: long holds with stepped ramps, so the
      compositor updates discrete frames instead of every vsync. */
   --animate-status-pulse: status-pulse 2s infinite;
+  --animate-live-activity-focus: live-activity-focus 2.2s steps(24) infinite;
+  --animate-live-activity-focus-counter: live-activity-focus-counter 2.2s steps(24) infinite;

apps/web/src/index.css · right 142–143

M
macroscopeappBotAug 16, 2026

src/index.css:143

These two tokens are never consumed: no animate-live-activity-focus / animate-live-activity-focus-counter class exists anywhere, and the @utility live-activity-focus (line 482) and @utility live-activity-focus-counter (line 494) hardcode the same 2.2s steps(24) infinite shorthand. That leaves the animation timing with two sources of truth, one of which is inert — editing the token silently changes nothing. Because this is @theme inline, the variables are not emitted either, so the utilities cannot reference them via var().

Suggest dropping the dead tokens and keeping the shorthand in the utilities as the single owner; the @keyframes stay in the output because Tailwind retains @theme keyframes whose name appears in an emitted animation: declaration.

diff
1 --animate-status-pulse: status-pulse 2s infinite;
2- --animate-live-activity-focus: live-activity-focus 2.2s steps(24) infinite;
3- --animate-live-activity-focus-counter: live-activity-focus-counter 2.2s steps(24) infinite;
4 --animate-ghost-pulse: ghost-pulse 2.4s infinite;

No longer relevant as of a95aceb534eb0828d41b98fecb21d880c7ad7262

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision bb47d9de · right 2300

Original diff
@@ -2035,6 +2207,161 @@ function workEntryRawCommand(
   return rawCommand === workEntry.command.trim() ? null : rawCommand;
 }
 
+type CommandWrapper = "env" | "sudo";
+
+const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
+  env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
+  sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
+};
+
+const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = {
+  env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug"]),
+  sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]),
+};
+
+function tokenizeShellCommand(command: string): string[] | null {
+  const input = command.trim();
+  const tokens: string[] = [];
+  let current = "";
+  let quote: '"' | "'" | null = null;
+  let escaping = false;
+  let tokenStarted = false;
+
+  for (let index = 0; index < input.length; index += 1) {
+    const character = input[index]!;
+    if (escaping) {
+      current += character;
+      escaping = false;
+      tokenStarted = true;
+      continue;
+    }
+    if (character === "\\" && quote !== "'") {
+      const nextCharacter = input[index + 1];
+      if (
+        quote === '"' &&
+        nextCharacter !== undefined &&
+        nextCharacter !== '"' &&
+        nextCharacter !== "\\" &&
+        nextCharacter !== "$" &&
+        nextCharacter !== "`" &&
+        nextCharacter !== "\n"
+      ) {
+        current += character;
+        tokenStarted = true;
+        continue;
+      }
+      escaping = true;
+      tokenStarted = true;
+      continue;
+    }
+    if (quote !== null) {
+      if (character === quote) {
+        quote = null;
+      } else {
+        current += character;
+      }
+      tokenStarted = true;
+      continue;
+    }
+    if (character === '"' || character === "'") {
+      quote = character;
+      tokenStarted = true;
+      continue;
+    }
+    if (/\s/u.test(character)) {
+      if (tokenStarted) {
+        tokens.push(current);
+        current = "";
+        tokenStarted = false;
+      }
+      continue;
+    }
+    current += character;
+    tokenStarted = true;
+  }
+
+  if (quote !== null || escaping) return null;
+  if (tokenStarted) tokens.push(current);
+  return tokens;
+}
+
+function commandProgramName(command: string): string | null {
+  const tokens = tokenizeShellCommand(command);
+  if (tokens === null) return null;
+  let index = 0;
+  let wrapper: CommandWrapper | null = null;
+
+  while (index < tokens.length) {
+    const token = tokens[index];
+    if (!token) return null;
+    if (wrapper !== "sudo" && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
+      index += 1;
+      continue;
+    }
C
cursorBotAug 17, 2026

Sudo env assignment mislabeled

Low Severity

commandProgramName skips VAR=value tokens for bare commands and env, but not after sudo. For sudo FOO=bar npm test, the live label becomes Running FOO=bar instead of Running npm, so the activity pill shows the environment assignment rather than the program being run.

Reviewed by Cursor Bugbot for commit bb47d9dea0c1715239b6290110709de5c82d0679. Configure here.

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision 11bd37f3 · right 2667–2670

Original diff
@@ -2289,84 +2643,35 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {
   return (
     <div
       className={cn(
-        "flex flex-col rounded-md px-0.5 py-0.5 transition-colors",
+        "flex flex-col rounded-md px-0.5 transition-colors",
+        isExpandedToolGroupEntry ? "py-0" : "py-0.5",
         canExpand &&
           "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70",
       )}
       {...rowToggleProps}
     >
       <div className="flex select-none items-center gap-1.5 transition-[opacity,translate] duration-200">
-        <span className={iconWrapperClass}>
+        <span
+          className={cn(iconWrapperClass, !showEntryIcon && "invisible")}
+          role={showFailedIndicator ? "img" : undefined}
+          aria-label={showFailedIndicator ? "Tool call failed" : undefined}
+          aria-hidden={!showEntryIcon}
+        >
           <WorkEntryIconSvg
             name={entryIconName}
-            className="block size-3.5 shrink-0 stroke-[1.8] opacity-80"
+            className="block size-4 shrink-0 stroke-[1.8] opacity-70"
           />
         </span>
         <div className="flex min-w-0 flex-1 items-center gap-1.5">
           <div className="min-w-0 flex-1 overflow-hidden">
-            <p className="flex min-w-0 w-full items-baseline gap-1.5 text-[12px] leading-5">
-              <span className={cn("min-w-0 shrink truncate", headingClass)}>{heading}</span>
-              {preview && (
-                <span className="min-w-0 flex-1 truncate text-secondary-label">{preview}</span>
+            <p
+              className={cn(
+                "flex min-w-0 w-full items-baseline gap-1.5 leading-relaxed",
+                isExpandedToolGroupEntry ? "text-[12px] leading-5" : "text-sm",

apps/web/src/components/chat/MessagesTimeline.tsx · right 2667–2670

M
macroscopeappBotAug 17, 2026

chat/MessagesTimeline.tsx:2670

Expanded tool-group children are now the only rows left on the old 12px scale. This PR moves the whole activity column to text-sm leading-relaxed with a min-h-6 row box — the turn fold (line 1110), the working header (line 1300), the live row (line 1412), and both work-toggle branches (lines 1533, 1566) — while the row revealed under those headers keeps text-[12px] leading-5 and, combined with py-0 on line 2647, has no min-h-6, so it renders ~20px tall even though it is still role="button" when the call has output.

The live row makes the mismatch most visible: appendActiveWorkRows renders its grouped entries through this same path, so the entry shown as Running psql at 14px in the header reappears at 12px in the child row directly below it, in the same size-6/size-4 icon column and at the same indent.

Suggest giving the expanded child the column's scale and minimum height (keeping only the tighter vertical padding for density):

suggestion
1 "text-sm",

No longer relevant as of 2fe727dca96cf12d501bdd9d2e9170c438c170db

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision 4dcdd965 · right 1540–1549

Original diff
@@ -1370,41 +1391,203 @@ const WorkGroupSection = memo(function WorkGroupSection({
             key={workEntry.id}
             workEntry={workEntry}
             workspaceRoot={workspaceRoot}
+            isExpandedToolGroupEntry={isExpandedToolGroupEntry}
           />
         ))}
       </div>
-    </section>
+    </GroupContainer>
   );
 });
 
+function LiveActivityRow({
+  label,
+  iconName,
+  failed = false,
+}: {
+  label: string;
+  iconName?: WorkEntryIconName;
+  failed?: boolean;
+}) {
+  return (
+    <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
+      <LiveActivityContent
+        label={label}
+        iconName={iconName}
+        failed={failed}
+        announceFailure={failed}
+      />
+      <div
+        aria-hidden
+        className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
+      >
+        <div className="live-activity-focus-counter">
+          <div className="live-activity-focus-aligned">
+            <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ThinkingActivityRow() {
+  return <LiveActivityRow label="Thinking" />;
+}
+
+function LiveActivityContent({
+  label,
+  iconName,
+  failed = false,
+  announceFailure = false,
+  highlighted = false,
+}: {
+  label: string;
+  iconName: WorkEntryIconName | undefined;
+  failed?: boolean;
+  announceFailure?: boolean;
+  highlighted?: boolean;
+}) {
+  const resolvedIconName = failed ? "x" : iconName;
+
+  return (
+    <div
+      className={cn(
+        "flex min-h-6 min-w-0 items-center gap-1.5 px-0.5 py-0.5",
+        highlighted ? "text-foreground" : "text-secondary-label",
+      )}
+    >
+      {resolvedIconName ? (
+        <span
+          className={cn(
+            "flex size-6 shrink-0 items-center justify-center",
+            failed ? "text-destructive" : highlighted ? "text-foreground" : "text-icon-muted",
+          )}
+          role={announceFailure ? "img" : undefined}
+          aria-label={announceFailure ? "Tool call failed" : undefined}
+        >
+          <WorkEntryIconSvg
+            name={resolvedIconName}
+            className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}
+          />
+        </span>
+      ) : null}
+      <span className="min-w-0 flex-1 truncate">{label}</span>
+    </div>
+  );
+}
+
+function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
+  const ctx = use(TimelineRowCtx);
+  const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot);
+  const failed = row.groupedEntries.some((entry) => workEntryDisplayIndicatesToolFailure(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-expanded={row.expanded}
+      onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
+    >
+      <LiveActivityRow label={label} iconName={workEntryIconName(row.entry)} failed={failed} />
+    </button>
+  );
+}
+
+function toolGroupSummaryIconName(
+  kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"],
+): WorkEntryIconName {
+  switch (kind) {
+    case "read":
+      return "eye";
+    case "edit":
+      return "square-pen";
+    case "command":
+      return "terminal";
+    case "search":
+      return "globe";
+    case "code-search":
+      return "search";
+    case "other":
+      return "wrench";
+    case "dynamic-tool":
+      return "hammer";
+    case "agent-tool":
+      return "bot";
+    case "tone-tool":
+      return "zap";
+    case "mixed":
+    case null:
+      return "hammer";
+  }
+}
+
 function WorkGroupToggleTimelineRow({
   row,
 }: {
   row: Extract<TimelineRow, { kind: "work-toggle" }>;
 }) {
   const ctx = use(TimelineRowCtx);
+  if (row.onlyToolEntries && row.summary) {
+    return (
+      <button
+        type="button"
+        className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
+        aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
+        aria-expanded={row.expanded}
+        onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
+      >
+        <span
+          className={cn(
+            "flex size-6 shrink-0 items-center justify-center",
+            row.hasFailure ? "text-destructive" : "text-icon-muted",
+          )}
+          role={row.hasFailure ? "img" : undefined}
+          aria-label={row.hasFailure ? "Tool call failed" : undefined}
+        >
+          <WorkEntryIconSvg
+            name={row.hasFailure ? "x" : toolGroupSummaryIconName(row.summaryKind)}

apps/web/src/components/chat/MessagesTimeline.tsx · right 1540–1549

M
macroscopeappBotAug 17, 2026

The aggregate failure icon here is not gated on collapse, so an expanded summary group swaps its category icon (eye / terminal / square-pen) for a destructive x and announces …, tool call failed while every failing child row below already renders its own x plus Tool call failed (PlainWorkEntryRow, lines 2620-2686).

The overflow toggle immediately below now avoids exactly that with showHiddenFailure = row.hasFailure && !row.expanded (line 1564), so the two disclosures in the same activity column disagree about aggregate failure state — one drops back to its normal icon when the failing rows become visible, the other doesn't. LiveWorkEntryTimelineRow (line 1482) has the same gap for its expanded batch.

Suggest deriving const showGroupFailure = row.hasFailure && !row.expanded; and using it for the icon name, wrapper color, role/aria-label, and the button's aria-label here (and the equivalent in the live row), so the aggregate marker only stands in for hidden rows.

Posted via Macroscope — UI Consistency

M
mariaAug 19, 2026

Intentional: the group summary keeps its aggregate failure identity while expanded. Expanded children explain which calls failed; they do not replace the status of the group. The overflow toggle differs because it describes only hidden rows.

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision 10b4e43e · right 2232–2232

Original diff
@@ -2035,6 +2221,170 @@ function workEntryRawCommand(
   return rawCommand === workEntry.command.trim() ? null : rawCommand;
 }
 
+type CommandWrapper = "env" | "sudo";
+
+const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
+  env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
+  sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
+};
+
+const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = {
+  env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug"]),

apps/web/src/components/chat/MessagesTimeline.tsx · right 2232–2232

M
macroscopeappBotAug 19, 2026

🟡 Medium chat/MessagesTimeline.tsx:2232

commandProgramName returns null for env -v npm test, so the UI reports Running command instead of Running npm. COMMAND_WRAPPER_FLAGS.env includes --debug but not GNU env's equivalent -v; add the alias so this valid flag is skipped.

✅ Resolved in f19ee2b971e375e51d5637424f1687537acd5fa9

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision 4dcdd965 · right 917–919

Original diff
@@ -921,25 +912,48 @@ type TimelineWorkEntry = Extract<MessagesTimelineRow, { kind: "work" }>["grouped
 type TimelineRow = MessagesTimelineRow;
 
 const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
+  const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;
+  const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
+  const isExpandedToolGroupHeader =
+    (row.kind === "work-toggle" && row.onlyToolEntries && row.expanded) ||
+    (row.kind === "work-live" && row.expanded);

apps/web/src/components/chat/MessagesTimeline.tsx · right 917–919

M
macroscopeappBotAug 17, 2026

chat/MessagesTimeline.tsx:919

isExpandedToolGroupHeader keys on row.onlyToolEntries && row.expanded, but the renderer only draws a group header (children below it) when row.onlyToolEntries && row.summary (WorkGroupToggleTimelineRow, line 1531). The overflow toggle — summary: null, pushed after its revealed rows in MessagesTimeline.logic.ts (lines 881-906) — can also carry onlyToolEntries: true, since it is now derived from hiddenEntries and workLogEntryIsToolLike is true for tone: "error" rows. With MAX_VISIBLE_WORK_LOG_ENTRIES = 1, a two-entry mixed group (e.g. one runtime.error row plus one tool row) hits exactly that: expanding it gives the trailing +1 previous tool call row pb-0 instead of pb-2, so it collapses against the next unrelated row while every revealed row above it keeps normal spacing.

Suggest gating on the same condition the renderer uses so the two decisions cannot drift:

suggestion
1 const isExpandedToolGroupHeader =
2 (row.kind === "work-toggle" && row.onlyToolEntries && !!row.summary && row.expanded) ||
3 (row.kind === "work-live" && row.expanded);

No longer relevant as of 96c8a4df8d37b35ef87d3c9cf78e6bf35d1ece76

apps/web/src/session-logic.ts · Earlier revision 17f6f2be · right 1075–1080

Original diff
@@ -993,12 +1043,61 @@ function collapseDerivedWorkLogEntries(
       });
       continue;
     }
+    const lifecycleKey = toolLifecycleCollapseMapKey(entry);
+    if (lifecycleKey !== undefined) {
+      const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey);
+      if (matchingLifecycleIndex !== undefined) {
+        const matchingEntry = collapsed[matchingLifecycleIndex];
+        if (matchingEntry?.activityKind === "tool.completed") {
+          collapsed[matchingLifecycleIndex] =
+            entry.activityKind === "tool.completed"
+              ? mergeDerivedWorkLogEntries(matchingEntry, entry)
+              : mergeDerivedWorkLogEntries(entry, matchingEntry);
+          continue;
+        }
+        if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) {
+          const merged = mergeDerivedWorkLogEntries(matchingEntry, entry);
+          collapsed[matchingLifecycleIndex] = merged;
+          toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex);
+          continue;
+        }
+        toolLifecycleRowIndex.delete(lifecycleKey);
+      }
+    }
     const previous = collapsed.at(-1);
-    if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
-      collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
+    const hasCompetingIdlessCompletionTarget =
+      previous?.activityKind === "tool.started" &&
+      entry.activityKind === "tool.completed" &&
+      entry.toolCallId === undefined &&
+      collapsed
+        .slice(0, -1)
+        .some(
+          (candidate) =>
+            candidate.activityKind !== "tool.completed" &&
+            candidate.itemType === previous.itemType &&
+            normalizeCompactToolLabel(candidate.toolTitle ?? candidate.label) ===
+              normalizeCompactToolLabel(previous.toolTitle ?? previous.label),
+        );

apps/web/src/session-logic.ts · right 1075–1080

M
macroscopeappBotAug 19, 2026

🟡 Medium src/session-logic.ts:1075

An unfinished same-type/same-label tool row from an older turn makes a current turn's adjacent tool.started/tool.completed pair remain as two rows, so the lifecycle is not collapsed. hasCompetingIdlessCompletionTarget scans all earlier collapsed rows without matching candidate.turnId to previous.turnId; restrict that search to the same turn, as shouldCollapseToolLifecycleEntries does.

✅ Resolved in 374c3d5638887c480344a72db072c15d8126b28b

apps/web/src/components/chat/MessagesTimeline.logic.ts · Earlier revision 17f6f2be · right 386

Original diff
@@ -242,6 +262,173 @@ export function normalizeCompactToolLabel(value: string): string {
   return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
 }
 
+type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other";
+type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed";
+
+export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {
+  return (
+    entry.itemType === "web_search" &&
+    /\bgrep\b/i.test(normalizeCompactToolLabel(entry.toolTitle ?? entry.label))
+  );
+}
+
+function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
+  if (
+    entry.requestKind === "file-read" ||
+    entry.itemType === "image_view" ||
+    (entry.itemType === "dynamic_tool_call" && entry.toolTitle === "Read File")
+  ) {
+    return "read";
+  }
+  if (
+    entry.requestKind === "file-change" ||
+    entry.itemType === "file_change" ||
+    (entry.changedFiles?.length ?? 0) > 0
+  ) {
+    return "edit";
+  }
+  if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) {
+    return "command";
+  }
+  if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
+  if (entry.itemType === "web_search") return "search";
+  return "other";
+}
+
+function toolGroupActionCount(
+  action: ToolGroupAction,
+  entries: ReadonlyArray<WorkLogEntry>,
+): number {
+  if (action !== "edit") return entries.length;
+
+  const changedFiles = new Set<string>();
+  let editsWithoutFileDetails = 0;
+  for (const entry of entries) {
+    if (!entry.changedFiles || entry.changedFiles.length === 0) {
+      editsWithoutFileDetails += 1;
+      continue;
+    }
+    for (const file of entry.changedFiles) changedFiles.add(file);
+  }
+  return changedFiles.size + editsWithoutFileDetails;
+}
+
+function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
+  switch (action) {
+    case "read":
+      return `Read ${count} ${count === 1 ? "file" : "files"}`;
+    case "edit":
+      return `Changed ${count} ${count === 1 ? "file" : "files"}`;
+    case "command":
+      return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
+    case "search":
+      return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
+    case "code-search":
+      return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
+    case "other":
+      return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
+  }
+}
+
+/** Immediate, provider-neutral fallback while generated tool summaries are disabled or unavailable. */
+export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): string {
+  const summaryEntries = toolGroupSummaryEntries(entries);
+  const groupedEntries = new Map<ToolGroupAction, WorkLogEntry[]>();
+  for (const entry of summaryEntries) {
+    const action = toolGroupAction(entry);
+    const group = groupedEntries.get(action);
+    if (group) group.push(entry);
+    else groupedEntries.set(action, [entry]);
+  }
+  const labels = [...groupedEntries].map(([action, actionEntries]) =>
+    toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)),
+  );
+  const sentenceLabels = labels.map((label, index) =>
+    index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
+  );
+  if (sentenceLabels.length < 2) return sentenceLabels[0] ?? "";
+  if (sentenceLabels.length === 2) return sentenceLabels.join(" and ");
+  return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
+}
+
+function toolGroupSummaryEntries(
+  entries: ReadonlyArray<WorkLogEntry>,
+): ReadonlyArray<WorkLogEntry> {
+  const terminalEntries = entries.filter(
+    (entry) =>
+      (entry.sourceActivityKind !== "tool.started" &&
+        entry.sourceActivityKind !== "tool.updated") ||
+      (entry.toolLifecycleStatus !== undefined && entry.toolLifecycleStatus !== "inProgress"),
+  );
+  return terminalEntries.length > 0 ? terminalEntries : entries;
+}
+
+function omitSupersededLiveLifecycleMarkers<T extends { entry: WorkLogEntry }>(
+  entries: readonly T[],
+): T[] {
+  const laterTerminalIdentities = new Set<string>();
+  const reversedEntries: T[] = [];
+
+  for (let index = entries.length - 1; index >= 0; index -= 1) {
+    const entry = entries[index]!;
+    const workEntry = entry.entry;
+    const normalizedLabel = normalizeCompactToolLabel(workEntry.toolTitle ?? workEntry.label);
+    const identity = [
+      workEntry.turnId ?? "no-turn",
+      workEntry.itemType ?? "",
+      normalizedLabel,
+    ].join("\u001f");
+    const isStatuslessIdlessMarker =
+      workEntry.toolCallId === undefined &&
+      workEntry.toolLifecycleStatus === undefined &&
+      (workEntry.sourceActivityKind === "tool.started" ||
+        workEntry.sourceActivityKind === "tool.updated");
+    if (isStatuslessIdlessMarker && laterTerminalIdentities.has(identity)) continue;
M
macroscopeappBotAug 19, 2026

🟡 Medium chat/MessagesTimeline.logic.ts:386

With concurrent same-label calls ordered started(A), started(B), completed(A), the live timeline drops B's still-running marker and shows only the completed call. The backward scan records one shared identity for A and B, so A's terminal marker supersedes both; correlate lifecycle events with a unique call identity before suppressing markers.

No longer relevant as of 6d25760e673c6eb1110d8ecf7692b1a75dab44e3

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision b06fab9f · right 1522–1523

Original diff
@@ -1370,19 +1397,171 @@ const WorkGroupSection = memo(function WorkGroupSection({
             key={workEntry.id}
             workEntry={workEntry}
             workspaceRoot={workspaceRoot}
+            isExpandedToolGroupEntry={isExpandedToolGroupEntry}
           />
         ))}
       </div>
-    </section>
+    </GroupContainer>
   );
 });
 
+function LiveActivityRow({
+  label,
+  iconName,
+  failed = false,
+}: {
+  label: string;
+  iconName?: WorkEntryIconName;
+  failed?: boolean;
+}) {
+  return (
+    <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
+      <LiveActivityContent
+        label={label}
+        iconName={iconName}
+        failed={failed}
+        announceFailure={failed}
+      />
+      <div
+        aria-hidden
+        className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
+      >
+        <div className="live-activity-focus-counter">
+          <div className="live-activity-focus-aligned">
+            <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ThinkingActivityRow() {
+  const { workingStepLabel } = use(TimelineRowActivityCtx);
+  return (
+    <LiveActivityRow label={workingStepLabel ? `Thinking · ${workingStepLabel}` : "Thinking"} />
+  );
+}
+
+function LiveActivityContent({
+  label,
+  iconName,
+  failed = false,
+  announceFailure = false,
+  highlighted = false,
+}: {
+  label: string;
+  iconName: WorkEntryIconName | undefined;
+  failed?: boolean;
+  announceFailure?: boolean;
+  highlighted?: boolean;
+}) {
+  const resolvedIconName = failed ? "x" : iconName;
+
+  return (
+    <div
+      className={cn(
+        "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5",
+        resolvedIconName ? "px-0.5" : "px-1",
+        highlighted ? "text-foreground" : "text-secondary-label",
+      )}
+    >
+      {resolvedIconName ? (
+        <span
+          className={cn(
+            "flex size-6 shrink-0 items-center justify-center",
+            failed ? "text-destructive" : highlighted ? "text-foreground" : "text-icon-muted",
+          )}
+          role={announceFailure ? "img" : undefined}
+          aria-label={announceFailure ? "Tool call failed" : undefined}
+        >
+          <WorkEntryIconSvg
+            name={resolvedIconName}
+            className="block size-4 shrink-0 stroke-[1.8] opacity-70"
+          />
+        </span>
+      ) : null}
+      <span className="min-w-0 flex-1 truncate">{label}</span>
+    </div>
+  );
+}
+
+function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
+  const ctx = use(TimelineRowCtx);
+  const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot);
+  const failed = workEntryIndicatesToolFailure(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-expanded={row.expanded}
+      onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
+    >
+      <LiveActivityRow label={label} iconName={workEntryIconName(row.entry)} failed={failed} />
+    </button>
+  );
+}
+
+function toolGroupSummaryIconName(
+  kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"],
+): WorkEntryIconName {
+  switch (kind) {
+    case "read":
+      return "eye";
+    case "edit":
+      return "square-pen";
+    case "command":
+      return "terminal";
+    case "search":
+      return "globe";
+    case "code-search":
+      return "search";
+    case "other":
+      return "wrench";
+    case "mixed":

apps/web/src/components/chat/MessagesTimeline.tsx · right 1522–1523

M
macroscopeappBotAug 17, 2026

chat/MessagesTimeline.tsx:1523

The two icon classifiers still disagree for the non-read cases. toolGroupAction funnels dynamic_tool_call, collab_agent_tool_call and tone-only tool entries into other, so the summary row draws wrench, while workEntryIconName gives those same entries hammer (line 2400) and bot (line 2402). One dynamic tool call therefore renders a hammer in the work-live row, switches to a wrench once it settles into Used 1 tool, and switches back to a hammer on the expanded child row — the icon changes twice for a single unchanged call.

The Read File case was fixed by teaching workEntryIconName about it; suggest closing the remaining gap the same way, by deriving this summary glyph from the group's per-entry workEntryIconName when the entries agree (which needs the entries, or their resolved icon name, carried on the work-toggle row) and keeping the action mapping only as the mixed-group fallback. Mapping other straight to hammer is not enough on its own, since it would then disagree with mcp_tool_call rows.

No longer relevant as of 9ae5f817411576b476a9e57b6680a2dbe9e3ca02

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision 2cfabfe0 · right 2203

Original diff
@@ -2025,32 +2174,152 @@ function workEntryPreview(
     : `${displayPath} +${workEntry.changedFiles!.length - 1} more`;
 }
 
-function workEntryRawCommand(
-  workEntry: Pick<TimelineWorkEntry, "command" | "rawCommand">,
-): string | null {
-  const rawCommand = workEntry.rawCommand?.trim();
-  if (!rawCommand || !workEntry.command) {
-    return null;
+type CommandWrapper = "env" | "sudo";
+
+const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
+  env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
+  sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
+};
+
+const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = {
+  env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug"]),
+  sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]),
+};
+
+function tokenizeShellCommand(command: string): string[] | null {
+  const tokens: string[] = [];
+  let current = "";
+  let quote: '"' | "'" | null = null;
+  let escaping = false;
+  let tokenStarted = false;
+
+  for (const character of command.trim()) {
+    if (escaping) {
+      current += character;
+      escaping = false;
+      tokenStarted = true;
+      continue;
+    }
+    if (character === "\\" && quote !== "'") {
M
macroscopeappBotAug 16, 2026

🟡 Medium chat/MessagesTimeline.tsx:2203

tokenizeShellCommand removes the backslashes in quoted Windows paths, so "C:\Program Files\node.exe" script.js becomes C:Program Filesnode.exe; commandProgramName then cannot split the path and displays Running C:Program Filesnode.exe instead of Running node.exe. Preserve Windows path separators while retaining shell escape handling.

✅ Resolved in d4e205311888b825f7796b07c672a484cda751b5

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision 3a54895e · right 2508–2532

Original diff
@@ -2189,71 +2502,94 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
         : "working"
     : failed > 0
       ? `${failed} failed`
-      : "✓ completed";
+      : "Completed";
 
   return (
-    <button
-      type="button"
-      onClick={onOpenAgents}
-      className="-mx-1 flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50"
-    >
-      <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
-      <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
-      <span className="min-w-0 truncate">
-        <span className="font-medium">{lead}</span>
-        {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null}
-      </span>
-      <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground">
-        <span>{status}</span>
-        {totalTokens > 0 ? (
-          <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span>
-        ) : null}
-        <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span>
-      </span>
-    </button>
+    <div className="@container/agent-group -mx-1 rounded-xl border border-border/70 bg-secondary p-1.5 dark:border-transparent dark:bg-input/32">

apps/web/src/components/chat/MessagesTimeline.tsx · right 2508–2532

M
macroscopeappBotAug 17, 2026

chat/MessagesTimeline.tsx:2508

This card is now a verbatim re-creation of ChangedFilesCard (ChangedFilesTree.tsx lines 54-141): identical shell rounded-xl border border-border/70 bg-secondary p-1.5 dark:border-transparent dark:bg-input/32, identical flex items-center justify-between gap-2 px-1 header, and the same Button size="xs" variant="outline" + Tooltip + @[24rem]/<container>:inline label action. Two call sites now own the same durable, theme-sensitive treatment — including the dark border/background pair — so a change to one silently diverges from the other, which is exactly how the padding/radius already drifted between these cards before this PR realigned them. Suggest extracting the shell plus the responsive icon+label action into a small shared timeline-card primitive, leaving only the container name, contents, and -mx-1/mt-4 positioning at each call site.

No longer relevant as of 4816e2c5c929842a3f13ba60496401d64b476023

apps/web/src/session-logic.ts · Earlier revision 8408cc31 · right 1109

Original diff
@@ -1007,19 +1082,31 @@ function shouldCollapseToolLifecycleEntries(
   previous: DerivedWorkLogEntry,
   next: DerivedWorkLogEntry,
 ): boolean {
-  if (previous.activityKind !== "tool.updated" && previous.activityKind !== "tool.completed") {
+  if (
+    previous.activityKind !== "tool.started" &&
+    previous.activityKind !== "tool.updated" &&
+    previous.activityKind !== "tool.completed"
+  ) {
     return false;
   }
-  if (next.activityKind !== "tool.updated" && next.activityKind !== "tool.completed") {
+  if (
+    next.activityKind !== "tool.started" &&
+    next.activityKind !== "tool.updated" &&
+    next.activityKind !== "tool.completed"
+  ) {
     return false;
   }
   if (previous.activityKind === "tool.completed") {
     return false;
   }
+  if (next.activityKind === "tool.started") {
+    return false;
+  }
   if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) {
     return true;
   }
   return (
+    previous.activityKind !== "tool.started" &&
C
cursorBotAug 16, 2026

Completed tools jump in timeline

Medium Severity

Merging a tool.started row into its completion copies the completion createdAt (and id) onto the start row. deriveTimelineEntries then re-sorts by createdAt, so a finishing call teleports later in the timeline and can split a live batch around intervening messages.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 8408cc3160f1a19b29f0a1afca773f31d71471b6. Configure here.

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision 266e2675 · right 2532–2532

Original diff
@@ -2189,71 +2526,94 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
         : "working"
     : failed > 0
       ? `${failed} failed`
-      : "✓ completed";
+      : "Completed";
 
   return (
-    <button
-      type="button"
-      onClick={onOpenAgents}
-      className="-mx-1 flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50"
-    >
-      <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
-      <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
-      <span className="min-w-0 truncate">
-        <span className="font-medium">{lead}</span>
-        {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null}
-      </span>
-      <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground">
-        <span>{status}</span>
-        {totalTokens > 0 ? (
-          <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span>
-        ) : null}
-        <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span>
-      </span>
-    </button>
+    <div className="@container/agent-group -mx-1 rounded-xl border border-border/70 bg-secondary p-1.5 dark:border-transparent dark:bg-input/32">

apps/web/src/components/chat/MessagesTimeline.tsx · right 2532–2532

M
macroscopeappBotAug 17, 2026

chat/MessagesTimeline.tsx:2532

This card re-creates the timeline's card surface (border border-border/70 bg-secondary dark:border-transparent dark:bg-input/32) but with different geometry than the only other card in the chat column, ChangedFilesCard (ChangedFilesTree.tsx:59): rounded-xl + p-1.5 here vs rounded-2xl + p-2 there. Both can render under the same assistant turn, so the same surface treatment shows two different corner radii and inset paddings. Suggest aligning this card with the established geometry (or extracting the shared surface so the two call sites cannot drift again).

suggestion
1 <div className="@container/agent-group -mx-1 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32">

No longer relevant as of f3e27d751bdb3fe2798e0037fbd65ccd1e8f05e1

apps/web/src/components/chat/ChangedFilesTree.tsx · Earlier revision 3a54895e · right 266–266

Original diff
@@ -277,11 +259,11 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
             ) : (
               <FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" />
             )}
-            <span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">
+            <span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">
               {node.name}
             </span>
             {hasNonZeroStat(node.stat) && (
-              <span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
+              <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums">

apps/web/src/components/chat/ChangedFilesTree.tsx · right 266–266

M
macroscopeappBotAug 17, 2026

Switching ml-auto → ml-1 here (and at line 301) removes the shared right edge these stats were aligned against, but they still render with DiffStatLabel's default layout="aligned", whose only purpose is that alignment: inline-grid grid-cols-[4ch_4ch] gap-2 text-right. Now that each block starts immediately after a variable-length filename, the fixed 4ch columns can never line up across rows — they only add ragged internal whitespace between the name, +N and -N. The new preview row in the same card renders the identical datum in the same hugging position with layout="inline" (line 179), so the two halves of the card now disagree. Suggest using the inline layout in both tree rows (or restoring ml-auto if the alignment column was meant to stay).

diff
1 <span className="ml-1 shrink-0 font-mono text-[10px] tabular-nums">
2- <DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
3+ <DiffStatLabel
4+ additions={node.stat.additions}
5+ deletions={node.stat.deletions}
6+ layout="inline"
7+ />

Posted via Macroscope — UI Consistency

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision 26af432f · right 1487–1494

Original diff
@@ -1370,41 +1391,203 @@ const WorkGroupSection = memo(function WorkGroupSection({
             key={workEntry.id}
             workEntry={workEntry}
             workspaceRoot={workspaceRoot}
+            isExpandedToolGroupEntry={isExpandedToolGroupEntry}
           />
         ))}
       </div>
-    </section>
+    </GroupContainer>
   );
 });
 
+function LiveActivityRow({
+  label,
+  iconName,
+  failed = false,
+}: {
+  label: string;
+  iconName?: WorkEntryIconName;
+  failed?: boolean;
+}) {
+  return (
+    <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
+      <LiveActivityContent
+        label={label}
+        iconName={iconName}
+        failed={failed}
+        announceFailure={failed}
+      />
+      <div
+        aria-hidden
+        className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
+      >
+        <div className="live-activity-focus-counter">
+          <div className="live-activity-focus-aligned">
+            <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ThinkingActivityRow() {
+  return <LiveActivityRow label="Thinking" />;
+}
+
+function LiveActivityContent({
+  label,
+  iconName,
+  failed = false,
+  announceFailure = false,
+  highlighted = false,
+}: {
+  label: string;
+  iconName: WorkEntryIconName | undefined;
+  failed?: boolean;
+  announceFailure?: boolean;
+  highlighted?: boolean;
+}) {
+  const resolvedIconName = failed ? "x" : iconName;
+
+  return (
+    <div
+      className={cn(
+        "flex min-h-6 min-w-0 items-center gap-1.5 px-0.5 py-0.5",
+        highlighted ? "text-foreground" : "text-secondary-label",
+      )}
+    >
+      {resolvedIconName ? (
+        <span
+          className={cn(
+            "flex size-6 shrink-0 items-center justify-center",
+            failed ? "text-destructive" : highlighted ? "text-foreground" : "text-icon-muted",
+          )}
+          role={announceFailure ? "img" : undefined}
+          aria-label={announceFailure ? "Tool call failed" : undefined}
+        >
+          <WorkEntryIconSvg
+            name={resolvedIconName}
+            className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}
+          />
+        </span>
+      ) : null}
+      <span className="min-w-0 flex-1 truncate">{label}</span>
+    </div>
+  );
+}
+
+function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
+  const ctx = use(TimelineRowCtx);
+  const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot);
+  const failed = row.groupedEntries.some((entry) => workEntryDisplayIndicatesToolFailure(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"

apps/web/src/components/chat/MessagesTimeline.tsx · right 1487–1494

M
macroscopeappBotAug 17, 2026

chat/MessagesTimeline.tsx:1487

This button and the settled tool-group summary button (line 1535) are the same disclosure — both call ctx.onToggleWorkGroup with the same work-group:tool:<turn>:<callId> id and reveal the same children — but only the settled one gets pointer feedback (transition-colors hover:bg-accent/20, also used by the overflow toggle at line 1569 and by expandable PlainWorkEntryRow at line 2664). The live row declares cursor-pointer and aria-expanded with no hover or press state and no chevron, so while a turn is running the control looks inert, then grows a hover tint the moment the batch settles into Used 1 tool. The new test at MessagesTimeline.test.tsx (expect(markup).not.toContain("hover:bg-accent/20")) pins that divergence, so it would need updating with the fix.

group/live-work also has no group-*/live-work consumer anywhere, so it can go.

suggestion
1 className="flex min-h-6 w-full max-w-full cursor-pointer items-center rounded-md text-left transition-colors hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"

No longer relevant as of 4816e2c5c929842a3f13ba60496401d64b476023

apps/web/src/components/chat/MessagesTimeline.logic.ts · Earlier revision a8275dda · right 898–899

Original diff
@@ -566,8 +892,11 @@ export function deriveMessagesTimelineRows(input: {
               groupId,
               hiddenCount: hiddenEntries.length,
               expanded,
-              onlyToolEntries: visibleGroupedEntries.every((entry) =>
-                workLogEntryIsToolLike(entry),
+              onlyToolEntries,
+              summary: null,
+              summaryKind: null,
+              hasFailure: visibleGroupedEntries.some((entry) =>
+                workEntryDisplayIndicatesToolFailure(entry),

apps/web/src/components/chat/MessagesTimeline.logic.ts · right 898–899

M
macroscopeappBotAug 17, 2026

hasFailure is only ever rendered by the summary branch of WorkGroupToggleTimelineRow (destructive icon plus the …, tool call failed accessible name, MessagesTimeline.tsx lines 1538-1550). This overflow branch produces the chevron variant (summary: null), which ignores the field entirely, so two collapsed disclosures of the same batch disagree: an all-tool group that hides a failed call announces the failure, while a mixed group that hides the same call stays silent.

The value is also computed over visibleGroupedEntries rather than hiddenEntries, so it would attribute a still-visible failure (which already renders its own X on its own row) to the collapsed set. Suggest scoping it to hiddenEntries and rendering the same failure affordance in the chevron branch, or dropping the field for this variant so the row type only carries state its renderer uses.

Posted via Macroscope — UI Consistency

apps/web/src/components/chat/MessagesTimeline.logic.ts · Earlier revision f19ee2b9 · right 734–736

Original diff
@@ -471,38 +671,142 @@ export function deriveMessagesTimelineRows(input: {
     terminalAssistantMessageIds,
     latestTurn: input.latestTurn ?? null,
     unsettledTurnId,
+    expandedTurnIds: input.expandedTurnIds,
   });
   const collapsedEntryIds = new Set<string>();
-  for (const fold of foldsByAnchorEntryId.values()) {
-    if (!input.expandedTurnIds?.has(fold.turnId)) {
-      for (const entryId of fold.hiddenEntryIds) {
-        collapsedEntryIds.add(entryId);
+  for (const folds of foldsByAnchorEntryId.values()) {
+    for (const fold of folds) {
+      if (!input.expandedTurnIds?.has(fold.turnId)) {
+        for (const entryId of fold.hiddenEntryIds) {
+          collapsedEntryIds.add(entryId);
+        }
       }
     }
   }
 
+  let activeTurnHeaderIndex = input.timelineEntries.length;
+  if (input.isWorking) {
+    const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries);
+    const firstOwnedAfterUser =
+      unsettledTurnId === null
+        ? -1
+        : input.timelineEntries.findIndex(
+            (entry, index) =>
+              index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId,
+          );
+    activeTurnHeaderIndex =
+      firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1;
+  }
+  const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) =>
+    input.isWorking &&
+    index >= activeTurnHeaderIndex &&
+    (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId);
+  const isVisibleActiveToolEntry = (entry: WorkLogEntry) =>
+    workLogEntryIsToolLike(entry) &&
+    (entry.toolLifecycleStatus === "inProgress" || !workEntryIndicatesToolNeutralStatus(entry));
+  const activeEntries = input.isWorking
+    ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
+    : [];
+  const activeTurnHasVisibleContent = activeEntries.some((entry) => {
+    if (entry.kind === "message") {
+      return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0;
+    }
+    if (entry.kind === "work") {
+      return (
+        entry.entry.agentSpawn === undefined &&
+        workLogEntryIsToolLike(entry.entry) &&
+        entry.entry.toolLifecycleStatus === "inProgress"
+      );
+    }
+    if (entry.kind === "proposed-plan" || entry.kind === "turn-plan") return true;
+    return false;
+  });
+
+  const activeToolEntries = activeEntries.flatMap((entry) =>
+    entry.kind === "work" &&
+    entry.entry.agentSpawn === undefined &&
+    entry.entry.tone !== "error" &&
+    workLogEntryIsToolLike(entry.entry)
+      ? [entry]
+      : [],
+  );
+  const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
+  const visibleActiveToolEntries = activeToolEntries.filter((entry) =>
+    isVisibleActiveToolEntry(entry.entry),
+  );

apps/web/src/components/chat/MessagesTimeline.logic.ts · right 734–736

M
macroscopeappBotAug 19, 2026

chat/MessagesTimeline.logic.ts:736

visibleActiveToolEntries never applies the terminal-entry rule that toolGroupSummaryEntries (line 355) applies to the settled group, so an id-less lifecycle pair is counted twice while the turn is running.

deriveWorkLogEntries deliberately keeps a statusless tool.updated marker alongside its tool.completed row for providers that emit no toolCallId (session-logic.test.ts, "does not leave id-less tool lifecycles running" — ids ["legacy-tool-update", "legacy-tool-complete"], statuses [undefined, "completed"]). Both are tool-like and neither is neutral (workEntryIndicatesToolSuccess is true for an undefined status), so both land in groupedEntries: the live row's label advances through the marker, and expanding it reveals two rows for one call. The moment the turn settles, the same batch renders as Used 1 tool with a single child (summarizeToolGroup test "counts id-less lifecycle markers as one completed tool"), so the same tool call visibly changes count on settle.

Suggest dropping superseded lifecycle markers here — entries whose sourceActivityKind is tool.started/tool.updated with an undefined toolLifecycleStatus once a later entry in the batch is terminal — rather than reusing toolGroupSummaryEntries as-is, which would also drop genuinely in-progress parallel calls whenever any sibling has completed.

No longer relevant as of 8f9910e4fb3c0cf05c6ba766ee6da472d901f96d

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision bb47d9de · right 2528–2544

Original diff
@@ -2189,71 +2519,94 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
         : "working"
     : failed > 0
       ? `${failed} failed`
-      : "✓ completed";
+      : "Completed";
 
   return (
-    <button
-      type="button"
-      onClick={onOpenAgents}
-      className="-mx-1 flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50"
-    >
-      <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
-      <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
-      <span className="min-w-0 truncate">
-        <span className="font-medium">{lead}</span>
-        {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null}
-      </span>
-      <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground">
-        <span>{status}</span>
-        {totalTokens > 0 ? (
-          <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span>
-        ) : null}
-        <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span>
-      </span>
-    </button>
+    <div className="@container/agent-group -mx-1 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32">
+      <div className="flex items-center justify-between gap-2 px-1">
+        <div className="flex min-w-0 flex-1 items-center gap-1.5 px-1 py-1 text-left text-xs leading-4">
+          <span className="sr-only @[32rem]/agent-group:hidden">{status}</span>
+          <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
+          <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
+          <span className="min-w-0 truncate font-medium text-foreground">{lead}</span>
+          {workflowName ? (
+            <span className="hidden min-w-0 truncate text-[11px] text-muted-foreground @[40rem]/agent-group:inline">
+              {workflowName}
+            </span>
+          ) : null}
+          <span className="ml-auto hidden shrink-0 items-center gap-2 text-[11px] text-muted-foreground @[32rem]/agent-group:flex">
+            <span>{status}</span>
+            {totalTokens > 0 ? (
+              <span className="font-mono tabular-nums">
+                Σ {formatSubagentTokenCount(totalTokens)}
+              </span>
+            ) : null}
+          </span>

apps/web/src/components/chat/MessagesTimeline.tsx · right 2528–2544

M
macroscopeappBotAug 17, 2026

chat/MessagesTimeline.tsx:2544

The status text is now gated on @[32rem]/agent-group, with only an sr-only copy below that width. The container is the card itself, so in a narrow chat column (split view, mobile web) sighted users lose 2 working / 1 failed / Completed entirely, and the sole remaining differentiator between a failed and a completed fleet is the size-1.5 dot — which is aria-hidden and carries no tooltip or label. Before this change the status was always rendered, so this is a color-only state at narrow widths.

Suggest keeping the status visible at all widths and gating only the token total, which is the part that needs the room:

suggestion
1 <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
2 <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
3 <span className="min-w-0 truncate font-medium text-foreground">{lead}</span>
4 {workflowName ? (
5 <span className="hidden min-w-0 truncate text-[11px] text-muted-foreground @[40rem]/agent-group:inline">
6 {workflowName}
7 </span>
8 ) : null}
9 <span className="ml-auto flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
10 <span>{status}</span>
11 {totalTokens > 0 ? (
12 <span className="hidden font-mono tabular-nums @[32rem]/agent-group:inline">
13 Σ {formatSubagentTokenCount(totalTokens)}
14 </span>
15 ) : null}
16 </span>

No longer relevant as of b99f41078abb2a18b42980fb85e85800b547e82b

apps/web/src/session-logic.ts · Earlier revision 266e2675 · right 1080

Original diff
@@ -993,12 +1019,65 @@ function collapseDerivedWorkLogEntries(
       });
       continue;
     }
+    const lifecycleKey = toolLifecycleCollapseMapKey(entry);
+    if (lifecycleKey !== undefined) {
+      const matchingLifecycleIndex = toolLifecycleRowIndex.get(lifecycleKey);
+      if (matchingLifecycleIndex !== undefined) {
+        const matchingEntry = collapsed[matchingLifecycleIndex];
+        if (
+          matchingEntry?.activityKind === "tool.completed" &&
+          entry.activityKind === "tool.started"
+        ) {
+          toolLifecycleRowIndex.delete(lifecycleKey);
+          collapsed[matchingLifecycleIndex] = mergeDerivedWorkLogEntries(entry, matchingEntry);
+          continue;
+        }
+        if (matchingEntry && shouldCollapseToolLifecycleEntries(matchingEntry, entry)) {
+          toolLifecycleRowIndex.delete(lifecycleKey);
+          const merged = mergeDerivedWorkLogEntries(matchingEntry, entry);
+          collapsed[matchingLifecycleIndex] = merged;
+          if (merged.activityKind !== "tool.completed") {
+            toolLifecycleRowIndex.set(lifecycleKey, matchingLifecycleIndex);
+          }
+          continue;
+        }
+        toolLifecycleRowIndex.delete(lifecycleKey);
+      }
+    }
     const previous = collapsed.at(-1);
-    if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
-      collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
+    const hasCompetingIdlessCompletionTarget =
+      previous?.activityKind === "tool.started" &&
+      entry.activityKind === "tool.completed" &&
+      entry.toolCallId === undefined &&
+      collapsed
+        .slice(0, -1)
+        .some(
+          (candidate) =>
+            candidate.activityKind !== "tool.completed" &&
+            candidate.itemType === previous.itemType &&
+            normalizeCompactToolLabel(candidate.toolTitle ?? candidate.label) ===
+              normalizeCompactToolLabel(previous.toolTitle ?? previous.label),
+        );
+    if (
+      previous &&
+      !hasCompetingIdlessCompletionTarget &&
+      shouldCollapseToolLifecycleEntries(previous, entry)
+    ) {
+      const previousIndex = collapsed.length - 1;
+      const previousKey = toolLifecycleCollapseMapKey(previous);
+      if (previousKey !== undefined) toolLifecycleRowIndex.delete(previousKey);
+      const merged = mergeDerivedWorkLogEntries(previous, entry);
+      collapsed[previousIndex] = merged;
+      const mergedKey = toolLifecycleCollapseMapKey(merged);
+      if (mergedKey !== undefined && merged.activityKind !== "tool.completed") {
+        toolLifecycleRowIndex.set(mergedKey, previousIndex);
+      }
       continue;
     }
     collapsed.push(entry);
+    if (lifecycleKey !== undefined) {
+      toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
+    }
C
cursorBotAug 17, 2026

Duplicate rows after tool completion

Medium Severity

The new toolLifecycleRowIndex drops a call’s key once the merged row is tool.completed, and when a later same-key event cannot merge it deletes the index and still appends. A delayed tool.updated (or similar) for that toolCallId then becomes a second work-log row instead of folding into the completed call.

Reviewed by Cursor Bugbot for commit 266e26758498b080ad8a3966270993f6fc6a2496. Configure here.

apps/web/src/components/chat/ChangedFilesTree.tsx · File comment

M
macroscopeappBotAug 17, 2026

This header toggle loses flex-1 (and, just below, the Show files / Hide files hint), so the disclosure now sizes to chevron + label while the header row still spans the full card width under justify-between. The gap between the label and the Open diff / expand-all actions used to be part of the toggle and is now dead space, and with px-1 py-1 on text-xs leading-4 the remaining target is ~24px tall — below a comfortable coarse-pointer hit target for the card's primary control, whose only affordance left is the chevron. Suggest keeping the button flexible so the whole header row still toggles (the ChangedFilesTree.test.tsx class assertion needs the same update).

suggestion
1 className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md px-1 py-1 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"

Posted via Macroscope — UI Consistency

apps/web/src/components/chat/MessagesTimeline.logic.ts · Earlier revision 65317530 · right 314

Original diff
@@ -242,6 +262,90 @@ export function normalizeCompactToolLabel(value: string): string {
   return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
 }
 
+type ToolGroupAction = "read" | "edit" | "command" | "search" | "other";
+
+function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
+  if (entry.requestKind === "file-read" || entry.itemType === "image_view") return "read";
+  if (
+    entry.requestKind === "file-change" ||
+    entry.itemType === "file_change" ||
+    (entry.changedFiles?.length ?? 0) > 0
+  ) {
+    return "edit";
+  }
+  if (entry.requestKind === "command" || entry.itemType === "command_execution" || entry.command) {
+    return "command";
+  }
+  if (entry.itemType === "web_search") return "search";
+  return "other";
+}
+
+function toolGroupActionCount(
+  action: ToolGroupAction,
+  entries: ReadonlyArray<WorkLogEntry>,
+): number {
+  if (action !== "edit") return entries.length;
+
+  const changedFiles = new Set<string>();
+  let editsWithoutFileDetails = 0;
+  for (const entry of entries) {
+    if (!entry.changedFiles || entry.changedFiles.length === 0) {
+      editsWithoutFileDetails += 1;
+      continue;
+    }
+    for (const file of entry.changedFiles) changedFiles.add(file);
+  }
+  return changedFiles.size + editsWithoutFileDetails;
+}
+
+function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
+  switch (action) {
+    case "read":
+      return `Read ${count} ${count === 1 ? "file" : "files"}`;
+    case "edit":
+      return `Changed ${count} ${count === 1 ? "file" : "files"}`;
+    case "command":
+      return `Ran ${count} ${count === 1 ? "command" : "commands"}`;
+    case "search":
+      return `Searched the web ${count} ${count === 1 ? "time" : "times"}`;
+    case "other":
+      return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
+  }
+}
C
cursorBotAug 16, 2026

Grep summarized as web search

Medium Severity

toolGroupAction treats every itemType === "web_search" entry as a web search, so completed groups get labels like Searched the web. In this codebase, codebase grep activities also use that itemType, so the new summary copy mislabels local search as web search.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 653175303ca37c3814a5c0719ae02e231aa87347. Configure here.

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision 967e2eaf · right 1461

Original diff
@@ -1370,19 +1392,138 @@ const WorkGroupSection = memo(function WorkGroupSection({
             key={workEntry.id}
             workEntry={workEntry}
             workspaceRoot={workspaceRoot}
+            isExpandedToolGroupEntry={isExpandedToolGroupEntry}
           />
         ))}
       </div>
     </section>
   );
 });
 
+function LiveActivityRow({ label, iconName }: { label: string; iconName?: WorkEntryIconName }) {
+  return (
+    <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
+      <LiveActivityContent label={label} iconName={iconName} />
+      <div aria-hidden className="live-activity-focus pointer-events-none absolute inset-y-0">
+        <div className="live-activity-focus-counter">
+          <div className="live-activity-focus-aligned">
+            <LiveActivityContent label={label} iconName={iconName} highlighted />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ThinkingActivityRow() {
+  return <LiveActivityRow label="Thinking" />;
+}
+
+function LiveActivityContent({
+  label,
+  iconName,
+  highlighted = false,
+}: {
+  label: string;
+  iconName: WorkEntryIconName | undefined;
+  highlighted?: boolean;
+}) {
+  return (
+    <div
+      className={cn(
+        "flex min-h-6 min-w-0 items-center gap-1.5 px-1 py-0.5",
+        highlighted ? "text-foreground" : "text-secondary-label",
+      )}
+    >
+      {iconName ? (
+        <span
+          className={cn(
+            "flex size-6 shrink-0 items-center justify-center",
+            highlighted ? "text-foreground" : "text-icon-muted",
+          )}
+        >
+          <WorkEntryIconSvg name={iconName} className="block size-4 shrink-0 stroke-[1.8]" />
+        </span>
+      ) : null}
+      <span className="min-w-0 flex-1 truncate">{label}</span>
+    </div>
+  );
+}
+
+function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
+  const ctx = use(TimelineRowCtx);
+
+  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-expanded={row.expanded}
+      onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
M
macroscopeappBotAug 16, 2026

🟡 Medium chat/MessagesTimeline.tsx:1461

Expanding a live tool group does not show its currently running tool details: aria-expanded changes, but WorkGroupSection filters the inProgress entry through workEntryIndicatesToolNeutralStatus, leaving the expanded group visually empty. Update the expanded-child filtering so the active entry is retained for live groups.

No longer relevant as of 96c8a4df8d37b35ef87d3c9cf78e6bf35d1ece76

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision b6fe8b02 · right 1515–1542

Original diff
@@ -1370,19 +1399,145 @@ const WorkGroupSection = memo(function WorkGroupSection({
             key={workEntry.id}
             workEntry={workEntry}
             workspaceRoot={workspaceRoot}
+            isExpandedToolGroupEntry={isExpandedToolGroupEntry}
           />
         ))}
       </div>
     </section>
   );
 });
 
+function LiveActivityRow({ label, iconName }: { label: string; iconName?: WorkEntryIconName }) {
+  return (
+    <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
+      <LiveActivityContent label={label} iconName={iconName} />
+      <div aria-hidden className="live-activity-focus pointer-events-none absolute inset-y-0">
+        <div className="live-activity-focus-counter">
+          <div className="live-activity-focus-aligned">
+            <LiveActivityContent label={label} iconName={iconName} highlighted />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ThinkingActivityRow() {
+  const { workingStepLabel } = use(TimelineRowActivityCtx);
+  return (
+    <LiveActivityRow label={workingStepLabel ? `Thinking · ${workingStepLabel}` : "Thinking"} />
+  );
+}
+
+function LiveActivityContent({
+  label,
+  iconName,
+  highlighted = false,
+}: {
+  label: string;
+  iconName: WorkEntryIconName | undefined;
+  highlighted?: boolean;
+}) {
+  return (
+    <div
+      className={cn(
+        "flex min-h-6 min-w-0 items-center gap-1.5 px-1 py-0.5",
+        highlighted ? "text-foreground" : "text-secondary-label",
+      )}
+    >
+      {iconName ? (
+        <span
+          className={cn(
+            "flex size-6 shrink-0 items-center justify-center",
+            highlighted ? "text-foreground" : "text-icon-muted",
+          )}
+        >
+          <WorkEntryIconSvg name={iconName} className="block size-4 shrink-0 stroke-[1.8]" />
+        </span>
+      ) : (
+        <span aria-hidden className="size-6 shrink-0" />
+      )}
+      <span className="min-w-0 flex-1 truncate">{label}</span>
+    </div>
+  );
+}
+
+function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
+  const ctx = use(TimelineRowCtx);
+
+  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-expanded={row.expanded}
+      onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
+    >
+      <LiveActivityRow
+        label={liveWorkEntryLabel(row.entry, ctx.workspaceRoot)}
+        iconName={workEntryIconName(row.entry)}
+      />
+    </button>
+  );
+}
+
+function toolGroupSummaryIconName(
+  kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"],
+): WorkEntryIconName {
+  switch (kind) {
+    case "read":
+      return "eye";
+    case "edit":
+      return "square-pen";
+    case "command":
+      return "terminal";
+    case "search":
+      return "globe";
+    case "code-search":
+      return "search";
+    case "other":
+      return "wrench";
+    case "mixed":
+    case null:
+      return "hammer";
+  }
+}
+
 function WorkGroupToggleTimelineRow({
   row,
 }: {
   row: Extract<TimelineRow, { kind: "work-toggle" }>;
 }) {
   const ctx = use(TimelineRowCtx);
+  if (row.onlyToolEntries && row.summary) {
+    return (
+      <button
+        type="button"
+        className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"

apps/web/src/components/chat/MessagesTimeline.tsx · right 1515–1542

M
macroscopeappBotAug 16, 2026

This PR moves the activity column to a single scale — the new summary branch here uses a size-6 icon slot with a size-4 glyph and text-sm leading-relaxed, PlainWorkEntryRow moved from size-5/size-3.5/text-[12px] to the same size-6/size-4/text-sm (lines 2557, 2612, 2620), and TurnFoldTimelineRow moved to text-sm leading-relaxed too. The count branch of this same component (lines 1550-1562) keeps the old metrics, so the +N previous log entries toggle now sits 0.25rem left of the rows it reveals and renders at 12px next to their 14px — a ragged left edge inside one group, where the two used to align.

Suggest bringing the count branch to the same slot and type scale:

diff
1 <button
2 type="button"
3- className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
4+ className="flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
5 aria-expanded={row.expanded}
6 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
7 >
8- <span className="flex size-5 shrink-0 items-center justify-center text-icon-muted">
9+ <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
10 <ChevronDownIcon
11 className={cn(
12- "size-3.5 shrink-0 opacity-70 transition-transform duration-200",
13+ "size-4 shrink-0 opacity-70 transition-transform duration-200",
14 row.expanded && "rotate-180",
15 )}
16 />
17 </span>

Posted via Macroscope — UI Consistency

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision d3d2a2df · right 1472

Original diff
@@ -1370,19 +1397,141 @@ const WorkGroupSection = memo(function WorkGroupSection({
             key={workEntry.id}
             workEntry={workEntry}
             workspaceRoot={workspaceRoot}
+            isExpandedToolGroupEntry={isExpandedToolGroupEntry}
           />
         ))}
       </div>
     </section>
   );
 });
 
+function LiveActivityRow({ label, iconName }: { label: string; iconName?: WorkEntryIconName }) {
+  return (
+    <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
+      <LiveActivityContent label={label} iconName={iconName} />
+      <div aria-hidden className="live-activity-focus pointer-events-none absolute inset-y-0">
+        <div className="live-activity-focus-counter">
+          <div className="live-activity-focus-aligned">
+            <LiveActivityContent label={label} iconName={iconName} highlighted />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ThinkingActivityRow() {
+  const { workingStepLabel } = use(TimelineRowActivityCtx);
+  return (
+    <LiveActivityRow label={workingStepLabel ? `Thinking · ${workingStepLabel}` : "Thinking"} />
+  );
+}
+
+function LiveActivityContent({
+  label,
+  iconName,
+  highlighted = false,
+}: {
+  label: string;
+  iconName: WorkEntryIconName | undefined;
+  highlighted?: boolean;
+}) {
+  return (
+    <div
+      className={cn(
+        "flex min-h-6 min-w-0 items-center gap-1.5 px-1 py-0.5",
+        highlighted ? "text-foreground" : "text-secondary-label",
+      )}
+    >
+      {iconName ? (
+        <span
+          className={cn(
+            "flex size-6 shrink-0 items-center justify-center",
+            highlighted ? "text-foreground" : "text-icon-muted",
+          )}
+        >
+          <WorkEntryIconSvg name={iconName} className="block size-4 shrink-0 stroke-[1.8]" />
+        </span>
+      ) : null}
+      <span className="min-w-0 flex-1 truncate">{label}</span>
+    </div>
+  );
+}
+
+function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
+  const ctx = use(TimelineRowCtx);
+
+  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-expanded={row.expanded}
+      onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
+    >
+      <LiveActivityRow
+        label={liveWorkEntryLabel(row.entry, ctx.workspaceRoot)}
M
macroscopeappBotAug 16, 2026

🟡 Medium chat/MessagesTimeline.tsx:1472

A completed successful work-live entry is displayed as if it is still running: liveWorkEntryLabel formats its command as Running <program>. When the trailing active batch contains only completed entries, this leaves the timeline showing a stale running status and hides the separate Thinking indicator until later commentary arrives. Use a completion-aware label/rendering path for completed entries so the row no longer reports an active command.

No longer relevant as of 4816e2c5c929842a3f13ba60496401d64b476023

apps/web/src/components/chat/MessagesTimeline.logic.ts · Earlier revision 10b4e43e · right 845–855

Original diff
@@ -523,15 +827,53 @@ export function deriveMessagesTimelineRows(input: {
         (entry) => !workEntryIndicatesToolNeutralStatus(entry),
       );
       if (visibleGroupedEntries.length > 0) {
-        if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
+        const onlyToolEntries = visibleGroupedEntries.every(
+          (entry) =>
+            workLogEntryIsToolLike(entry) &&
+            entry.agentSpawn === undefined &&
+            entry.tone !== "error",
+        );
+        if (onlyToolEntries) {
+          const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
+          const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
+          const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
+          nextRows.push({
+            kind: "work-toggle",
+            id: `work-toggle:${timelineEntry.id}`,
+            createdAt: timelineEntry.createdAt,
+            groupId,
+            hiddenCount: visibleGroupedEntries.length,
+            expanded,
+            onlyToolEntries: true,
+            summary: summarizeToolGroup(visibleGroupedEntries),
+            summaryKind,
+            hasFailure: visibleGroupedEntries.some((entry) =>
+              workEntryDisplayIndicatesToolFailure(entry),
+            ),
+          });
+          if (expanded) {
+            for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {

apps/web/src/components/chat/MessagesTimeline.logic.ts · right 845–855

M
macroscopeappBotAug 19, 2026

chat/MessagesTimeline.logic.ts:855

summarizeToolGroup now counts only terminal lifecycle entries (toolGroupSummaryEntries, line 354), but hiddenCount, hasFailure, and the rows revealed on expand still use the full visibleGroupedEntries, so the header and its own contents disagree.

For the id-less lifecycle shape this PR keeps in the work log (does not leave id-less tool lifecycles running: a statusless tool.updated plus its tool.completed, session-logic.test.ts), the update marker is not neutral — workEntryIndicatesToolSuccess is true for an undefined status — so it survives the line 826 filter. The collapsed row reads Used 1 tool, and expanding it lists two rows for that one call, the intermediate marker rendered with the same settled treatment as the completion.

Suggest deriving the entry set once and using it for the label, the count, the failure flag, and the expanded children (toolGroupSummaryEntries already falls back to the raw entries when every entry is non-terminal, so an in-flight-only group still renders).

Same split inside the helper: toolGroupSummaryKind filters for the action set (line 367) but the icon fallback still maps over the unfiltered entries (line 374), so a non-terminal marker with a different itemType can push the group icon to mixed/hammer while the label ignores that entry.

No longer relevant as of f19ee2b971e375e51d5637424f1687537acd5fa9

apps/web/src/components/chat/MessagesTimeline.logic.ts · Earlier revision 03958302 · right 832

Original diff
@@ -471,38 +694,143 @@ export function deriveMessagesTimelineRows(input: {
     terminalAssistantMessageIds,
     latestTurn: input.latestTurn ?? null,
     unsettledTurnId,
+    expandedTurnIds: input.expandedTurnIds,
   });
   const collapsedEntryIds = new Set<string>();
-  for (const fold of foldsByAnchorEntryId.values()) {
-    if (!input.expandedTurnIds?.has(fold.turnId)) {
-      for (const entryId of fold.hiddenEntryIds) {
-        collapsedEntryIds.add(entryId);
+  for (const folds of foldsByAnchorEntryId.values()) {
+    for (const fold of folds) {
+      if (!input.expandedTurnIds?.has(fold.turnId)) {
+        for (const entryId of fold.hiddenEntryIds) {
+          collapsedEntryIds.add(entryId);
+        }
       }
     }
   }
 
+  let activeTurnHeaderIndex = input.timelineEntries.length;
+  if (input.isWorking) {
+    const latestUserMessageIndex = lastUserMessageIndex(input.timelineEntries);
+    const firstOwnedAfterUser =
+      unsettledTurnId === null
+        ? -1
+        : input.timelineEntries.findIndex(
+            (entry, index) =>
+              index > latestUserMessageIndex && timelineEntryTurnId(entry) === unsettledTurnId,
+          );
+    activeTurnHeaderIndex =
+      firstOwnedAfterUser >= 0 ? firstOwnedAfterUser : latestUserMessageIndex + 1;
+  }
+  const entryBelongsToActiveTurn = (entry: TimelineEntry, index: number) =>
+    input.isWorking &&
+    index >= activeTurnHeaderIndex &&
+    (unsettledTurnId === null || timelineEntryTurnId(entry) === unsettledTurnId);
+  const isVisibleActiveToolEntry = (entry: WorkLogEntry) =>
+    workLogEntryIsToolLike(entry) &&
+    (entry.toolLifecycleStatus === "inProgress" || !workEntryIndicatesToolNeutralStatus(entry));
+  const activeEntries = input.isWorking
+    ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
+    : [];
+  const activeTurnHasVisibleContent = activeEntries.some((entry) => {
+    if (entry.kind === "message") {
+      return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0;
+    }
+    if (entry.kind === "work") {
+      return (
+        entry.entry.agentSpawn === undefined &&
+        workLogEntryIsToolLike(entry.entry) &&
+        entry.entry.toolLifecycleStatus === "inProgress"
+      );
+    }
+    if (entry.kind === "proposed-plan" || entry.kind === "turn-plan") return true;
+    return false;
+  });
+
+  const activeToolEntries = activeEntries.flatMap((entry) =>
+    entry.kind === "work" &&
+    entry.entry.agentSpawn === undefined &&
+    entry.entry.tone !== "error" &&
+    workLogEntryIsToolLike(entry.entry)
+      ? [entry]
+      : [],
+  );
+  const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
+  const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
+    activeToolEntries.filter((entry) => isVisibleActiveToolEntry(entry.entry)),
+    (entry) => entry.entry,
+  );
+  const activeWorkAnchor = activeToolEntries[0];
+  const latestActiveToolEntry = visibleActiveToolEntries.at(-1);
+  const activeWorkRow =
+    activeWorkAnchor && latestActiveToolEntry
+      ? (() => {
+          const groupId = workGroupId(activeWorkAnchor.id, activeWorkAnchor.entry);
+          return {
+            kind: "work-live" as const,
+            id: `work-live:${workGroupIdentity(activeWorkAnchor.id, activeWorkAnchor.entry)}`,
+            createdAt: activeWorkAnchor.createdAt,
+            entry: latestActiveToolEntry.entry,
+            groupedEntries: visibleActiveToolEntries.map((entry) => entry.entry),
+            groupId,
+            expanded: input.expandedWorkGroupIds?.has(groupId) ?? false,
+          };
+        })()
+      : null;
+  const appendWorkingRow = () => {
+    nextRows.push({
+      kind: "working",
+      id: "working-indicator-row",
+      createdAt: input.activeTurnStartedAt,
+      showThinking: activeWorkRow === null && !activeTurnHasVisibleContent,
+    });
+  };
+  const appendActiveWorkRows = () => {
+    if (activeWorkRow === null) return;
+    nextRows.push(activeWorkRow);
+    if (!activeWorkRow.expanded) return;
+    for (const [entryIndex, workEntry] of activeWorkRow.groupedEntries.entries()) {
+      nextRows.push({
+        kind: "work",
+        id: workEntry.id,
+        createdAt: workEntry.createdAt,
+        groupedEntries: [workEntry],
+        isExpandedToolGroupEntry: true,
+        isLastExpandedToolGroupEntry: entryIndex === activeWorkRow.groupedEntries.length - 1,
+      });
+    }
+  };
+
   for (let index = 0; index < input.timelineEntries.length; index += 1) {
     const timelineEntry = input.timelineEntries[index];
     if (!timelineEntry) {
       continue;
     }
 
-    const turnFold = foldsByAnchorEntryId.get(timelineEntry.id);
-    if (turnFold) {
-      nextRows.push({
-        kind: "turn-fold",
-        id: `turn-fold:${turnFold.turnId}`,
-        createdAt: turnFold.createdAt,
-        turnId: turnFold.turnId,
-        label: turnFold.label,
-        expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,
-      });
+    if (input.isWorking && index === activeTurnHeaderIndex) {
+      appendWorkingRow();
+    }
+
+    const anchoredTurnFolds = foldsByAnchorEntryId.get(timelineEntry.id);
+    if (anchoredTurnFolds) {
+      for (const turnFold of anchoredTurnFolds) {
+        nextRows.push({
+          kind: "turn-fold",
+          id: `turn-fold:${turnFold.turnId}`,
+          createdAt: turnFold.createdAt,
+          turnId: turnFold.turnId,
+          label: turnFold.label,
+          expanded: input.expandedTurnIds?.has(turnFold.turnId) ?? false,
+        });
+      }
     }
 
     if (collapsedEntryIds.has(timelineEntry.id)) {
       continue;
     }
 
+    if (activeWorkEntryIds.has(timelineEntry.id)) {
+      continue;
+    }
C
cursorBotAug 19, 2026

Thinking work vanishes from live timeline

High Severity

The live batch treats every non-error workLogEntryIsToolLike row as a tool, including thinking rows without agentSpawn such as background task progress. Those ids are stripped from the chronological list, but isVisibleActiveToolEntry still hides neutral thinking rows, so they never land in work-live either and disappear while the turn is active. After settle, the same predicate folds them into a tool summary.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 039583025bbe4ecfc209f957aa2309983aef4ace. Configure here.

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision d4e20531 · right 1482–1485

Original diff
@@ -1370,19 +1391,174 @@ const WorkGroupSection = memo(function WorkGroupSection({
             key={workEntry.id}
             workEntry={workEntry}
             workspaceRoot={workspaceRoot}
+            isExpandedToolGroupEntry={isExpandedToolGroupEntry}
           />
         ))}
       </div>
-    </section>
+    </GroupContainer>
   );
 });
 
+function LiveActivityRow({
+  label,
+  iconName,
+  failed = false,
+}: {
+  label: string;
+  iconName?: WorkEntryIconName;
+  failed?: boolean;
+}) {
+  return (
+    <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
+      <LiveActivityContent
+        label={label}
+        iconName={iconName}
+        failed={failed}
+        announceFailure={failed}
+      />
+      <div
+        aria-hidden
+        className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
+      >
+        <div className="live-activity-focus-counter">
+          <div className="live-activity-focus-aligned">
+            <LiveActivityContent label={label} iconName={iconName} failed={failed} highlighted />
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ThinkingActivityRow() {
+  return <LiveActivityRow label="Thinking" />;
+}
+
+function LiveActivityContent({
+  label,
+  iconName,
+  failed = false,
+  announceFailure = false,
+  highlighted = false,
+}: {
+  label: string;
+  iconName: WorkEntryIconName | undefined;
+  failed?: boolean;
+  announceFailure?: boolean;
+  highlighted?: boolean;
+}) {
+  const resolvedIconName = failed ? "x" : iconName;
+
+  return (
+    <div
+      className={cn(
+        "flex min-h-6 min-w-0 items-center gap-1.5 px-1 py-0.5",
+        highlighted ? "text-foreground" : "text-secondary-label",
+      )}
+    >
+      {resolvedIconName ? (
+        <span
+          className={cn(
+            "flex size-6 shrink-0 items-center justify-center",
+            failed ? "text-destructive" : highlighted ? "text-foreground" : "text-icon-muted",
+          )}
+          role={announceFailure ? "img" : undefined}
+          aria-label={announceFailure ? "Tool call failed" : undefined}
+        >
+          <WorkEntryIconSvg
+            name={resolvedIconName}
+            className={cn("block size-4 shrink-0 stroke-[1.8]", !highlighted && "opacity-70")}
+          />
+        </span>
+      ) : null}
+      <span className="min-w-0 flex-1 truncate">{label}</span>
+    </div>
+  );
+}
+
+function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "work-live" }> }) {
+  const ctx = use(TimelineRowCtx);
+  const label = liveWorkEntryLabel(row.entry, ctx.workspaceRoot);
+  const failed = row.groupedEntries.some((entry) => {
+    const { command: _command, ...entryWithoutCommand } = entry;
+    return workEntryIndicatesToolFailure(entryWithoutCommand);
+  });

apps/web/src/components/chat/MessagesTimeline.tsx · right 1482–1485

M
macroscopeappBotAug 17, 2026

chat/MessagesTimeline.tsx:1485

The live row strips command before asking workEntryIndicatesToolFailure, but the settled paths introduced alongside it do not: hasFailure for the tool-group summary (MessagesTimeline.logic.ts line 830) and showFailedIndicator on the expanded child row (line 2605) both run the heuristic over detail + command. Because the exception is keyed on which row renders the entry rather than on the entry's state, a completed call that this PR keeps in the live slot (see the "keeps a completed trailing call in the live slot" case) shows no failure while the turn runs, then gains a destructive X plus Tool call failed — and taints the whole group summary — the moment the turn settles, with nothing about the entry having changed. The PR's own test (rg 'command not found' src) is exactly such a command.

Suggest gating the exception on the lifecycle state it was written for, so an entry keeps one failure affordance in both rows:

suggestion
1 const failed = row.groupedEntries.some((entry) => {
2 if (entry.toolLifecycleStatus !== "inProgress") return workEntryIndicatesToolFailure(entry);
3 const { command: _command, ...entryWithoutCommand } = entry;
4 return workEntryIndicatesToolFailure(entryWithoutCommand);
5 });

No longer relevant as of dea09d603789154877b669854f6f10798511d657

apps/web/src/components/chat/MessagesTimeline.tsx · Earlier revision 63a901ed · right 2416–2537

Original diff
@@ -2189,71 +2399,90 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time
         : "working"
     : failed > 0
       ? `${failed} failed`
-      : "✓ completed";
+      : "Completed";
 
   return (
-    <button
-      type="button"
-      onClick={onOpenAgents}
-      className="-mx-1 flex w-full items-center gap-2 rounded-md border border-border/60 bg-card/50 px-2.5 py-1.5 text-left text-[13px] transition hover:bg-accent/50"
-    >
-      <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
-      <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
-      <span className="min-w-0 truncate">
-        <span className="font-medium">{lead}</span>
-        {workflowName ? <span className="text-muted-foreground"> · {workflowName}</span> : null}
-      </span>
-      <span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[.7rem] text-muted-foreground">
-        <span>{status}</span>
-        {totalTokens > 0 ? (
-          <span className="tabular-nums">Σ {formatSubagentTokenCount(totalTokens)}</span>
-        ) : null}
-        <span className="text-info-foreground">{live ? "Open Agents ▸" : "View ▸"}</span>
-      </span>
-    </button>
+    <div className="@container/agent-group -mx-1 rounded-xl border border-border/70 bg-secondary p-1.5 dark:border-transparent dark:bg-input/32">
+      <div className="flex items-center justify-between gap-2 px-1">
+        <div className="flex min-w-0 flex-1 items-center gap-1.5 px-1 py-1 text-left text-xs leading-4">
+          <span aria-hidden className={cn("size-1.5 shrink-0 rounded-full", dotClass)} />
+          <WorkEntryIconSvg name="bot" className="size-3.5 shrink-0 text-muted-foreground" />
+          <span className="min-w-0 truncate font-medium text-foreground">{lead}</span>
+          {workflowName ? (
+            <span className="hidden min-w-0 truncate text-[11px] text-muted-foreground @[40rem]/agent-group:inline">
+              {workflowName}
+            </span>
+          ) : null}
+          <span className="ml-auto hidden shrink-0 items-center gap-2 text-[11px] text-muted-foreground @[32rem]/agent-group:flex">

apps/web/src/components/chat/MessagesTimeline.tsx · right 2416–2537

M
macroscopeappBotAug 16, 2026

chat/MessagesTimeline.tsx:2416

The status text (3 failed / 2 working / Completed) was previously always rendered; gating it on @[32rem]/agent-group means that in a narrow chat column the only remaining failure signal is the aria-hidden colour dot on line 2408, so the state becomes colour-only and invisible to assistive tech. Suggest keeping a text fallback that is only announced when the visible status is hidden.

diff
1 <div className="flex min-w-0 flex-1 items-center gap-1.5 px-1 py-1 text-left text-xs leading-4">
2+ <span className="sr-only @[32rem]/agent-group:hidden">{status}</span>
3 <span className="ml-auto hidden shrink-0 items-center gap-2 text-[11px] text-muted-foreground @[32rem]/agent-group:flex">

No longer relevant as of d2095f27f302cd6d4c8bc795bb6f8e38c509d90e