}) {
+ const [expanded, setExpanded] = useState(false);
+ if (!steps.length) return null;
+ const completed = steps.filter((step) => step.status === "completed").length;
+ const current = steps.find((step) => step.status === "running");
+ return (
+
}
+ >
+
} />}
+ icon={
}
+ title="Tasks"
+ sub={expanded ? null : current?.text}
+ trailing={
+ <>
+ {!expanded ? (
+
+ {steps.map((step) => (
+
+ ))}
+
+ ) : null}
+
+ {completed}/{steps.length}
+
+
+ >
+ }
+ />
+
+
+ {steps.map((step) => (
+
+ {step.status === "completed" ? (
+
+ ) : (
+
+ )}
+ {step.text}
+
+ {step.status === "running"
+ ? "now"
+ : step.durationMs !== undefined
+ ? `${Math.round(step.durationMs / 1000)}s`
+ : null}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx
index 9dbc14584c0bc..a477c35635f33 100644
--- a/apps/web/src/components/chat/ComposerUsageLimits.tsx
+++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx
@@ -1,18 +1,42 @@
-import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts";
+import {
+ type EnvironmentId,
+ isProviderAvailable,
+ type ProviderInstanceId,
+ type ServerProvider,
+ type ServerProviderUsageWindow,
+ type UsageLimitSourceSnapshots,
+ type UsageLimitsReport,
+} from "@t3tools/contracts";
import type { TimestampFormat } from "@t3tools/contracts/settings";
-import { limitsNotice } from "@t3tools/shared/usageLimits";
-import { GaugeIcon } from "lucide-react";
+import {
+ collectLimitAccounts,
+ collectProviderUsageLimits,
+ formatResetsIn,
+ formatRunsOutIn,
+ isLowLimit,
+ limitsNotice,
+ remainingPercent,
+ tightestSessionLimit,
+} from "@t3tools/shared/usageLimits";
+import { GaugeIcon, InfoIcon } from "lucide-react";
+import { useRef, useState } from "react";
+import { useNowMinute } from "../../hooks/useNowMinute";
import { ensureLocalApi } from "../../localApi";
import { formatUpcomingTimestamp } from "../../timestampFormat";
import { Button } from "../ui/button";
+import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
import { providerClients } from "../settings/providerDriverMeta";
import { RedactedSensitiveText } from "../settings/RedactedSensitiveText";
import { LimitWindows, ResetCredits } from "../usage/UsageLimits";
import { ComposerBanner } from "./ComposerBanner";
import type { ComposerBannerStackItem } from "./ComposerBannerStack";
+import { Badge } from "../ui/badge";
+import { Text } from "../ui/text";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
function accountLabel(account: UsageLimitsReport["accounts"][number]): string {
+ if (account.email?.trim()) return account.email.trim();
if (!account.instanceId) return account.label;
const driver = providerClients.get(account.driver)?.label ?? String(account.driver);
const instance =
@@ -25,27 +49,62 @@ function AccountSummary({ account }: { readonly account: UsageLimitsReport["acco
const label = accountLabel(account);
const driver = providerClients.get(account.driver)?.label ?? String(account.driver);
return (
- <>
- {label.includes("@") ? (
+
+ {account.email || label.includes("@") ? (
) : (
- label
+ {label}
)}
- {account.instanceId && label !== driver ? {driver} : null}
+ {account.instanceId && label !== driver ? (
+
+ {driver}
+
+ ) : null}
{account.plan ? (
- {account.plan}
+
+ {account.plan}
+
) : null}
- >
+
);
}
+function providerName(driver: ServerProvider["driver"], displayName?: string | null): string {
+ return displayName?.trim() || providerClients.get(driver)?.label || String(driver);
+}
+
+/** `Codex at 82% of the 5 hour window`: how much of the window is spent. */
+function formatLimitSummary(
+ driver: ServerProvider["driver"],
+ window: ServerProviderUsageWindow,
+): string {
+ return `${providerName(driver)} at ${100 - remainingPercent(window)}% of the ${window.label.toLowerCase()} window`;
+}
+
+/** The tightest window in a report, for its one-line summary. */
+function tightestReportWindow(report: UsageLimitsReport) {
+ let tightest: {
+ readonly driver: ServerProvider["driver"];
+ readonly window: ServerProviderUsageWindow;
+ } | null = null;
+ for (const account of report.accounts) {
+ if (limitsNotice(account.limits) !== null) continue;
+ for (const window of account.limits.windows) {
+ if (tightest === null || remainingPercent(window) < remainingPercent(tightest.window)) {
+ tightest = { driver: account.driver, window };
+ }
+ }
+ }
+ return tightest;
+}
+
export function usageLimitsBannerItem(
id: string,
report: UsageLimitsReport,
@@ -54,14 +113,18 @@ export function usageLimitsBannerItem(
): ComposerBannerStackItem {
const [first] = report.accounts;
const single = report.accounts.length === 1 && first ? first : null;
- const summary = single ? (
+ const tightest = tightestReportWindow(report);
+ // The line names the tightest window; each account's block follows below it.
+ const summary = tightest ? (
+ formatLimitSummary(tightest.driver, tightest.window)
+ ) : single ? (
) : (
`${report.accounts.length} accounts`
);
return {
id,
- variant: "info",
+ variant: tightest && isLowLimit(tightest.window) ? "warning" : "info",
icon:
,
title: "Usage limits",
description: summary,
@@ -116,61 +179,337 @@ function UsageLimitsBannerBody({
readonly report: UsageLimitsReport;
readonly environmentId: EnvironmentId;
}) {
- const now = Date.parse(report.createdAt);
return (
-
- {report.accounts.map((account) => {
- const resetCreditInput =
- account.resetCreditInput ??
- (account.instanceId ? { instanceId: account.instanceId } : undefined);
- const notice = limitsNotice(account.limits);
- const externalUsage = account.limits.externalUsage;
- return (
-
- {report.accounts.length > 1 ? (
-
-
-
- ) : null}
- {notice ? (
-
{notice}
- ) : (
-
- )}
- {externalUsage ? (
-
void ensureLocalApi().shell.openExternal(externalUsage.url)}
- >
- Manage usage
-
- ) : null}
- {resetCreditInput && account.limits.resetCredits ? (
-
- ) : null}
-
- );
- })}
- {report.notices.map((notice) => (
-
- {notice}
-
- ))}
+
+
);
}
+
+function UsageLimitsAccounts({
+ report,
+ environmentId,
+}: {
+ readonly report: UsageLimitsReport;
+ readonly environmentId: EnvironmentId;
+}) {
+ const now = Date.parse(report.createdAt);
+ return (
+ <>
+ {[false, true].map((unavailable) => {
+ const accounts = report.accounts.filter(
+ (account) => (account.limits.unavailable?.reason === "unsupported") === unavailable,
+ );
+ if (accounts.length === 0) return null;
+ return (
+
+ {report.accounts.some(
+ (account) => account.limits.unavailable?.reason === "unsupported",
+ ) ? (
+
+ {unavailable ? "Connections" : "Reported usage"}
+
+ ) : null}
+ {accounts.map((account) => {
+ const resetCreditInput =
+ account.resetCreditInput ??
+ (account.instanceId ? { instanceId: account.instanceId } : undefined);
+ const notice =
+ account.limits.unavailable?.reason === "unsupported"
+ ? "Usage unavailable for this API connection"
+ : limitsNotice(account.limits);
+ const externalUsage = account.limits.externalUsage;
+ return (
+
+
+
+ {account.sourceLabel ? (
+
+
+ }
+ >
+
+
+
+
+ Source: {account.sourceLabel}
+ Account ID: {account.id}
+
+
+
+ ) : null}
+
+ {notice ? (
+
+ {notice}
+
+ ) : (
+
+ )}
+ {externalUsage ? (
+
void ensureLocalApi().shell.openExternal(externalUsage.url)}
+ >
+ Manage usage
+
+ ) : null}
+ {resetCreditInput && account.limits.resetCredits ? (
+
+ ) : null}
+
+ );
+ })}
+
+ );
+ })}
+ {report.notices.map((notice) => (
+
+ {notice}
+
+ ))}
+ >
+ );
+}
+
+/**
+ * The session a low-limit notice watches: the selected instance's own account
+ * when it reports, else the tightest account of that driver a hub reports.
+ */
+export interface LowLimitSignal {
+ /** Dismissals last until this window resets. */
+ readonly key: string;
+ readonly driver: ServerProvider["driver"];
+ readonly window: ServerProviderUsageWindow;
+}
+
+/** The session window an instance reports for its own signed-in account. */
+function ownSessionLimit(provider: ServerProvider) {
+ const limits = provider.usageLimits;
+ if (!limits || limitsNotice(limits) !== null) return null;
+ return tightestSessionLimit([
+ {
+ key: String(provider.instanceId),
+ driver: provider.driver,
+ displayName: null,
+ email: undefined,
+ plan: undefined,
+ accentColor: undefined,
+ environments: [],
+ sourceLabel: null,
+ redeem: null,
+ limits,
+ },
+ ]);
+}
+
+export function resolveLowLimitSignal(
+ provider: ServerProvider | null,
+ providers: readonly ServerProvider[],
+ sources: UsageLimitSourceSnapshots,
+ environmentId: EnvironmentId,
+): LowLimitSignal | null {
+ if (!provider) return null;
+ const tightest =
+ ownSessionLimit(provider) ??
+ tightestSessionLimit(
+ collectLimitAccounts(
+ new Map([
+ [
+ environmentId,
+ {
+ entry: { target: { label: "" } },
+ serverConfig: { providers, usageLimitSources: sources },
+ },
+ ],
+ ]),
+ ),
+ provider.driver,
+ );
+ if (!tightest || !isLowLimit(tightest.window)) return null;
+ return {
+ key: [
+ environmentId,
+ provider.instanceId,
+ tightest.account.key,
+ tightest.window.id,
+ tightest.window.resetsAt ?? "",
+ ].join("\u0000"),
+ driver: provider.driver,
+ window: tightest.window,
+ };
+}
+
+/**
+ * Another ready provider to move to: one whose own session is not low, the
+ * roomiest first. Providers that report no limits come after those that do.
+ */
+export function resolveLowLimitSwitchTarget(
+ current: ServerProvider,
+ providers: readonly ServerProvider[],
+): ServerProvider | null {
+ const candidates = providers.flatMap((provider) => {
+ if (
+ provider.driver === current.driver ||
+ !provider.enabled ||
+ !provider.installed ||
+ provider.status !== "ready" ||
+ !isProviderAvailable(provider) ||
+ provider.models.length === 0
+ ) {
+ return [];
+ }
+ const limits = ownSessionLimit(provider);
+ if (limits && isLowLimit(limits.window)) return [];
+ return [{ provider, remaining: limits?.remainingPercent ?? -1 }];
+ });
+ candidates.sort((left, right) => right.remaining - left.remaining);
+ return candidates[0]?.provider ?? null;
+}
+
+// Session-scoped so a remount does not bring a dismissed notice back before
+// its window resets.
+const dismissedLowLimitKeys = new Set
();
+
+export function isLowLimitDismissed(key: string): boolean {
+ return dismissedLowLimitKeys.has(key);
+}
+
+/** Ticks once a minute; the rest of the composer does not re-render with it. */
+function LowLimitTiming({ window }: { readonly window: ServerProviderUsageWindow }) {
+ const minute = useNowMinute();
+ const now = Date.parse(`${minute}:00.000Z`);
+ return (
+
+ }>
+ {remainingPercent(window)}% left
+
+
+
+ {window.label} window
+ {[formatResetsIn(window, now), formatRunsOutIn(window, now)]
+ .filter((part) => part !== null)
+ .map((part) => (
+
+ {part}
+
+ ))}
+
+
+
+ );
+}
+
+export interface LimitsSource {
+ readonly environmentId: EnvironmentId;
+ readonly instanceId: ProviderInstanceId;
+ readonly providers: readonly ServerProvider[];
+ readonly sources: UsageLimitSourceSnapshots;
+}
+
+function LimitsPopover({ source }: { readonly source: LimitsSource }) {
+ const popupRef = useRef(null);
+ return (
+
+ }>Limits
+
+
+
+
+ );
+}
+
+function LimitsPopoverReport({ source }: { readonly source: LimitsSource }) {
+ const [openedAt] = useState(Date.now);
+ const report = collectProviderUsageLimits(
+ source.instanceId,
+ source.providers,
+ source.sources,
+ openedAt,
+ );
+ return (
+
+
Usage limits
+ {report ? (
+
+ ) : (
+
Usage limits are unavailable
+ )}
+
+ );
+}
+
+/** The notice for a session under a quarter left, with a way over to another provider. */
+export function lowLimitBannerItem({
+ signal,
+ switchTarget,
+ onSwitch,
+ limits,
+ onDismissed,
+}: {
+ readonly signal: LowLimitSignal;
+ readonly switchTarget: ServerProvider | null;
+ readonly onSwitch: (provider: ServerProvider) => void;
+ readonly limits: LimitsSource | null;
+ readonly onDismissed: () => void;
+}): ComposerBannerStackItem {
+ return {
+ id: `low-limit:${signal.key}`,
+ variant: "warning",
+ icon: ,
+ title: (
+
+ {providerName(signal.driver)} usage low
+
+
+ ),
+ actions: (
+ <>
+ {limits ? : null}
+ {switchTarget ? (
+ onSwitch(switchTarget)}>
+ Switch to {providerName(switchTarget.driver, switchTarget.displayName)}
+
+ ) : null}
+ >
+ ),
+ dismissLabel: "Dismiss low usage notice",
+ onDismiss: () => {
+ dismissedLowLimitKeys.add(signal.key);
+ onDismissed();
+ },
+ };
+}
diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx
index e78d23679b40d..6573e78e10797 100644
--- a/apps/web/src/components/chat/DiffStatLabel.tsx
+++ b/apps/web/src/components/chat/DiffStatLabel.tsx
@@ -48,3 +48,62 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: {
);
});
+
+type DiffBlock = "addition" | "deletion" | "empty";
+// Blocks never reorder, so each slot keeps its own key.
+const DIFF_BLOCK_SLOTS = ["first", "second", "third", "fourth", "fifth"] as const;
+
+/** Five blocks: how many fill grows with the size of the change, split by its mix. */
+function diffStatBlocks(additions: number, deletions: number): DiffBlock[] {
+ const total = additions + deletions;
+ const filled =
+ total === 0 ? 0 : total < 5 ? 1 : total < 20 ? 2 : total < 100 ? 3 : total < 1000 ? 4 : 5;
+ let additionBlocks = total === 0 ? 0 : Math.round((filled * additions) / total);
+ if (additions > 0 && additionBlocks === 0) additionBlocks = 1;
+ if (deletions > 0 && additionBlocks === filled && filled > 1) additionBlocks = filled - 1;
+ const deletionBlocks = deletions > 0 ? filled - additionBlocks : 0;
+ return DIFF_BLOCK_SLOTS.map((_, index) =>
+ index < additionBlocks
+ ? "addition"
+ : index < additionBlocks + deletionBlocks
+ ? "deletion"
+ : "empty",
+ );
+}
+
+const fullCount = new Intl.NumberFormat("en-US");
+
+/** The working bar and turn fold diff: full counts with separators and five blocks. */
+export const DiffStatBlocks = memo(function DiffStatBlocks(props: {
+ additions: number;
+ deletions: number;
+}) {
+ const { additions, deletions } = props;
+ return (
+
+
+ +{fullCount.format(additions)}
+ −{fullCount.format(deletions)}
+
+
+ {diffStatBlocks(additions, deletions).map((block, index) => (
+
+ ))}
+
+
+ );
+});
diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx
index f4e6ea987c492..7d36d2cec64d9 100644
--- a/apps/web/src/components/chat/ExpandedImageDialog.tsx
+++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx
@@ -1,13 +1,27 @@
import { memo, useRef, useState, type ReactNode, type KeyboardEvent } from "react";
+import { cn } from "~/lib/utils";
+import {
+ CopyIcon,
+ DownloadIcon,
+ ExternalLinkIcon,
+ ImageIcon,
+ MinusIcon,
+ PlusIcon,
+ VideoIcon,
+ XIcon,
+ Image as ImageGlyphIcon,
+ Text as TextGlyphIcon,
+} from "lucide-react";
+import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
import { flushSync } from "react-dom";
-import { ChevronLeftIcon, ChevronRightIcon, XIcon, ImageIcon, TextIcon } from "lucide-react";
import { Button } from "../ui/button";
+
import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
import type { ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview";
import { resolveExternalWebLinkHost } from "./externalLinkContextMenu";
import { useAssetUrlRefresh, useAssetUrlState } from "../../assets/assetUrls";
import { OpenMediaLink } from "../media/OpenMediaLink";
-import { MediaActions, type MediaActionSource } from "../media/MediaActions";
+import { MediaActions, useMediaByteAction, type MediaActionSource } from "../media/MediaActions";
import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
import { isContextMenuOpen } from "../../contextMenuFallback";
import {
@@ -16,7 +30,9 @@ import {
snapShotAccessibilityDetails,
} from "./SnapShotAttachmentDetails";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
-import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage";
+import { ZoomableImage, type ZoomableImageHandle, type ZoomableImageView } from "./ZoomableImage";
+import { readLocalApi } from "../../localApi";
+import { toastManager } from "../ui/toast";
import { composerFloatingLayerProps } from "./composerEventScope";
import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/reactivity";
@@ -32,7 +48,7 @@ interface ExpandedImageDialogProps {
}
const EXPANDED_MEDIA_STATE_CLASS_NAME =
- "flex aspect-auto h-48 min-h-0 w-[min(var(--media-width),32rem)] flex-col items-center justify-center gap-3 rounded-card border border-border bg-black p-6 text-center text-sm text-white shadow-dialog";
+ "flex aspect-auto h-48 min-h-0 w-[min(var(--media-width),32rem)] flex-col items-center justify-center gap-3 rounded-card border border-border bg-black p-6 text-center text-body text-white shadow-dialog";
function ExpandedMediaFailure({ children }: { children: ReactNode }) {
return (
@@ -77,6 +93,69 @@ function ExpandedVideo({ item }: { readonly item: ExpandedImageItem }) {
);
}
+/** An http(s) destination the viewer can hand to the system browser, never a blob or data URL. */
+function browserUrl(url: string | null | undefined): string | null {
+ if (!url) return null;
+ try {
+ const protocol = new URL(url, window.location.href).protocol;
+ return protocol === "http:" || protocol === "https:" ? url : null;
+ } catch {
+ return null;
+ }
+}
+
+function MediaBarButton({
+ label,
+ onClick,
+ disabled,
+ children,
+}: {
+ label: string;
+ onClick: () => void;
+ disabled?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+
+ }
+ >
+ {children}
+
+ {label}
+
+ );
+}
+
+function MediaThumbnail({ item }: { item: ExpandedImageItem }) {
+ const [failed, setFailed] = useState(false);
+ if (item.type === "video" || item.src === null || failed) {
+ const Icon = item.type === "video" ? VideoIcon : ImageIcon;
+ return ;
+ }
+ return (
+ setFailed(true)}
+ />
+ );
+}
+
export const ExpandedImageDialog = memo(function ExpandedImageDialog({
preview,
onClose,
@@ -84,6 +163,12 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
const [imageOffset, setImageOffset] = useState(0);
const [failedImageSrc, setFailedImageSrc] = useState(null);
const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState(null);
+ const [naturalSize, setNaturalSize] = useState<{
+ src: string;
+ width: number;
+ height: number;
+ } | null>(null);
+ const [view, setView] = useState(null);
const zoomableImageRef = useRef(null);
const [returnFocus] = useState(() => {
const target = document.activeElement instanceof HTMLElement ? document.activeElement : null;
@@ -117,10 +202,14 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
},
}
: source;
+ const runByteAction = useMediaByteAction(actionsSource);
const navigateImage = (direction: -1 | 1) => {
setImageOffset((current) => current + direction);
};
+ const selectImage = (target: number) => {
+ setImageOffset(target - preview.index);
+ };
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || isContextMenuOpen() || event.target instanceof HTMLVideoElement)
@@ -157,6 +246,43 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
: accessibilityDetails?.format === "json"
? "Show accessibility JSON"
: "Show extracted text";
+ const imageFailed = item.src === null || failedImageSrc === item.src;
+ const zoomable = item.type !== "video" && !showingAccessibilityDetails && !imageFailed;
+ const unavailable = actionsSource.src === null && actionsSource.asset === undefined;
+ const canCopyImage =
+ item.type !== "video" &&
+ !unavailable &&
+ typeof navigator !== "undefined" &&
+ Boolean(navigator.clipboard?.write) &&
+ typeof ClipboardItem !== "undefined";
+ const externalUrl =
+ item.originalUrl && resolveExternalWebLinkHost(item.originalUrl) !== null
+ ? item.originalUrl
+ : browserUrl(item.src);
+ const openExternally = actionsSource.onOpenFile
+ ? { label: "Open in file viewer", run: actionsSource.onOpenFile }
+ : externalUrl
+ ? {
+ label: item.originalUrl ? "Open original" : "Open in browser",
+ run: () => {
+ void readLocalApi()
+ ?.shell.openExternal(externalUrl)
+ .catch((error: unknown) => {
+ toastManager.add({
+ type: "error",
+ title: "Could not open link",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ });
+ });
+ },
+ }
+ : null;
+ const dimensions =
+ naturalSize && naturalSize.src === item.src
+ ? `${naturalSize.width.toLocaleString()} × ${naturalSize.height.toLocaleString()}`
+ : null;
+ const meta = item.type === "video" ? null : dimensions;
+ const zoomPercent = view ? `${Math.round(view.scale * 100)}%` : "100%";
return (
{
+ if (
+ event.key !== "Escape" ||
+ isContextMenuOpen() ||
+ !(event.target instanceof Node) ||
+ !event.currentTarget.contains(event.target)
+ )
+ return;
+ event.preventDefault();
+ event.stopPropagation();
+ close();
+ }}
onKeyDown={onKeyDown}
initialFocus={closeButtonRef}
finalFocus={() => returnFocus.target}
@@ -185,105 +324,216 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
}}
>
Expanded {mediaLabel} preview
-
-
-
- {preview.images.length > 1 && (
- navigateImage(-1)}
+
+
-
-
- )}
-
-
- {item.type === "video" ? (
-
- ) : showingAccessibilityDetails ? (
- accessibilityDetails ? (
-
- ) : null
- ) : item.src === null || failedImageSrc === item.src ? (
-
-
- {openOriginalLink
- ? "This image could not be loaded."
- : "Image unavailable. The file may have been moved or deleted."}
-
- {openOriginalLink}
-
- ) : (
-
setFailedImageSrc(item.src)}
- />
- )}
-
-
- {item.name}
- {preview.images.length > 1 ? ` (${index + 1}/${preview.images.length})` : ""}
+
+
+ {item.name}
+
+ {imageCount > 1 ? ` (${index + 1}/${imageCount})` : ""}
- {accessibilityDetails && item.source ? (
-
-
- setAccessibilityDetailsSrc(showingAccessibilityDetails ? null : item.src)
- }
- size="icon-micro"
- variant="overlay"
- />
- }
- >
- {showingAccessibilityDetails ? (
-
- ) : (
-
+
+ {meta || item.sizeBytes !== undefined ? (
+
+
+ }
+ >
+ {meta ?? formatAttachmentSize(item.sizeBytes!)}
+
+
+ {meta ? {meta} : null}
+ {item.sizeBytes !== undefined ? (
+ {formatAttachmentSize(item.sizeBytes)}
+ ) : null}
+
+
+ ) : null}
+
+ {accessibilityDetails && item.source ? (
+
+
+ setAccessibilityDetailsSrc(showingAccessibilityDetails ? null : item.src)
+ }
+ />
+ }
+ >
+ {showingAccessibilityDetails ? (
+
+ ) : (
+
+ )}
+
+ {contentsLabel}
+
+ ) : item.source ? (
+
+ ) : null}
+ {zoomable ? (
+
+ {/* aria-disabled keeps focus in the dialog when a click reaches a zoom limit, so arrow keys still navigate. */}
+
zoomableImageRef.current?.zoomOut()}
+ >
+
+
+
zoomableImageRef.current?.resetZoom()}
+ >
+ {zoomPercent}
+
+
zoomableImageRef.current?.zoomIn()}
+ >
+
+
+
+ ) : null}
+ {item.type !== "video" ? (
+ void runByteAction("copy-image")}
+ >
+
+
+ ) : null}
+ void runByteAction("save")}
+ >
+
+
+ {openExternally ? (
+
+
+
+ ) : null}
+
+
+ }
+ >
+
+
+ Close
+
+
+
+
+ {item.type === "video" ? (
+
+ ) : showingAccessibilityDetails ? (
+ accessibilityDetails ? (
+
+ ) : null
+ ) : item.src === null || failedImageSrc === item.src ? (
+
+
+ {openOriginalLink
+ ? "This image could not be loaded."
+ : "Image unavailable. The file may have been moved or deleted."}
+
+ {openOriginalLink}
+
+ ) : (
+
setFailedImageSrc(item.src)}
+ onNaturalSize={(size) => {
+ if (item.src !== null) setNaturalSize({ src: item.src, ...size });
+ }}
+ onViewChange={setView}
+ />
+ )}
+
+
+ {imageCount > 1 ? (
+
+ {preview.images.map((thumbnail, thumbnailIndex) => (
+ selectImage(thumbnailIndex)}
+ >
+
- {contentsLabel}
-
- ) : item.source ? (
-
- ) : null}
+ >
+
+
+
+ ))}
-
-
- {preview.images.length > 1 && (
-
navigateImage(1)}
- >
-
-
- )}
+ ) : null}
+
);
diff --git a/apps/web/src/components/chat/ExpandedImagePreview.tsx b/apps/web/src/components/chat/ExpandedImagePreview.tsx
index 0eec6753f8e96..d1c2aba6ee77d 100644
--- a/apps/web/src/components/chat/ExpandedImagePreview.tsx
+++ b/apps/web/src/components/chat/ExpandedImagePreview.tsx
@@ -31,6 +31,8 @@ export interface ExpandedImageItem {
originalUrl?: string;
srcFragment?: string;
actionsSource?: MediaActionSource;
+ /** Known for attachments; markdown images only learn their pixel size once loaded. */
+ sizeBytes?: number;
}
export interface ExpandedImagePreview {
@@ -186,6 +188,7 @@ export function buildExpandedImagePreview(
name: image.name,
mimeType: image.mimeType,
source: image.source,
+ sizeBytes: image.sizeBytes,
},
]
: [],
@@ -202,6 +205,7 @@ export function buildExpandedImagePreview(
src: image.src,
name: image.name,
mimeType: image.mimeType,
+ sizeBytes: image.sizeBytes,
...(image.source?.kind === "snap-shot" ? { source: image.source } : {}),
})),
index: selectedIndex,
diff --git a/apps/web/src/components/chat/MarkdownCodeBlock.tsx b/apps/web/src/components/chat/MarkdownCodeBlock.tsx
index 4d265041db874..204c74defa654 100644
--- a/apps/web/src/components/chat/MarkdownCodeBlock.tsx
+++ b/apps/web/src/components/chat/MarkdownCodeBlock.tsx
@@ -1,4 +1,11 @@
-import { PlayIcon, WrapTextIcon, CheckIcon, CopyIcon } from "lucide-react";
+import {
+ createHighlightCacheKey,
+ estimateHighlightedSize,
+ extractFenceLanguage,
+} from "./MarkdownCodeBlock.logic";
+import { reportMarkdownActionFailure } from "./markdownActionFailure";
+import { CheckIcon, CopyIcon, PlayIcon, WrapTextIcon } from "lucide-react";
+
import { toHtml } from "hast-util-to-html";
import React, { use, useEffect, useState, type ReactNode } from "react";
@@ -19,12 +26,6 @@ import {
import { Button } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { HighlightedCodeLines } from "./HighlightedCodeLines";
-import {
- createHighlightCacheKey,
- estimateHighlightedSize,
- extractFenceLanguage,
-} from "./MarkdownCodeBlock.logic";
-import { reportMarkdownActionFailure } from "./markdownActionFailure";
import { PierreEntryIcon } from "./PierreEntryIcon";
const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
@@ -74,7 +75,7 @@ export function MarkdownCodeBlockFrame({
@@ -203,8 +204,9 @@ export function MarkdownCodeBlock({
if (diagram) {
return (
+ // The transparent border and fixed-height header match the code frame, so the
-
+
{viewToggle}
{copyButton}
@@ -228,7 +230,7 @@ export function MarkdownCodeBlock({
render={
setWrapped((value) => !value)}
@@ -236,7 +238,7 @@ export function MarkdownCodeBlock({
/>
}
>
-
+
{wrapLabel}
diff --git a/apps/web/src/components/chat/MarkdownTable.tsx b/apps/web/src/components/chat/MarkdownTable.tsx
index 9982e0a6cc562..672754f22b02f 100644
--- a/apps/web/src/components/chat/MarkdownTable.tsx
+++ b/apps/web/src/components/chat/MarkdownTable.tsx
@@ -34,7 +34,7 @@ export function MarkdownTable({ children, ...props }: ComponentProps<"table">) {
return (
-
+
diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
index 0428d0942477c..e8d2ab2f749bb 100644
--- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
@@ -1,8 +1,9 @@
+import { resolveTimelineIsAtEnd } from "./MessagesTimeline.logic";
import { injectHtmlRenderBootstrap } from "@t3tools/shared/htmlRender";
import { HtmlRenderDocument } from "../files/BrowserDocumentFrame";
import { flushSync } from "react-dom";
import { AudioPreview } from "../files/AudioPreview";
-import { cdp, page } from "vite-plus/test/browser";
+import { cdp, page, userEvent } from "vite-plus/test/browser";
import {
ComposerContextId,
EnvironmentId,
@@ -10,6 +11,7 @@ import {
NodeId,
ProviderDriverKind,
ProviderInstanceId,
+ ProviderSessionId,
RunAttemptId,
RunId,
RuntimeRequestId,
@@ -17,6 +19,7 @@ import {
ScheduledTaskId,
ThreadId,
type OrchestrationV2Subagent,
+ type OrchestrationV2ProjectedTurnItem,
} from "@t3tools/contracts";
import { LegendList, type LegendListRef } from "@legendapp/list/react";
import {
@@ -24,6 +27,7 @@ import {
Profiler,
StrictMode,
useState,
+ useRef,
type ComponentProps,
type ReactElement,
} from "react";
@@ -37,8 +41,9 @@ import { environmentThreadDetails } from "../../state/threads";
import { act, fakeTimers, render, stubGlobal } from "../../../test/render";
import { ChangedFilesCard } from "./ChangedFilesTree";
import { MessagesTimeline } from "./MessagesTimeline";
-import { resolveTimelineIsAtEnd } from "./MessagesTimeline.logic";
-import { makeStreamingTimelineFixture } from "../../test-fixtures";
+import { ComposerApprovalPanel } from "./ComposerApprovalPanel";
+import { usePendingThreadRequests } from "./usePendingThreadRequests";
+import { makeStreamingTimelineFixture, makeThreadProjectionFixture } from "../../test-fixtures";
import {
type PendingApproval,
deriveTimelineEntriesFromVisibleTurnItems,
@@ -223,6 +228,55 @@ function setup({ scheduledTasks = [] as ReadonlyArray<{ id: string; title: strin
const TASK_ID = ScheduledTaskId.make("task-1");
const AUTOMATION_PROMPT = "Triage the inbox\n\nLabel every new issue and close duplicates.";
+it("keeps collapsed approval shortcuts and disabled cancel actions inert", async () => {
+ const requestId = RuntimeRequestId.make("guarded-approval");
+ const onRespond = vi.fn(async () => {});
+ const ui = (disabled = false, isResponding = false) => (
+
+ );
+ await using screen = await renderElement(ui());
+ const header = screen.container.querySelector
(
+ '[data-chat-composer-request-card="approval"] > button',
+ )!;
+ await click(header);
+ await expect.element(screen.getByText("src/index.ts")).not.toBeInTheDocument();
+ for (const key of ["Enter", "Escape"]) {
+ const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
+ document.body.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ }
+ expect(onRespond).not.toHaveBeenCalled();
+ await click(header);
+ await expect.element(screen.getByText("src/index.ts")).toBeVisible();
+ document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
+ expect(onRespond).toHaveBeenLastCalledWith(requestId, "accept");
+ onRespond.mockClear();
+ for (const [disabled, isResponding] of [
+ [true, false],
+ [false, true],
+ ]) {
+ await screen.rerender(ui(disabled, isResponding));
+ const cancel = screen.getByRole("button", { name: "Cancel approval", exact: true }).element();
+ cancel.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ for (const key of ["Enter", " "]) {
+ cancel.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
+ }
+ expect(onRespond).not.toHaveBeenCalled();
+ }
+});
+
describe("MessagesTimeline work rows", () => {
it("keeps a tall HTML preview in place when its last accordion opens at the bottom", async () => {
const props = baseProps();
@@ -326,6 +380,19 @@ describe("MessagesTimeline work rows", () => {
expect(viewport.scrollTop).toBeGreaterThan(0);
const top = heading.element().getBoundingClientRect().top;
const height = viewport.scrollHeight;
+ const fadedRowIds: string[] = [];
+ const listening = new AbortController();
+ onTestFinished(() => listening.abort());
+ document.addEventListener(
+ "animationstart",
+ (event) => {
+ if (event.animationName !== "timeline-fold-reveal" || !(event.target instanceof Element))
+ return;
+ const row = event.target.querySelector("[data-timeline-row-id]");
+ fadedRowIds.push(row?.getAttribute("data-timeline-row-id") ?? "");
+ },
+ { signal: listening.signal },
+ );
await heading.click();
await vi.waitFor(() => {
expect(viewport.scrollHeight).toBeGreaterThan(height + 500);
@@ -334,6 +401,10 @@ describe("MessagesTimeline work rows", () => {
500,
);
});
+ await vi.waitFor(() => {
+ expect(fadedRowIds).toEqual(["entry-progress"]);
+ expect(viewport.querySelector("[data-fold-reveal]")).toBeNull();
+ });
});
it("keeps following a streaming turn after a heading is expanded at the bottom", async () => {
@@ -371,6 +442,197 @@ describe("MessagesTimeline work rows", () => {
await vi.waitFor(() => expect(distanceFromEnd()).toBeLessThan(2));
});
+ it("loads a streamed approval patch when review opens and hides it again", async () => {
+ setup();
+ const itemId = TurnItemId.make("approval-patch");
+ environmentQuery.data = {
+ item: { type: "file_change", id: itemId, diffStr: "@@ -1 +1 @@\n-old\n+proposed" },
+ };
+ onTestFinished(() => setup());
+ await using screen = await renderTimeline([], {
+ onRespondToApproval: async () => {},
+ pendingApprovals: [
+ {
+ requestId: RuntimeRequestId.make("patch-request"),
+ requestKind: "file-change",
+ createdAt: CREATED_AT,
+ detail: "Update two files",
+ responseCapability: "live",
+ files: ["src/one.ts", "src/two.ts"].map((path) => ({
+ path,
+ source: { threadId: ThreadId.make("thread-1"), itemId, revision: "live" },
+ })),
+ },
+ ],
+ });
+ expect(screen.text()).not.toContain("+proposed");
+ await screen.getByRole("button", { name: "Review diff" }).click();
+ await vi.waitFor(() => {
+ expect(screen.getByLabelText("Proposed diff").element().textContent).toContain("+proposed");
+ });
+ expect(screen.getByLabelText("Proposed diff").element().querySelectorAll("pre")).toHaveLength(
+ 1,
+ );
+ await screen.getByRole("button", { name: "Hide diff" }).click();
+ await expect.element(screen.getByLabelText("Proposed diff")).not.toBeInTheDocument();
+ });
+
+ it("keeps live assistant text citable and expands the work window below a fixed bar", async () => {
+ const runId = RunId.make("history-run");
+ await using screen = await renderTimeline(
+ [
+ message("Build it", { runId }),
+ ...Array.from({ length: 2000 }, (_, index) =>
+ message(`History answer ${index}`, { runId, role: "assistant" }, `history-${index}`),
+ ),
+ message("Live answer", { runId, role: "assistant", streaming: true }, "live-answer"),
+ ],
+ {
+ isWorking: true,
+ activeTurnStartedAt: CREATED_AT,
+ latestRun: { runId, status: "running", startedAt: CREATED_AT, completedAt: null },
+ },
+ );
+ await expect.element(screen.getByText("Live answer", { exact: true })).toBeVisible();
+ expect(
+ screen
+ .getByText("Live answer", { exact: true })
+ .element()
+ .closest('[data-assistant-citation-source="live-answer"]'),
+ ).not.toBeNull();
+ const bar = screen.getByRole("button", { name: "Open work history" });
+ await expect.element(bar).toHaveAttribute("aria-expanded", "false");
+ const history = screen.getByRole("region", { name: "Work history" }).element();
+ expect(history.clientHeight).toBeGreaterThan(0);
+ expect(history.clientHeight).toBeLessThanOrEqual(220);
+ const mountedSources = () =>
+ screen.container.querySelectorAll("[data-assistant-citation-source]").length;
+ expect(mountedSources()).toBeLessThan(60);
+ const barElement = bar.element();
+ const barTop = barElement.getBoundingClientRect().top;
+ const viewport = history.querySelector('[data-slot="scroll-area-viewport"]')!;
+ const fullHeight = viewport.scrollHeight;
+ expect(fullHeight).toBeGreaterThan(2000 * 20);
+ expect(fullHeight - viewport.clientHeight - viewport.scrollTop).toBeLessThanOrEqual(2);
+ const tailSources = mountedSources();
+ const tailAnchor = history.querySelector("[data-assistant-citation-source]")!;
+ let tailAnchorTop = 0;
+ await act(() => {
+ viewport.scrollTop = fullHeight - viewport.clientHeight - 400;
+ viewport.dispatchEvent(new Event("scroll"));
+ tailAnchorTop = tailAnchor.getBoundingClientRect().top;
+ });
+ await expect.element(screen.getByRole("button", { name: "Jump to latest" })).toBeVisible();
+ await vi.waitFor(() => expect(mountedSources()).toBeGreaterThan(tailSources));
+ expect(Math.abs(tailAnchor.getBoundingClientRect().top - tailAnchorTop)).toBeLessThanOrEqual(1);
+ await expect.element(bar).toHaveAttribute("aria-expanded", "false");
+ expect(Math.abs(viewport.scrollHeight - fullHeight)).toBeLessThan(fullHeight / 10);
+ expect(mountedSources()).toBeLessThan(70);
+ expect(barElement.getBoundingClientRect().top).toBe(barTop);
+ await screen.getByRole("button", { name: "Jump to latest" }).click();
+ await expect.element(screen.getByText("Live answer", { exact: true })).toBeVisible();
+ await expect
+ .element(screen.getByRole("button", { name: "Jump to latest" }))
+ .not.toBeInTheDocument();
+ await act(() => {
+ viewport.scrollTop = 0;
+ viewport.dispatchEvent(new Event("scroll"));
+ });
+ await expect.element(screen.getByText("History answer 0", { exact: true })).toBeVisible();
+ await vi.waitFor(() => expect(viewport.scrollTop).toBe(0));
+ await screen.getByRole("button", { name: "Jump to latest" }).click();
+ const barTops = new Set();
+ const resizes = new ResizeObserver(() => barTops.add(barElement.getBoundingClientRect().top));
+ resizes.observe(history);
+ using _resizes = { [Symbol.dispose]: () => resizes.disconnect() };
+
+ await bar.click();
+ await expect
+ .element(screen.getByRole("button", { name: "Close work history" }))
+ .toHaveAttribute("aria-expanded", "true");
+ await expect.element(screen.getByText("History answer 0", { exact: true })).toBeInTheDocument();
+ await Promise.all(history.getAnimations().map((animation) => animation.finished));
+ expect(getComputedStyle(history).maxHeight).toBe("none");
+ expect(
+ screen.getByText("History answer 0", { exact: true }).element().getBoundingClientRect().top,
+ ).toBeGreaterThanOrEqual(history.getBoundingClientRect().top);
+ expect(barElement.getBoundingClientRect().top).toBe(barTop);
+ expect([...barTops]).toEqual([barTop]);
+ expect(mountedSources()).toBeLessThan(120);
+
+ await screen.getByRole("button", { name: "Close work history" }).click();
+ await expect.element(barElement).toHaveAttribute("aria-expanded", "false");
+ await Promise.all(history.getAnimations().map((animation) => animation.finished));
+ expect(history.clientHeight).toBeLessThanOrEqual(220);
+ await vi.waitFor(() => expect(mountedSources()).toBeLessThan(60));
+ expect(barElement.getBoundingClientRect().top).toBe(barTop);
+ expect([...barTops]).toEqual([barTop]);
+
+ await bar.click();
+ await expect
+ .element(screen.getByText("History answer 49", { exact: true }))
+ .toBeInTheDocument();
+ await Promise.all(history.getAnimations().map((animation) => animation.finished));
+ expect(screen.container.textContent).not.toContain("History answer 50");
+ await act(() => {
+ screen.getByText("History answer 49", { exact: true }).element().scrollIntoView();
+ });
+ await expect
+ .element(screen.getByText("History answer 99", { exact: true }))
+ .toBeInTheDocument();
+ });
+
+ it("keeps the bar still when work arrives while the history collapses at the bottom", async () => {
+ const runId = RunId.make("collapse-run");
+ const props = {
+ ...baseProps(),
+ isWorking: true,
+ activeTurnStartedAt: CREATED_AT,
+ latestRun: { runId, status: "running" as const, startedAt: CREATED_AT, completedAt: null },
+ };
+ const entries = (steps: number) => [
+ ...Array.from({ length: 40 }, (_, index) =>
+ message(`Earlier ${index}`, {}, `earlier-${index}`),
+ ),
+ message("Build it", { runId }),
+ ...Array.from({ length: steps }, (_, index) =>
+ message(`Step ${index}`, { runId, role: "assistant" }, `step-${index}`),
+ ),
+ ];
+ await using screen = await renderElement(
+ ,
+ );
+ const bar = screen.getByRole("button", { name: "Open work history" });
+ await expect.element(bar).toBeVisible();
+ const viewport = props.listRef.current!.getScrollableNode()!;
+ await vi.waitFor(() =>
+ expect(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop).toBeLessThan(2),
+ );
+ const barElement = bar.element();
+ await bar.click();
+ const history = screen.getByRole("region", { name: "Work history" }).element();
+ await expect.element(screen.getByText("Step 0", { exact: true })).toBeInTheDocument();
+ await Promise.all(history.getAnimations().map((animation) => animation.finished));
+ const barTop = barElement.getBoundingClientRect().top;
+ const expandedHeight = history.clientHeight;
+ const barTops = new Set();
+ const resizes = new ResizeObserver(() => barTops.add(barElement.getBoundingClientRect().top));
+ resizes.observe(history);
+ using _resizes = { [Symbol.dispose]: () => resizes.disconnect() };
+ history.style.transitionDuration = "10s";
+
+ await screen.getByRole("button", { name: "Close work history" }).click();
+ await vi.waitFor(() => expect(history.clientHeight).toBeLessThan(expandedHeight - 20));
+ await screen.rerender( );
+ const arrivedHeight = history.clientHeight;
+ await vi.waitFor(() => expect(history.clientHeight).toBeLessThan(arrivedHeight - 60));
+ for (const animation of history.getAnimations()) animation.finish();
+ await vi.waitFor(() =>
+ expect(history.querySelector('[data-slot="scroll-area-viewport"]')).not.toBeNull(),
+ );
+ expect([...barTops]).toEqual([barTop]);
+ });
+
it.each(["delegated", "legacy", "missing"] as const)(
"uses the reported task for %s completion notifications",
async (source) => {
@@ -1064,7 +1326,7 @@ describe("MessagesTimeline usage limit notice", () => {
it("labels the retry time with the viewer's timezone", async () => {
await using screen = await renderTimeline(usageLimitEntries("2026-03-17T19:30:00.000Z"));
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
- expect(screen.text()).toMatch(/Usage limit reached\. Retry after .+\./);
+ expect(screen.text()).toMatch(/Retry after .+\./);
expect(screen.text()).toContain(`(${timeZone})`);
});
@@ -2048,31 +2310,110 @@ it("keeps concurrent approvals actionable outside collapsed work and restores se
});
const props = baseProps();
const onRespondToApproval = vi.fn(async () => {});
- const ui = (overrides: Partial = {}) => (
-
- );
+ const projection = makeThreadProjectionFixture();
+ function ApprovalTimeline({ overrides }: { overrides: Partial }) {
+ const timelineProps = {
+ ...props,
+ timelineEntries: entries(),
+ pendingApprovals: approvals,
+ canOperateThread: true,
+ onRespondToApproval,
+ isWorking: true,
+ runningRunId: fixture.runId,
+ ...overrides,
+ };
+ const promptRef = useRef("");
+ const composerRef = useRef(null);
+ const requests = usePendingThreadRequests({
+ environmentId: ENVIRONMENT_ID,
+ activeThreadId: projection.thread.id,
+ serverProjection: {
+ ...projection,
+ runtimeRequests: timelineProps.pendingApprovals.map((approval) => ({
+ id: approval.requestId,
+ nodeId: NodeId.make(approval.requestId),
+ providerTurnId: null,
+ nativeRequestRef: null,
+ kind: approval.requestKind,
+ status: "pending",
+ responseCapability:
+ approval.responseCapability === "live"
+ ? { type: "live", providerSessionId: ProviderSessionId.make("approval-session") }
+ : { type: "not_resumable", reason: "Provider process is gone" },
+ createdAt: DateTime.makeUnsafe(approval.createdAt),
+ resolvedAt: null,
+ })),
+ turnItems: timelineProps.pendingApprovals.map((approval) => ({
+ ...sourceItem,
+ type: "approval_request",
+ id: TurnItemId.make(approval.requestId),
+ requestId: approval.requestId,
+ requestKind: approval.requestKind,
+ prompt: approval.detail ?? "",
+ reason: approval.reason,
+ fullAccessOverride: approval.fullAccessOverride,
+ appName: approval.appName,
+ options: approval.options,
+ })),
+ },
+ supportsQuestionAttachments: false,
+ composerDraftTarget: { environmentId: ENVIRONMENT_ID, threadId: projection.thread.id },
+ setComposerDraftPrompt: () => {},
+ promptRef,
+ composerRef,
+ setThreadError: () => {},
+ });
+ const active = requests.activePendingApproval;
+ return (
+ <>
+
+ Approval test link
+
+ Approval role link
+
+
+ Approval summary Details
+
+
+ {active && !timelineProps.heldApprovalRequestIds?.has(active.requestId) ? (
+
+ ) : null}
+ >
+ );
+ }
+ const ui = (overrides: Partial = {}) => ;
await using screen = await renderElement(ui());
+ for (const target of [
+ screen.getByRole("link", { name: "Approval test link" }),
+ screen.getByRole("link", { name: "Approval role link" }),
+ screen.getByText("Approval summary"),
+ screen.getByLabelText("Approval editable"),
+ ]) {
+ const key = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
+ target.element().dispatchEvent(key);
+ expect(key.defaultPrevented).toBe(false);
+ }
+ expect(onRespondToApproval).not.toHaveBeenCalled();
await expect.element(screen.getByRole("group", { name: "Command approval" })).toBeVisible();
await expect
.element(screen.getByRole("group", { name: "App permission approval" }))
.toBeVisible();
- await expect.element(screen.getByText(approvals[0]!.detail!, { exact: true })).toBeVisible();
- await expect.element(screen.getByText("Needs permission to run the command")).toBeVisible();
+ const commandRow = screen.getByRole("group", { name: "Command approval" });
+ await expect.element(commandRow.getByText(approvals[0]!.detail!, { exact: true })).toBeVisible();
+ await expect.element(commandRow.getByText("Needs permission to run the command")).toBeVisible();
await vi.waitFor(() => {
expect(
getComputedStyle(screen.getByLabelText("Command", { exact: true }).element()).userSelect,
).toBe("text");
expect(
- getComputedStyle(screen.getByText("Needs permission to run the command").element())
+ getComputedStyle(commandRow.getByText("Needs permission to run the command").element())
.userSelect,
).toBe("text");
});
@@ -2080,18 +2421,19 @@ it("keeps concurrent approvals actionable outside collapsed work and restores se
await click(screen.button("Why am I being asked this?"));
await expect.element(page.getByText(/an ask rule in the agent's own settings/)).toBeVisible();
await click(screen.button("Why am I being asked this?"));
- await click(screen.buttons("More approval options")[1]!);
- await expect.element(page.getByRole("menuitem", { name: "Always allow Safari" })).toBeVisible();
- await page.getByRole("menuitem", { name: "Always allow Safari" }).click();
- expect(onRespondToApproval).toHaveBeenLastCalledWith(appRequest, "acceptAlways");
- await click(screen.buttons("More approval options")[0]!);
+ await expect.element(screen.getByText("1/2", { exact: true })).toBeVisible();
+ await click(screen.button("More approval options"));
await expect.element(page.getByRole("menuitem", { name: "Cancel", exact: true })).toBeVisible();
- await page.getByRole("menuitem", { name: "Always allow this session" }).click();
+ await userEvent.keyboard("{Escape}");
+ const sessionToggle = screen.getByRole("button", { name: "Always allow this session" });
+ await sessionToggle.click();
+ await expect.element(sessionToggle).toHaveAttribute("aria-pressed", "true");
+ await screen.getByRole("button", { name: "Approve", exact: true }).click();
expect(onRespondToApproval).toHaveBeenLastCalledWith(commandRequest, "acceptForSession");
- await click(screen.button("Decline"));
+ await sessionToggle.click();
+ await expect.element(sessionToggle).toHaveAttribute("aria-pressed", "false");
+ await screen.getByRole("button", { name: "Decline", exact: true }).click();
expect(onRespondToApproval).toHaveBeenLastCalledWith(commandRequest, "decline");
- await click(screen.button("Allow once"));
- expect(onRespondToApproval).toHaveBeenLastCalledWith(appRequest, "accept");
for (const timelineEntries of [entries(), []]) {
await screen.rerender(
ui({ timelineEntries, heldApprovalRequestIds: new Set([commandRequest]) }),
@@ -2099,19 +2441,32 @@ it("keeps concurrent approvals actionable outside collapsed work and restores se
await expect
.element(screen.getByRole("group", { name: "Command approval" }))
.not.toBeInTheDocument();
- await vi.waitFor(() => expect(screen.buttons("Approve")).toHaveLength(0));
await expect
- .element(screen.getByRole("button", { name: "Allow once", exact: true }))
+ .element(screen.getByRole("button", { name: "Approve", exact: true }))
+ .not.toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("group", { name: "App permission approval" }))
.toBeVisible();
await screen.rerender(ui({ timelineEntries }));
await expect.element(screen.getByRole("group", { name: "Command approval" })).toBeVisible();
- await vi.waitFor(() => expect(screen.buttons("Approve")).toHaveLength(1));
+ await expect
+ .element(screen.getByRole("button", { name: "Approve", exact: true }))
+ .toBeVisible();
}
await screen.rerender(ui({ timelineEntries: [], isWorking: false }));
await expect.element(screen.getByRole("group", { name: "Command approval" })).toBeVisible();
- await click(screen.button("Approve"));
+ await screen.getByRole("button", { name: "Approve", exact: true }).click();
expect(onRespondToApproval).toHaveBeenLastCalledWith(commandRequest, "accept");
- await click(screen.button("Deny"));
+ // The resolved request leaves the projection, so the real tray queue advances.
+ await screen.rerender(ui({ pendingApprovals: [approvals[1]!] }));
+ await expect.element(screen.getByRole("button", { name: "Allow once" })).toBeVisible();
+ await click(screen.button("More approval options"));
+ await expect.element(page.getByRole("menuitem", { name: "Always allow Safari" })).toBeVisible();
+ await page.getByRole("menuitem", { name: "Always allow Safari" }).click();
+ expect(onRespondToApproval).toHaveBeenLastCalledWith(appRequest, "acceptAlways");
+ await screen.getByRole("button", { name: "Allow once", exact: true }).click();
+ expect(onRespondToApproval).toHaveBeenLastCalledWith(appRequest, "accept");
+ await screen.getByRole("button", { name: "Decline", exact: true }).click();
expect(onRespondToApproval).toHaveBeenLastCalledWith(appRequest, "decline");
await screen.rerender(
ui({
@@ -2132,6 +2487,9 @@ it("keeps concurrent approvals actionable outside collapsed work and restores se
);
await screen.rerender(ui({ respondingRequestIds: [commandRequest] }));
await expect.element(screen.getByRole("button", { name: "Approve", exact: true })).toBeDisabled();
+ await screen.rerender(
+ ui({ pendingApprovals: [approvals[1]!], respondingRequestIds: [commandRequest] }),
+ );
await expect
.element(screen.getByRole("button", { name: "Allow once", exact: true }))
.toBeEnabled();
@@ -2141,7 +2499,7 @@ it("keeps concurrent approvals actionable outside collapsed work and restores se
await expect
.element(screen.getByText("Provider process is gone. Interrupt or restart the run to respond."))
.toBeVisible();
- await vi.waitFor(() => expect(screen.buttons("Approve")).toHaveLength(0));
+ await expect.element(screen.getByRole("button", { name: "Approve", exact: true })).toBeDisabled();
await screen.rerender(
ui({ pendingApprovals: [], timelineEntries: entries(true), isWorking: false }),
);
@@ -2157,3 +2515,225 @@ it("keeps concurrent approvals actionable outside collapsed work and restores se
.element(screen.getByRole("button", { name: "Open the current tab", exact: true }))
.toBeVisible();
});
+
+describe("MessagesTimeline work rows", () => {
+ it("keeps working through an async question and pauses only when it blocks", async () => {
+ const runId = RunId.make("async-question-run");
+ const pendingRequest = {
+ kind: "question" as const,
+ label: "Which approach should I use?",
+ createdAt: CREATED_AT,
+ blocking: false,
+ };
+ const props = {
+ ...baseProps(),
+ timelineEntries: [message("Continue working", { runId })],
+ isWorking: true,
+ activeTurnInProgress: true,
+ activeTurnStartedAt: CREATED_AT,
+ latestRun: { runId, status: "running" as const, startedAt: CREATED_AT, completedAt: null },
+ pendingRequest,
+ };
+ await using screen = await renderElement( );
+ expect(screen.text()).toContain("Working for");
+ expect(screen.text()).not.toContain("Paused at");
+
+ await screen.rerender(
+ ,
+ );
+ expect(screen.text()).toContain("Paused at");
+ expect(screen.text()).toContain(pendingRequest.label);
+
+ await screen.rerender( );
+ expect(screen.text()).toContain("Working for");
+ expect(screen.text()).not.toContain("Paused at");
+ });
+
+ it.each(["run-backed", "runless"] as const)(
+ "folds the %s working history once it outgrows the collapsed window and keeps it open as the turn fold",
+ async (kind) => {
+ const runId = kind === "runless" ? null : RunId.make("growing-run");
+ const entries = (count: number) => [
+ message("Build it", { runId }),
+ ...Array.from({ length: count }, (_, index) =>
+ message(`Step ${index}`, { runId, role: "assistant" }, `step-${index}`),
+ ),
+ ];
+ const props = {
+ ...baseProps(),
+ isWorking: true,
+ runlessWorkActive: runId === null,
+ activeTurnStartedAt: CREATED_AT,
+ latestRun:
+ runId === null
+ ? null
+ : { runId, status: "running" as const, startedAt: CREATED_AT, completedAt: null },
+ };
+ await using screen = await renderElement(
+ ,
+ );
+ await expect.element(screen.getByText("Step 1", { exact: true })).toBeVisible();
+ expect(screen.buttons(/work history/)).toEqual([]);
+ const label = screen
+ .getByText(/^Working for/)
+ .element()
+ .closest("button")!;
+ expect(label.disabled).toBe(true);
+ expect(label.hasAttribute("aria-expanded")).toBe(false);
+
+ await screen.rerender( );
+ await expect
+ .element(screen.getByRole("button", { name: "Open work history" }))
+ .toHaveAttribute("aria-expanded", "false");
+
+ await screen.getByRole("button", { name: "Open work history" }).click();
+ await expect
+ .element(screen.getByRole("button", { name: "Close work history" }))
+ .toHaveAttribute("aria-expanded", "true");
+ await screen.rerender(
+ ,
+ );
+ await expect
+ .element(screen.getByRole("button", { name: /^Worked for/ }))
+ .toHaveAttribute("aria-expanded", "true");
+ },
+ );
+
+ it("stops showing Thinking in the working window when the provider turn ends", async () => {
+ const runId = RunId.make("ended-provider-run");
+ const props = {
+ ...baseProps(),
+ timelineEntries: [message("Continue the queue", { runId })],
+ isWorking: true,
+ activeTurnInProgress: true,
+ activeTurnStartedAt: CREATED_AT,
+ latestRun: { runId, status: "running" as const, startedAt: CREATED_AT, completedAt: null },
+ };
+ await using screen = await renderElement( );
+ expect(screen.text()).toContain("Thinking");
+
+ await screen.rerender(
+ ,
+ );
+ expect(screen.text()).not.toContain("Thinking");
+ expect(screen.text()).toContain("Working for");
+ });
+
+ it("updates reset labels across midnight and expires snooze at the deadline", async () => {
+ const now = new Date(2026, 9, 18, 23, 59, 30);
+ const resetAt = new Date(2026, 9, 19, 0, 0, 15).toISOString();
+ const runId = RunId.make("limited-run");
+ using _timers = fakeTimers({ now });
+ await using screen = await renderTimeline(
+ [
+ message("Build it", { runId }),
+ work("usage-limit", {
+ runId,
+ label: "Usage limit reached.",
+ tone: "error",
+ itemType: "error",
+ toolLifecycleStatus: "failed",
+ projectedItem: v2Item("error", {
+ runId,
+ status: "failed",
+ failure: { class: "usage_limit", message: "Limit reached", resetAt },
+ }),
+ }),
+ ],
+ {
+ latestRun: { runId, status: "failed", startedAt: CREATED_AT, completedAt: CREATED_AT },
+ usageLimit: { runId, resetAt, snoozed: false, onToggleSnooze: vi.fn() },
+ },
+ );
+ expect(screen.text()).toContain("resumes tomorrow at ");
+ await act(async () => vi.advanceTimersByTimeAsync(30_000));
+ expect(screen.text()).toContain("resumes ");
+ expect(screen.text()).not.toContain("tomorrow at ");
+ await act(async () => vi.advanceTimersByTimeAsync(14_999));
+ expect(screen.buttons("Snooze")).toHaveLength(1);
+ await act(async () => vi.advanceTimersByTimeAsync(1));
+ expect(screen.text()).not.toContain("resumes ");
+ expect(screen.buttons("Snooze")).toHaveLength(0);
+ });
+
+ it("expands every background group for a cited run and allows collapsing them again", async () => {
+ const fixture = makeStreamingTimelineFixture("Cited response after the second update.");
+ const entries = deriveTimelineEntriesFromVisibleTurnItems({
+ visibleTurnItems: fixture.visibleTurnItems
+ .filter(
+ (row) =>
+ row.item.type === "user_message" ||
+ (row.item.type === "assistant_message" && row.item.runId === fixture.runId),
+ )
+ .map((row): OrchestrationV2ProjectedTurnItem => ({
+ ...row,
+ item:
+ row.item.type === "user_message"
+ ? {
+ ...row.item,
+ runId: fixture.runId,
+ type: "notification",
+ source: { kind: "background_task" },
+ outcome: "updated",
+ summary: "Background task updated",
+ }
+ : row.item.type === "assistant_message"
+ ? { ...row.item, status: "completed", streaming: false }
+ : row.item,
+ })),
+ optimisticMessages: [],
+ });
+ const feedback = message("Feedback sent between updates.") as Extract<
+ Entry,
+ { kind: "message" }
+ >;
+ const timelineEntries = [entries[0]!, feedback, ...entries.slice(1)];
+ const props = { ...baseProps(), timelineEntries };
+ const citationRequest = {
+ key: "second-background-group",
+ citation: {
+ version: 1 as const,
+ environmentId: ENVIRONMENT_ID,
+ threadId: fixture.threadId,
+ messageId: MessageId.make("live-assistant"),
+ text: "Cited response",
+ start: 0,
+ end: 14,
+ prefix: "",
+ suffix: "",
+ },
+ };
+ await using screen = await renderElement( );
+ const groups = () => screen.buttons("1 wake-up: Background task updated");
+ await vi.waitFor(() =>
+ expect(groups().map((node) => node.getAttribute("aria-expanded"))).toEqual([
+ "false",
+ "false",
+ ]),
+ );
+ expect(screen.text()).toContain(feedback.message.text);
+ expect(screen.text()).toContain("Cited response");
+
+ await screen.rerender( );
+ await vi.waitFor(() =>
+ expect(groups().map((node) => node.getAttribute("aria-expanded"))).toEqual(["true", "true"]),
+ );
+ expect(screen.text()).toContain("Cited response");
+
+ await screen.rerender( );
+ await click(groups()[1]!);
+ await vi.waitFor(() =>
+ expect(groups().map((node) => node.getAttribute("aria-expanded"))).toEqual(["true", "false"]),
+ );
+ expect(screen.text()).toContain("Cited response");
+ expect(screen.text()).toContain(feedback.message.text);
+ });
+});
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
index 09cbfe4e7775a..bec1d961b2a92 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
@@ -17,21 +17,28 @@ import {
deriveTimelineEntriesFromVisibleTurnItemsWithState,
workEntryDisplayIndicatesToolFailure,
type TimelineEntry,
+ type WorkLogEntry,
} from "../../session-logic";
import { makeStreamingTimelineFixture } from "../../test-fixtures";
import type { TurnDiffSummary } from "../../types";
import { describe, expect, it } from "vite-plus/test";
import { MessageId, RunId } from "@t3tools/contracts";
import {
+ applyWorkingWindow,
+ applyBackgroundActivityGroups,
computeStableMessagesTimelineRows,
deriveMessagesTimelineRows,
timelineTurnFoldRunIdsByEntryId,
liveAnswerPreview,
+ withRunGroupsOpen,
+ withTurnFoldOpen,
deriveMessagesTimelineRowsWithState,
+ pendingApprovalSlotLabel,
resolveAssistantMessageCopyState,
type MessagesTimelineRow,
workEntryDisplayLabel,
} from "./MessagesTimeline.logic";
+import { presentSlashCommand } from "./timelineSlashCommands";
describe("resolveAssistantMessageCopyState", () => {
it("copies the rendered representation of Codex directives", () => {
@@ -590,7 +597,7 @@ describe("deriveMessagesTimelineRows", () => {
},
);
- it("shows each compaction trigger as one live status, then a reasoned token divider", () => {
+ it("shows each automatic compaction trigger as one live status, then a reasoned token divider", () => {
const fixture = makeStreamingTimelineFixture();
const [history0, history1, history2, liveUser, , liveAssistant] = fixture.visibleTurnItems;
if (liveUser?.item.type !== "user_message" || liveAssistant?.item.type !== "assistant_message")
@@ -640,7 +647,6 @@ describe("deriveMessagesTimelineRows", () => {
"Context automatically compacted before cache expiry",
],
["onReturn", { kind: "working", compacting: true }, "Context compacted before sending"],
- [undefined, { kind: "context-compaction", active: true }, undefined],
] as const) {
const prompt = compactPrompt(reason);
for (const rows of [rowsFor([prompt]), rowsFor([prompt, compaction])]) {
@@ -757,11 +763,15 @@ describe("deriveMessagesTimelineRows", () => {
});
expect(rows.filter((row) => row.kind === "turn-fold")).toHaveLength(1);
expect(rows.some((row) => row.kind === "work")).toBe(false);
- expect(rows.filter((row) => row.kind === "context-compaction")).toMatchObject([
- { active: false, tokenCounts: { before: "100K", after: "20K" } },
- ]);
if (promptText === "/compact") {
- expect(rows.some((row) => row.kind === "message")).toBe(false);
+ expect(rows.some((row) => row.kind === "context-compaction")).toBe(false);
+ expect(rows.find((row) => row.kind === "message")).toMatchObject({
+ command: { name: "compact", standalone: true, result: "100K → 20K tokens" },
+ });
+ } else {
+ expect(rows.filter((row) => row.kind === "context-compaction")).toMatchObject([
+ { active: false, tokenCounts: { before: "100K", after: "20K" } },
+ ]);
}
},
);
@@ -965,9 +975,9 @@ describe("deriveMessagesTimelineRows", () => {
expect(liveRowsFor([prompt, compaction, interruptResult])).toMatchObject([
{ kind: "context-compaction", label: "Context compaction stopped" },
]);
- // Stopped before the provider started: no compaction row says so, so the interrupt stays.
- expect(liveRowsFor([prompt, interruptResult]).map((row) => row.id)).toContain(
- "interrupt-result",
+ // Before the provider starts, the stopped fold still reports the interruption.
+ expect(liveRowsFor([prompt, interruptResult])).toContainEqual(
+ expect.objectContaining({ kind: "turn-fold", ending: "stopped", foldable: false }),
);
});
@@ -1095,6 +1105,87 @@ describe("deriveMessagesTimelineRows", () => {
expect(userRow?.revertTurnCount).toBe(0);
expect(assistantRow?.assistantTurnDiffSummary).toBe(assistantTurnDiffSummary);
+ expect(rows.find((row) => row.kind === "turn-fold")).toMatchObject({
+ label: "Worked for 30s",
+ foldable: false,
+ diff: { additions: 3, deletions: 1 },
+ });
+ });
+
+ it("keeps a text-only turn's bar through streaming, settlement, and the next turn", () => {
+ const runId = RunId.make("text-only");
+ const prompt: TimelineEntry = {
+ id: "prompt",
+ kind: "message",
+ createdAt: "2026-01-01T00:00:00Z",
+ message: {
+ id: MessageId.make("prompt"),
+ role: "user",
+ text: "reply ok",
+ runId,
+ inputIntent: "turn_start",
+ streaming: false,
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: "2026-01-01T00:00:00Z",
+ },
+ };
+ const reply: TimelineEntry = {
+ ...prompt,
+ id: "reply",
+ createdAt: "2026-01-01T00:00:02Z",
+ message: {
+ ...prompt.message,
+ id: MessageId.make("reply"),
+ role: "assistant",
+ text: "ok",
+ createdAt: "2026-01-01T00:00:02Z",
+ updatedAt: "2026-01-01T00:00:03Z",
+ },
+ };
+ const input = {
+ timelineEntries: [prompt, reply],
+ latestRun: {
+ runId,
+ status: "running" as const,
+ startedAt: prompt.createdAt,
+ completedAt: null,
+ },
+ activeTurnStartedAt: prompt.createdAt,
+ isWorking: true,
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ };
+ for (const streaming of [true, false]) {
+ const running = {
+ ...input,
+ timelineEntries: [prompt, { ...reply, message: { ...reply.message, streaming } }],
+ };
+ const rows = applyWorkingWindow(deriveMessagesTimelineRows(running), running);
+ expect(rows.map((row) => row.id)).toEqual(["prompt", "working-indicator-row", "reply"]);
+ expect(rows.filter((row) => row.kind === "working")).toMatchObject([
+ { createdAt: prompt.createdAt, window: { items: [], answering: streaming } },
+ ]);
+ expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
+ }
+ const settled = deriveMessagesTimelineRows({
+ ...input,
+ isWorking: false,
+ latestRun: { ...input.latestRun, status: "completed", completedAt: reply.message.updatedAt },
+ });
+ expect(settled.map((row) => row.id)).toEqual(["prompt", "turn-fold:text-only", "reply"]);
+ expect(settled[1]).toMatchObject({ label: "Worked for 3s", foldable: false, diff: null });
+ const next = {
+ ...input,
+ timelineEntries: [
+ prompt,
+ reply,
+ { ...prompt, id: "next", message: { ...prompt.message, runId: RunId.make("next") } },
+ ],
+ latestRun: { ...input.latestRun, runId: RunId.make("next") },
+ };
+ expect(
+ deriveMessagesTimelineRows(next).filter((row) => row.kind === "turn-fold"),
+ ).toMatchObject([{ runId, label: "Worked for 3s", foldable: false }]);
});
it("folds the first assistant message and settled work before the terminal response", () => {
@@ -1401,7 +1492,12 @@ describe("deriveMessagesTimelineRows", () => {
expect(foldRow?.label).toBe("Worked for 12s");
});
- it("uses latest-turn timings and the stopped label for an interrupted latest turn", () => {
+ it.each([
+ ["00:00:00.400Z", "Stopped at 0s"],
+ ["00:00:59.700Z", "Stopped at 59s"],
+ ["00:01:12.700Z", "Stopped at 1m 12s"],
+ ["01:02:03.900Z", "Stopped at 1h 2m 3s"],
+ ])("uses latest-turn timings and the live timer format, ending at %s", (completedAt, label) => {
const rows = deriveMessagesTimelineRows({
timelineEntries: [
{
@@ -1421,7 +1517,7 @@ describe("deriveMessagesTimelineRows", () => {
runId: "turn-1" as never,
status: "interrupted",
startedAt: "2026-01-01T00:00:00Z",
- completedAt: "2026-01-01T00:00:47Z",
+ completedAt: `2026-01-01T${completedAt}`,
},
isWorking: false,
activeTurnStartedAt: null,
@@ -1433,14 +1529,15 @@ describe("deriveMessagesTimelineRows", () => {
expect.objectContaining({
kind: "turn-fold",
runId: "turn-1",
- label: "You stopped after 47s",
+ label,
+ ending: "stopped",
expanded: false,
}),
]);
});
it.each(["steer", "promoted_queued_to_steer"] as const)(
- "keeps the duration header at the initiating prompt when %s arrives before output",
+ "puts the fold after a %s that arrives before output",
(inputIntent) => {
const runId = RunId.make("steered-run");
const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString();
@@ -1502,13 +1599,14 @@ describe("deriveMessagesTimelineRows", () => {
turnDiffSummaries: [],
supportsConversationRollback: false,
});
- expect(rows.slice(0, 3).map((row) => row.id)).toEqual([
- "initial-prompt",
- isWorking ? "working-indicator-row" : `turn-fold:${runId}`,
- "steer",
- ]);
- expect(rows[1]?.createdAt).toBe(time(0));
- if (!isWorking) expect(rows[1]).toMatchObject({ label: "Worked for 20s", expanded });
+ expect(rows.slice(0, 3).map((row) => row.id)).toEqual(
+ isWorking
+ ? ["initial-prompt", "working-indicator-row", "steer"]
+ : ["initial-prompt", "steer", `turn-fold:${runId}`],
+ );
+ const header = rows.find((row) => row.kind === "working" || row.kind === "turn-fold");
+ expect(header?.createdAt).toBe(time(0));
+ if (!isWorking) expect(header).toMatchObject({ expanded });
expect(rows.some((row) => row.id === "final")).toBe(true);
expect(rows.some((row) => row.id === "work")).toBe(isWorking || expanded);
}
@@ -1516,7 +1614,7 @@ describe("deriveMessagesTimelineRows", () => {
},
);
- it("splits a settled turn's work at each steer and keeps the total time on top", () => {
+ it("folds a steered turn once, after its last steer, and keeps replies between steers", () => {
const runId = RunId.make("steered-run");
const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString();
const message = (
@@ -1539,51 +1637,93 @@ describe("deriveMessagesTimelineRows", () => {
streaming: false,
},
});
- const work = (id: string, second: number): TimelineEntry => ({
+ const edit = (id: string, second: number, additions: number): TimelineEntry => ({
kind: "work",
id,
createdAt: time(second),
- entry: { id, createdAt: time(second), runId, label: "Ran command", tone: "tool" },
+ entry: {
+ id,
+ createdAt: time(second),
+ runId,
+ label: "Edited file",
+ tone: "tool",
+ itemType: "file_change",
+ projectedItem: {
+ position: 0,
+ visibility: "local",
+ sourceThreadId: ThreadId.make("thread"),
+ sourceItemId: id,
+ item: { id, runId, status: "completed", type: "file_change", additions, deletions: 1 },
+ } as never,
+ },
});
- const rows = (expandedRunIds: ReadonlySet) =>
+ const rows = (
+ status: "completed" | "interrupted",
+ expandedRunIds: ReadonlySet = new Set(),
+ ) =>
deriveMessagesTimelineRows({
timelineEntries: [
message("prompt", 0, "user", "turn_start"),
- work("work-before-steer", 4),
- message("steer", 10, "user", "steer"),
- work("work-after-steer", 15),
+ edit("edit-before-steers", 4, 10),
+ message("first-steer", 10, "user", "steer"),
+ message("second-steer", 12, "user", "steer"),
+ edit("edit-before-reply", 15, 20),
+ message("reply", 20, "assistant"),
+ message("third-steer", 25, "user", "steer"),
+ edit("edit-after-steers", 28, 30),
message("final", 30, "assistant"),
],
- latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(30) },
+ latestRun: { runId, status, startedAt: time(0), completedAt: time(30) },
isWorking: false,
expandedRunIds,
- turnDiffSummaries: [],
+ turnDiffSummaries: [
+ {
+ runId,
+ completedAt: time(30),
+ assistantMessageId: MessageId.make("final"),
+ checkpointTurnCount: 1,
+ checkpointRef: "checkpoint-1" as never,
+ status: "ready",
+ files: [{ path: "a.ts", kind: "modified", additions: 30, deletions: 2 }],
+ },
+ ],
supportsConversationRollback: false,
});
- const segmentKey = RunId.make(`${runId}:steer:steer`);
- const collapsed = rows(new Set());
+ const collapsed = rows("completed");
expect(collapsed.map((row) => row.id)).toEqual([
"prompt",
+ "first-steer",
+ "second-steer",
+ "reply",
+ "third-steer",
`turn-fold:${runId}`,
- "steer",
- `turn-fold:${segmentKey}`,
"final",
]);
- expect(collapsed[1]).toMatchObject({ label: "Worked for 30s" });
- expect(collapsed[3]).toMatchObject({ label: "Continued for 20s", createdAt: time(10) });
+ expect(collapsed[5]).toMatchObject({
+ createdAt: time(0),
+ ending: "worked",
+ diff: { additions: 30, deletions: 2 },
+ });
+ expect(rows("interrupted").filter((row) => row.kind === "turn-fold")).toEqual([
+ expect.objectContaining({ ending: "stopped" }),
+ ]);
- expect(rows(new Set([segmentKey])).map((row) => row.id)).toEqual([
+ const expanded = rows("completed", new Set([runId]));
+ expect(expanded.map((row) => row.id)).toEqual([
"prompt",
+ "first-steer",
+ "second-steer",
+ "reply",
+ "third-steer",
`turn-fold:${runId}`,
- "steer",
- `turn-fold:${segmentKey}`,
- "work-after-steer",
+ "work-toggle:edit-before-steers",
"final",
]);
+ expect(expanded[6]).toMatchObject({ hiddenCount: 3 });
});
- it("stacks back-to-back steers as one burst with a single steer label", () => {
+ it("stacks back-to-back steers under their prompt as one burst", () => {
const runId = RunId.make("double-steer-run");
const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString();
const message = (
@@ -1640,16 +1780,14 @@ describe("deriveMessagesTimelineRows", () => {
).result;
const byId = new Map(rows.map((row) => [row.id, row]));
- expect(byId.get("prompt")).not.toHaveProperty("groupedWithNext");
+ expect(byId.get("prompt")).toMatchObject({ groupedWithNext: true });
expect(byId.get("first-steer")).toMatchObject({
groupedWithNext: true,
- groupedWithPrevious: false,
- continuesSteer: false,
+ groupedWithPrevious: true,
});
expect(byId.get("second-steer")).toMatchObject({
groupedWithNext: false,
groupedWithPrevious: true,
- continuesSteer: true,
});
});
@@ -2137,15 +2275,21 @@ describe("deriveMessagesTimelineRows", () => {
"work-live",
]);
- expect(shape(rows({ resume: "failed", working: false }))).toEqual([
+ // A failed run stays open under a fold that names how it ended and carries
+ // the error, as on a normal thread.
+ const failedRows = rows({ resume: "failed", working: false });
+ expect(shape(failedRows)).toEqual([
"user:launch",
"fold:Worked for 8s",
"assistant:launch-answer",
"user:resume",
- "work",
+ "fold:Failed after 8s",
"work",
"assistant:resume-answer",
]);
+ expect(failedRows.findLast((row) => row.kind === "turn-fold")).toMatchObject({
+ failureMessage: "Subagent failed",
+ });
});
it("keeps answered questions in their own row outside tool groups and turn folds", () => {
@@ -2439,7 +2583,7 @@ describe("deriveMessagesTimelineRows", () => {
];
const foldInput = { timelineEntries, latestRun: null, isWorking: false };
const foldRunIds = timelineTurnFoldRunIdsByEntryId(foldInput);
- const foldRunId = foldRunIds.get("imported-update") ?? null;
+ const foldRunId = foldRunIds.get("imported-update")?.runId ?? null;
expect(foldRunId).not.toBeNull();
const foldRow = deriveMessagesTimelineRows({
...foldInput,
@@ -3464,7 +3608,7 @@ describe("v2 run and attempt history", () => {
);
});
- it("hides the interruption request while keeping intervening work and the result", () => {
+ it("folds a stopped turn and drops its interrupt request and result", () => {
const runId = "turn-1" as never;
const interruptEvent = (type: "run_interrupt_request" | "run_interrupt_result") => ({
position: type === "run_interrupt_request" ? 0 : 2,
@@ -3490,7 +3634,7 @@ describe("v2 run and attempt history", () => {
message: type === "run_interrupt_request" ? "Stopping" : "Stopped",
},
});
- const rows = deriveMessagesTimelineRows({
+ const input = {
timelineEntries: [
{
id: "interrupt-request",
@@ -3526,11 +3670,17 @@ describe("v2 run and attempt history", () => {
isWorking: false,
turnDiffSummaries: [],
supportsConversationRollback: false,
- });
-
- expect(rows.map((row) => row.id)).toEqual(["work-entry", "interrupt-result"]);
- expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
+ } satisfies Parameters[0];
+ const rows = deriveMessagesTimelineRows(input);
+ expect(rows).toEqual([
+ expect.objectContaining({ id: "turn-fold:turn-1", ending: "stopped", expanded: false }),
+ ]);
+ const expandedRows = deriveMessagesTimelineRows({
+ ...input,
+ expandedRunIds: new Set([runId]),
+ });
+ expect(expandedRows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "work-entry"]);
const stoppedRows = deriveMessagesTimelineRows({
timelineEntries: [
{
@@ -3564,12 +3714,69 @@ describe("v2 run and attempt history", () => {
isWorking: false,
turnDiffSummaries: [],
supportsConversationRollback: false,
+ expandedRunIds: new Set([runId]),
});
- expect(stoppedRows.map((row) => row.id)).toEqual(["stopped-command-entry", "interrupt-result"]);
- expect(stoppedRows[0]).toMatchObject({
+ expect(stoppedRows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "stopped-command-entry"]);
+ expect(stoppedRows[1]).toMatchObject({
kind: "work",
displayLabel: "sleep 90 && echo slept",
});
+ const steeredInput = {
+ ...input,
+ timelineEntries: [
+ input.timelineEntries[1]!,
+ {
+ id: "steer",
+ kind: "message",
+ createdAt: "2026-01-01T00:00:02.500Z",
+ message: {
+ id: MessageId.make("steer"),
+ role: "user",
+ text: "Stop after this tool",
+ runId,
+ inputIntent: "steer",
+ streaming: false,
+ createdAt: "2026-01-01T00:00:02.500Z",
+ updatedAt: "2026-01-01T00:00:02.500Z",
+ },
+ },
+ input.timelineEntries[2]!,
+ ],
+ } satisfies Parameters[0];
+ const steeredRows = deriveMessagesTimelineRows(steeredInput);
+ expect(steeredRows).toEqual([
+ expect.objectContaining({ id: "steer" }),
+ expect.objectContaining({ id: "turn-fold:turn-1", ending: "stopped", foldable: true }),
+ ]);
+ expect(
+ deriveMessagesTimelineRows({ ...steeredInput, expandedRunIds: new Set([runId]) }).map(
+ (row) => row.id,
+ ),
+ ).toEqual(["steer", "turn-fold:turn-1", "work-entry"]);
+ // A stop before any work still states the stop, with nothing to open.
+ const noWorkRows = deriveMessagesTimelineRows({
+ ...input,
+ timelineEntries: [
+ {
+ id: "prompt",
+ kind: "message",
+ createdAt: "2026-01-01T00:00:00Z",
+ message: {
+ id: MessageId.make("prompt"),
+ role: "user",
+ text: "Run the suite",
+ runId,
+ streaming: false,
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: "2026-01-01T00:00:00Z",
+ },
+ },
+ input.timelineEntries[0]!,
+ input.timelineEntries[2]!,
+ ],
+ });
+ expect(noWorkRows.map((row) => row.id)).toEqual(["prompt", "turn-fold:turn-1"]);
+ expect(noWorkRows[1]).toMatchObject({ ending: "stopped", foldable: false });
});
});
@@ -3586,6 +3793,12 @@ it.each([
const row: Extract = {
kind: "turn-fold",
id: "fold",
+ ending: "worked",
+ diff: null,
+ foldable: true,
+ failureMessage: null,
+ noticeMessage: null,
+ resetAt: null,
runId: RunId.make("run"),
createdAt: "2026-01-01T00:00:00Z",
label: "Worked for 1m",
@@ -4086,7 +4299,7 @@ describe("linked timeline resources", () => {
]);
});
- it("shows a created chat after a steer only when that steer's segment is open", () => {
+ it("folds a created chat after a steer under the turn's one fold", () => {
const message = (id: string, role: "user" | "assistant", second: number) => ({
kind: "message" as const,
id,
@@ -4109,18 +4322,17 @@ describe("linked timeline resources", () => {
message("after-steer", "assistant", 3),
message("final", "assistant", 4),
];
- const segmentKey = RunId.make(`${runId}:steer:steer`);
const rowIds = (expanded: RunId[]) =>
deriveMessagesTimelineRows({
...common,
timelineEntries,
expandedRunIds: new Set(expanded),
}).map((row) => row.id);
- expect(rowIds([runId])).not.toContain("created");
- expect(rowIds([segmentKey])).toEqual([
- `turn-fold:${runId}`,
+ expect(rowIds([])).not.toContain("created");
+ expect(rowIds([runId])).toEqual([
+ "intro",
"steer",
- `turn-fold:${segmentKey}`,
+ `turn-fold:${runId}`,
"created",
"after-steer",
"final",
@@ -4454,18 +4666,20 @@ describe("failed turn transcript", () => {
turnDiffSummaries: [],
supportsConversationRollback: false,
});
- expect(rows.some((row) => row.kind === "turn-fold" || row.kind === "attempt-fold")).toBe(
- false,
- );
- const work = rows.flatMap((row) => (row.kind === "work" ? row.groupedEntries : []));
+ expect(rows.some((row) => row.kind === "attempt-fold")).toBe(false);
expect(rows.find((row) => row.kind === "work-toggle")).toEqual(
isLatest ? expect.objectContaining({ hiddenCount: 3, expanded: false }) : undefined,
);
- expect(work.map((entry) => entry.id)).toEqual(
- isLatest ? ["failure"] : ["command", "failure"],
- );
- expect(work.at(-1)?.detail).toBe("The provider stopped this turn.\nRetry later.");
- expect(work.at(-1)?.createdAt).toBe("2026-09-20T12:00:00.000Z");
+ expect(rows.filter((row) => row.kind === "turn-fold")).toEqual([
+ expect.objectContaining({
+ ending: failureClass === "usage_limit" ? "limited" : "failed",
+ expanded: true,
+ foldable: true,
+ failureMessage: "The provider stopped this turn.\nRetry later.",
+ }),
+ ]);
+ const work = rows.flatMap((row) => (row.kind === "work" ? row.groupedEntries : []));
+ expect(work.map((entry) => entry.id)).toEqual(isLatest ? [] : ["command"]);
if (isLatest) return;
const entriesWithoutTools = deriveTimelineEntriesFromVisibleTurnItems({
visibleTurnItems: items.filter((row) => row.item.type !== "command_execution"),
@@ -4491,149 +4705,900 @@ describe("failed turn transcript", () => {
turnDiffSummaries: [],
supportsConversationRollback: false,
});
- expect(compactRows.map((row) => row.kind)).toEqual([
- "message",
- "message",
- "work",
- "assistant-meta",
- ]);
- expect(compactRows[1]).toMatchObject({ showAssistantMeta: false });
- expect(compactRows.at(-1)).toMatchObject({
- kind: "assistant-meta",
- showAssistantCopyButton: true,
+ // With nothing else to fold, the fold only names the failure; the text keeps its footer.
+ expect(compactRows.map((row) => row.kind)).toEqual(["message", "turn-fold", "message"]);
+ expect(compactRows[1]).toMatchObject({
+ foldable: false,
+ failureMessage: "The provider stopped this turn.\nRetry later.",
+ });
+ expect(compactRows[2]).toMatchObject({
+ showAssistantMeta: true,
message: { id: "assistant-before-failure" },
});
},
);
});
-describe("live subagents after their parent turn settles", () => {
- const runId = RunId.make("parent-run");
- const threadId = ThreadId.make("parent-thread");
- const providerTurnId = ProviderTurnId.make("parent-provider-turn");
- const at = (second: number) =>
- DateTime.makeUnsafe(`2026-10-06T10:00:${String(second).padStart(2, "0")}Z`);
- const base = (id: string, second: number) => ({
- id: TurnItemId.make(id),
- threadId,
- runId,
- nodeId: null,
- providerThreadId: null,
- providerTurnId,
- nativeItemRef: null,
- parentItemId: null,
- ordinal: second,
- title: null,
- startedAt: at(second),
- completedAt: at(second),
- updatedAt: at(second),
+describe("working window", () => {
+ const runId = RunId.make("window-run");
+ const time = (second: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, second)).toISOString();
+ const prompt = (id: string, second: number, inputIntent?: "steer") => ({
+ id,
+ kind: "message" as const,
+ createdAt: time(second),
+ message: {
+ id: MessageId.make(id),
+ role: "user" as const,
+ text: id,
+ ...(inputIntent ? { inputIntent, runId } : {}),
+ createdAt: time(second),
+ updatedAt: time(second),
+ streaming: false,
+ },
});
- const projected = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({
- position: item.ordinal,
- visibility: "local",
- sourceThreadId: threadId,
- sourceItemId: item.id,
- item,
+ const turnItem = (id: string, fields: Record) => ({
+ position: 0,
+ visibility: "local" as const,
+ sourceThreadId: ThreadId.make("thread"),
+ sourceItemId: id,
+ item: { id, runId, status: "completed", parentItemId: null, ...fields },
});
- const child = (
+ const work = (
id: string,
second: number,
- status: OrchestrationV2TurnItem["status"],
- origin: "app_owned" | "provider_native" = "app_owned",
- ): OrchestrationV2TurnItem => ({
- ...base(id, second),
- status,
- completedAt: status === "running" ? null : at(second),
- type: "subagent",
- subagentId: NodeId.make(id),
- origin,
- driver: ProviderDriverKind.make("codex"),
- providerInstanceId: ProviderInstanceId.make("codex"),
- childThreadId: origin === "app_owned" ? ThreadId.make(`${id}-thread`) : null,
- prompt: `Inspect ${id}`,
- result: null,
+ entry: Partial & { readonly label: string },
+ ) => ({
+ id,
+ kind: "work" as const,
+ createdAt: time(second),
+ entry: { id, createdAt: time(second), runId, tone: "tool" as const, ...entry },
});
- const rowsFor = (children: ReadonlyArray) =>
- deriveMessagesTimelineRows({
- timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
- visibleTurnItems: (
- [
- {
- ...base("user", 0),
- status: "completed",
- type: "user_message",
- messageId: MessageId.make("user"),
- createdBy: "user",
- creationSource: "web",
- inputIntent: "turn_start",
- text: "Delegate the review",
- attachments: [],
- },
- {
- ...base("reasoning", 1),
- status: "completed",
- type: "reasoning",
- text: "Two children can split this.",
- streaming: false,
- },
- {
- ...base("command", 2),
- status: "completed",
- type: "command_execution",
- input: "pwd",
- output: "/repo",
- exitCode: 0,
- },
- ...children,
- {
- ...base("assistant", 8),
- status: "completed",
- type: "assistant_message",
- messageId: MessageId.make("assistant"),
- text: "Both children are running.",
- streaming: false,
- },
- ] satisfies OrchestrationV2TurnItem[]
- ).map(projected),
- optimisticMessages: [],
- }),
- latestRun: {
- runId,
- status: "completed",
- startedAt: DateTime.formatIso(at(0)),
- completedAt: DateTime.formatIso(at(8)),
- },
- isWorking: false,
+ const run = (entries: ReadonlyArray) => {
+ const input = {
+ timelineEntries: entries as never,
+ latestRun: { runId, status: "running" as const, startedAt: time(0), completedAt: null },
+ runningRunId: runId,
+ isWorking: true,
+ activeTurnStartedAt: time(0),
turnDiffSummaries: [],
supportsConversationRollback: false,
- });
-
- it.each(["app_owned", "provider_native"] as const)(
- "keeps a running %s child visible while the rest of the settled turn folds",
- (origin) => {
- const rows = rowsFor([child("child", 3, "running", origin)]);
- expect(rows.map((row) => row.id)).toEqual([
- "user",
- `turn-fold:${runId}`,
- "child",
- "assistant",
- ]);
- },
- );
-
- it("keeps a launch batch visible while any member is still running", () => {
- const rows = rowsFor([child("done", 3, "completed"), child("live", 4, "running")]);
- expect(rows.map((row) => row.id)).toEqual(["user", `turn-fold:${runId}`, "done", "assistant"]);
- expect(rows.find((row) => row.id === "done")).toMatchObject({
- subagents: [{ item: { id: "done" } }, { item: { id: "live" } }],
- });
- });
+ };
+ return applyWorkingWindow(deriveMessagesTimelineRows(input), input);
+ };
+ const workingRow = (rows: ReadonlyArray) =>
+ rows.find(
+ (row): row is Extract => row.kind === "working",
+ );
- it("keeps a running child visible when a steer supersedes its attempt", () => {
- const attempt = {
- id: RunAttemptId.make("superseded-attempt"),
- runId,
- attemptOrdinal: 1,
+ it("moves the running turn's work under the bar and keeps steers and replies above it", () => {
+ const rows = run([
+ prompt("prompt", 0),
+ work("edit", 2, {
+ label: "Edited a.ts",
+ itemType: "file_change",
+ projectedItem: turnItem("edit", {
+ type: "file_change",
+ additions: 84,
+ deletions: 22,
+ }) as never,
+ }),
+ {
+ id: "reply",
+ kind: "message",
+ createdAt: time(2),
+ message: {
+ id: MessageId.make("reply"),
+ role: "assistant",
+ text: "reply",
+ runId,
+ createdAt: time(2),
+ updatedAt: time(2),
+ streaming: false,
+ },
+ },
+ prompt("steer", 3, "steer"),
+ work("command", 4, {
+ label: "Running bun typecheck",
+ itemType: "command_execution",
+ toolLifecycleStatus: "inProgress",
+ }),
+ ]);
+ expect(rows.map((row) => row.id)).toEqual([
+ "prompt",
+ "reply",
+ "steer",
+ "working-indicator-row",
+ ]);
+ const window = workingRow(rows)?.window;
+ expect(window?.items.map((item) => item.id)).toEqual(["edit", "command"]);
+ expect(window?.diff).toEqual({ additions: 84, deletions: 22 });
+ // Sending a steer re-keys the window, which closes an open history.
+ expect(window?.key).toBe("steer");
+ expect(window?.historyOpen).toBe(false);
+ });
+
+ it("groups a steer with its prompt once the bar moves below both", () => {
+ const rows = run([
+ prompt("prompt", 0),
+ work("command", 2, { label: "Running sleep", itemType: "command_execution" }),
+ prompt("steer", 3, "steer"),
+ ]);
+ expect(rows.map((row) => row.id)).toEqual(["prompt", "steer", "working-indicator-row"]);
+ expect(rows[0]).toMatchObject({ groupedWithNext: true, groupedWithPrevious: false });
+ expect(rows[1]).toMatchObject({ groupedWithNext: false, groupedWithPrevious: true });
+ });
+
+ it("puts the settled fold where the bar was, after the steer", () => {
+ const rows = deriveMessagesTimelineRows({
+ timelineEntries: [
+ prompt("prompt", 0),
+ work("command", 2, { label: "Ran sleep", itemType: "command_execution" }),
+ prompt("steer", 3, "steer"),
+ {
+ id: "answer",
+ kind: "message",
+ createdAt: time(5),
+ message: {
+ id: MessageId.make("answer"),
+ role: "assistant",
+ text: "done",
+ runId,
+ createdAt: time(5),
+ updatedAt: time(5),
+ streaming: false,
+ },
+ },
+ prompt("next", 6),
+ ] as never,
+ latestRun: { runId, status: "completed", startedAt: time(0), completedAt: time(5) },
+ isWorking: false,
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ });
+ expect(rows.map((row) => row.id)).toEqual([
+ "prompt",
+ "steer",
+ `turn-fold:${runId}`,
+ "answer",
+ "next",
+ ]);
+ });
+
+ it("reports the latest failed test run until a rerun passes", () => {
+ const testRun = (id: string, second: number, failed: boolean) =>
+ work(id, second, {
+ label: "Ran pnpm vitest run",
+ command: "pnpm vitest run",
+ itemType: "command_execution",
+ toolLifecycleStatus: "completed",
+ structuredPayload: {
+ type: "command_execution",
+ input: "pnpm vitest run",
+ ...(failed ? { outputIndicatesFailure: true } : {}),
+ } as never,
+ projectedItem: turnItem(id, {
+ type: "command_execution",
+ input: "pnpm vitest run",
+ ...(failed ? { outputIndicatesFailure: true, failedTestCount: 2 } : {}),
+ }) as never,
+ });
+ expect(workingRow(run([prompt("prompt", 0), testRun("first", 2, true)]))?.window).toMatchObject(
+ { failedTests: { count: 2 } },
+ );
+ expect(
+ workingRow(run([prompt("prompt", 0), testRun("first", 2, true), testRun("second", 5, false)]))
+ ?.window?.failedTests,
+ ).toBeNull();
+ });
+
+ it("names a provider retry and a pending stop", () => {
+ const window = workingRow(
+ run([
+ prompt("prompt", 0),
+ work("retry", 2, {
+ label: "Retrying provider (2/5)",
+ itemType: "error",
+ toolLifecycleStatus: "inProgress",
+ projectedItem: turnItem("retry", {
+ type: "error",
+ status: "running",
+ failure: { class: "transport_error", message: "dropped", code: null, retryable: true },
+ retry: { attempt: 2, maxAttempts: 5, retryDelayMs: null },
+ }) as never,
+ }),
+ {
+ id: "stop",
+ kind: "event",
+ createdAt: time(3),
+ projectedItem: turnItem("stop", { type: "run_interrupt_request", message: "Stopping" }),
+ },
+ ]),
+ )?.window;
+ expect(window).toMatchObject({ retryAttempt: 2, stopping: true });
+ });
+
+ it("settles thinking that later activity superseded", () => {
+ const window = workingRow(
+ run([
+ prompt("prompt", 0),
+ work("thought", 1, {
+ label: "Thinking",
+ detail: "Checking the workspace",
+ itemType: "reasoning",
+ toolLifecycleStatus: "inProgress",
+ }),
+ work("command", 2, {
+ label: "Ran pwd",
+ itemType: "command_execution",
+ toolLifecycleStatus: "completed",
+ }),
+ ]),
+ )?.window;
+ const thought = window?.items.find((item) => item.id === "thought");
+ expect(thought?.kind === "work" && thought.entry.toolLifecycleStatus).toBe("completed");
+ });
+
+ it("holds a runless subagent thread's live work", () => {
+ const input = {
+ timelineEntries: [
+ prompt("prompt", 0),
+ {
+ ...work("command", 2, {
+ label: "Running pwd",
+ itemType: "command_execution",
+ toolLifecycleStatus: "inProgress",
+ }),
+ entry: {
+ id: "command",
+ createdAt: time(2),
+ label: "Running pwd",
+ tone: "tool" as const,
+ itemType: "command_execution" as const,
+ toolLifecycleStatus: "inProgress" as const,
+ },
+ },
+ ] as never,
+ isWorking: true,
+ runlessWorkActive: true,
+ activeTurnStartedAt: time(0),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ };
+ const rows = applyWorkingWindow(deriveMessagesTimelineRows(input), input);
+ expect(rows.map((row) => row.id)).toEqual(["prompt", "working-indicator-row"]);
+ expect(workingRow(rows)?.window?.items.map((item) => item.id)).toEqual(["command"]);
+ });
+
+ it("streams the answer below the bar and folds it once work follows", () => {
+ const answer = {
+ id: "answer",
+ kind: "message" as const,
+ createdAt: time(3),
+ message: {
+ id: MessageId.make("answer"),
+ role: "assistant" as const,
+ text: "Look",
+ runId,
+ createdAt: time(3),
+ updatedAt: time(3),
+ streaming: true,
+ },
+ };
+ const command = work("command", 2, {
+ label: "Ran pwd",
+ itemType: "command_execution",
+ toolLifecycleStatus: "completed",
+ });
+ const answering = run([prompt("prompt", 0), command, answer]);
+ expect(answering.map((row) => row.id)).toEqual(["prompt", "working-indicator-row", "answer"]);
+ expect(workingRow(answering)?.window?.items.map((item) => item.id)).toEqual(["command"]);
+ const followed = run([
+ prompt("prompt", 0),
+ command,
+ { ...answer, message: { ...answer.message, streaming: false } },
+ work("next", 4, {
+ label: "Running ls",
+ itemType: "command_execution",
+ toolLifecycleStatus: "inProgress",
+ }),
+ ]);
+ expect(followed.map((row) => row.id)).toEqual(["prompt", "working-indicator-row"]);
+ expect(workingRow(followed)?.window?.items.map((item) => item.id)).toEqual([
+ "command",
+ "answer",
+ "next",
+ ]);
+ });
+
+ it("keeps unchanged window items when a later one changes", () => {
+ const command = work("command", 2, {
+ label: "Ran pwd",
+ itemType: "command_execution",
+ toolLifecycleStatus: "completed",
+ });
+ const next = (status: "inProgress" | "completed") =>
+ work("next", 3, {
+ label: "Ran ls",
+ itemType: "command_execution",
+ toolLifecycleStatus: status,
+ });
+ const first = computeStableMessagesTimelineRows(
+ run([prompt("prompt", 0), command, next("inProgress")]),
+ { result: [], byId: new Map() },
+ );
+ const later = computeStableMessagesTimelineRows(
+ run([prompt("prompt", 0), command, next("completed")]),
+ first,
+ );
+ const [firstCommand, firstNext] = workingRow(first.result)!.window!.items;
+ const [laterCommand, laterNext] = workingRow(later.result)!.window!.items;
+ expect(laterCommand).toBe(firstCommand);
+ expect(laterNext).not.toBe(firstNext);
+ });
+
+ it("leaves rows alone when nothing is running", () => {
+ const input = {
+ timelineEntries: [prompt("prompt", 0)] as never,
+ isWorking: false,
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ };
+ const rows = deriveMessagesTimelineRows(input);
+ expect(applyWorkingWindow(rows, input)).toBe(rows);
+ });
+});
+
+describe("turn fold endings", () => {
+ const failedRunId = RunId.make("failed-fold-run");
+ const failedFoldInput = (failureMessage: string) => {
+ const threadId = ThreadId.make("thread");
+ const at = DateTime.makeUnsafe("2026-01-01T00:00:01Z");
+ const base = {
+ threadId,
+ runId: failedRunId,
+ nodeId: null,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal: 0,
+ title: null,
+ startedAt: at,
+ completedAt: at,
+ updatedAt: at,
+ };
+ const projected = (position: number, item: OrchestrationV2TurnItem) => ({
+ position,
+ visibility: "local" as const,
+ sourceThreadId: threadId,
+ sourceItemId: item.id,
+ item,
+ });
+ return {
+ timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
+ visibleTurnItems: [
+ projected(0, {
+ ...base,
+ id: TurnItemId.make("command"),
+ type: "command_execution",
+ status: "completed",
+ input: "pwd",
+ output: "",
+ exitCode: 0,
+ }),
+ projected(1, {
+ ...base,
+ id: TurnItemId.make("failure"),
+ type: "error",
+ status: "failed",
+ failure: {
+ class: "provider_error",
+ message: failureMessage,
+ code: null,
+ retryable: false,
+ },
+ }),
+ ],
+ optimisticMessages: [],
+ }),
+ latestRun: {
+ runId: failedRunId,
+ status: "failed" as const,
+ startedAt: "2026-01-01T00:00:00Z",
+ completedAt: "2026-01-01T00:00:02Z",
+ },
+ isWorking: false,
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ };
+ };
+
+ it("finds the work a collapsed failed fold hides and opens it by dropping the collapse", () => {
+ const input = failedFoldInput("Provider stopped");
+ const fold = timelineTurnFoldRunIdsByEntryId(input).get("command");
+ expect(fold).toEqual({ runId: failedRunId, ending: "failed" });
+ // The failure itself stays on the fold line at any fold state.
+ expect(timelineTurnFoldRunIdsByEntryId(input).has("failure")).toBe(false);
+ const collapsed = new Set([failedRunId]);
+ expect(
+ deriveMessagesTimelineRows({ ...input, expandedRunIds: collapsed }).some(
+ (row) => row.id === "command",
+ ),
+ ).toBe(false);
+ const opened = withTurnFoldOpen(collapsed, fold!);
+ expect(opened.has(failedRunId)).toBe(false);
+ expect(withTurnFoldOpen(opened, fold!)).toBe(opened);
+ expect(
+ deriveMessagesTimelineRows({ ...input, expandedRunIds: opened }).some(
+ (row) => row.id === "command",
+ ),
+ ).toBe(true);
+ const workedRun = RunId.make("worked");
+ expect(withTurnFoldOpen(new Set(), { runId: workedRun, ending: "worked" })).toEqual(
+ new Set([workedRun]),
+ );
+ });
+
+ it("repaints a fold whose failure text changes", () => {
+ const first = computeStableMessagesTimelineRows(
+ deriveMessagesTimelineRows(failedFoldInput("Provider stopped")),
+ { result: [], byId: new Map() },
+ );
+ const next = computeStableMessagesTimelineRows(
+ deriveMessagesTimelineRows(failedFoldInput("Usage limit reached")),
+ first,
+ );
+ expect(next.result.find((row) => row.kind === "turn-fold")).toMatchObject({
+ failureMessage: "Usage limit reached",
+ });
+ });
+
+ it("opens only the closed update groups that hold a run", () => {
+ const runId = RunId.make("background-run");
+ const group = (id: string, runIds: RunId[], expanded: boolean) => ({
+ kind: "background-activity" as const,
+ id,
+ createdAt: "2026-01-01T00:00:00Z",
+ runIds,
+ updateCount: 1,
+ updates: [],
+ active: false,
+ expanded,
+ });
+ const rows = [group("a", [runId], false), group("b", [RunId.make("other")], false)];
+ expect(withRunGroupsOpen(new Set(), rows, runId)).toEqual(new Set(["a"]));
+ const open = new Set(["a"]);
+ expect(withRunGroupsOpen(open, rows, runId)).toBe(open);
+ });
+
+ it.each([false, true])("keeps checkpoint fold totals, capped=%s", (capped) => {
+ const runId = RunId.make("diff-run");
+ const rows = deriveMessagesTimelineRows({
+ timelineEntries: [
+ {
+ id: "work",
+ kind: "work",
+ createdAt: "2026-01-01T00:00:05Z",
+ entry: {
+ id: "work",
+ createdAt: "2026-01-01T00:00:05Z",
+ runId,
+ label: "Ran command",
+ tone: "tool",
+ },
+ },
+ ],
+ latestRun: {
+ runId,
+ status: "completed",
+ startedAt: "2026-01-01T00:00:00Z",
+ completedAt: "2026-01-01T00:01:34Z",
+ },
+ isWorking: false,
+ turnDiffSummaries: [
+ {
+ runId,
+ checkpointTurnCount: 1,
+ checkpointRef: CheckpointRef.make("refs/t3/checkpoints/diff-run"),
+ status: "ready",
+ files: [
+ { path: "a.ts", kind: "modified", additions: 54, deletions: 22 },
+ { path: "b.ts", kind: "added", additions: 30, deletions: 0 },
+ ],
+ ...(capped ? { filesTotal: { count: 250, additions: 1000, deletions: 500 } } : {}),
+ assistantMessageId: null,
+ completedAt: "2026-01-01T00:01:34Z",
+ },
+ ],
+ supportsConversationRollback: false,
+ });
+ expect(rows).toEqual([
+ expect.objectContaining({
+ kind: "turn-fold",
+ label: "Worked for 1m 34s",
+ ending: "worked",
+ diff: { additions: capped ? 1000 : 84, deletions: capped ? 500 : 22 },
+ }),
+ ]);
+ });
+
+ it("names pending approvals by what they would run", () => {
+ expect(
+ pendingApprovalSlotLabel({ requestKind: "command", detail: "git push origin main" }),
+ ).toBe("Approve git push");
+ expect(pendingApprovalSlotLabel({ requestKind: "command", detail: "CI=1 bun test" })).toBe(
+ "Approve bun test",
+ );
+ expect(pendingApprovalSlotLabel({ requestKind: "file-change" })).toBe("Approve file change");
+ });
+});
+
+it.each([
+ [true, "updated"],
+ [true, "failed"],
+ [false, "updated"],
+ [false, "completed"],
+ [false, "failed"],
+] as const)("groups consecutive notification responses (%s, %s)", (active, outcome) => {
+ const fixture = makeStreamingTimelineFixture("Update received");
+ const entries = deriveTimelineEntriesFromVisibleTurnItems({
+ visibleTurnItems: fixture.visibleTurnItems.map((row) => ({
+ ...row,
+ item:
+ row.item.type === "user_message"
+ ? {
+ ...row.item,
+ type: "notification",
+ source: { kind: outcome === "failed" ? "monitor" : "background_task" },
+ outcome: active && row.item.runId === fixture.runId ? "updated" : outcome,
+ summary:
+ outcome === "failed" ? "#28844: checks failed" : "Background activity updated",
+ }
+ : row.item.type === "command_execution"
+ ? { ...row.item, status: "completed", exitCode: 0, output: "Passed" }
+ : row.item.type === "assistant_message"
+ ? {
+ ...row.item,
+ streaming: active && row.item.runId === fixture.runId,
+ status: active && row.item.runId === fixture.runId ? "running" : "completed",
+ }
+ : row.item,
+ })),
+ optimisticMessages: [],
+ });
+ const input = {
+ timelineEntries: entries,
+ latestRun: {
+ runId: fixture.runId,
+ status: active ? ("running" as const) : ("completed" as const),
+ startedAt: fixture.time(5),
+ completedAt: active ? null : fixture.time(8),
+ },
+ isWorking: active,
+ activeTurnStartedAt: fixture.time(5),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ hasPendingRequest: false,
+ hasThreadError: false,
+ };
+ const original = applyWorkingWindow(deriveMessagesTimelineRows(input), input);
+ const collapsed = applyBackgroundActivityGroups(original, input);
+ const answerRows = (runId: RunId, rows = original) =>
+ rows.filter(
+ (row) =>
+ (row.kind === "message" || row.kind === "assistant-meta") && row.message.runId === runId,
+ );
+ const keptAnswer = (rows: MessagesTimelineRow[]) => {
+ const answer = answerRows(fixture.runId, rows);
+ return answer.length > 0 ? answer : answerRows(fixture.historyRunId, rows);
+ };
+ expect(keptAnswer(original)).not.toEqual([]);
+ expect(collapsed.slice(1)).toEqual(keptAnswer(original));
+ if (active) {
+ const streaming = deriveMessagesTimelineRows(input);
+ expect(keptAnswer(streaming)).toMatchObject([{ message: { streaming: true } }]);
+ expect(applyBackgroundActivityGroups(streaming, input).slice(1)).toEqual(keptAnswer(streaming));
+ }
+ expect(collapsed[0]).toMatchObject({
+ kind: "background-activity",
+ updateCount: 2,
+ active,
+ expanded: false,
+ runIds: [fixture.historyRunId, fixture.runId],
+ });
+ if (!active) {
+ const queuedRunId = RunId.make("queued-wake-run");
+ const queuedInput = {
+ ...input,
+ latestRun: {
+ runId: queuedRunId,
+ status: "queued" as const,
+ startedAt: fixture.time(9),
+ completedAt: null,
+ },
+ isWorking: true,
+ activeTurnStartedAt: fixture.time(9),
+ };
+ const queuedRows = applyWorkingWindow(deriveMessagesTimelineRows(queuedInput), queuedInput);
+ const queued = applyBackgroundActivityGroups(queuedRows, queuedInput);
+ expect(keptAnswer(queuedRows)).toMatchObject([{ message: { streaming: false } }]);
+ expect(queued.slice(1)).toEqual(keptAnswer(queuedRows));
+ expect(queued[0]).toMatchObject({
+ id: collapsed[0]!.id,
+ updateCount: 2,
+ active: true,
+ runIds: [fixture.historyRunId, fixture.runId, queuedRunId],
+ });
+ const silentInput = {
+ ...input,
+ timelineEntries: entries.filter(
+ (entry) => entry.kind !== "message" || entry.message.runId !== fixture.runId,
+ ),
+ };
+ const silent = applyBackgroundActivityGroups(
+ applyWorkingWindow(deriveMessagesTimelineRows(silentInput), silentInput),
+ silentInput,
+ );
+ expect(silent.slice(1)).toEqual(answerRows(fixture.historyRunId));
+ }
+ const expanded = applyBackgroundActivityGroups(original, {
+ ...input,
+ expandedWorkGroupIds: new Set([collapsed[0]!.id]),
+ });
+ expect(expanded.slice(1)).toEqual(original);
+ expect(expanded.slice(1).every((row, index) => row === original[index])).toBe(true);
+ expect(applyBackgroundActivityGroups(original, input)).toEqual(collapsed);
+
+ const recentInput = {
+ ...input,
+ timelineEntries: entries.filter((entry) =>
+ entry.kind === "message"
+ ? entry.message.runId === fixture.runId
+ : entry.kind === "work" && entry.entry.runId === fixture.runId,
+ ),
+ };
+ const recent = applyWorkingWindow(deriveMessagesTimelineRows(recentInput), recentInput);
+ const recentGroup = applyBackgroundActivityGroups(recent, recentInput);
+ expect(applyBackgroundActivityGroups(original, input, recentGroup)[0]?.id).toBe(
+ recentGroup[0]?.id,
+ );
+ const restored = applyBackgroundActivityGroups(original, {
+ ...input,
+ expandedWorkGroupIds: new Set([recentGroup[0]!.id]),
+ });
+ expect(restored[0]).toMatchObject({ id: recentGroup[0]!.id, expanded: true, updateCount: 2 });
+ expect(restored.slice(1)).toEqual(original);
+
+ // A matching label without typed delivery metadata is never a grouping signal.
+ const untyped = entries.map((entry) => {
+ if (entry.kind !== "work" || entry.entry.itemType !== "notification") return entry;
+ const work = { ...entry.entry };
+ delete work.projectedItem;
+ delete work.structuredPayload;
+ return { ...entry, entry: work };
+ });
+ expect(applyBackgroundActivityGroups(original, { ...input, timelineEntries: untyped })).toBe(
+ original,
+ );
+});
+
+it.each([
+ "user",
+ "approval",
+ "question",
+ "failure",
+ "notification-failure",
+ "system-notice",
+ "pending",
+ "thread-error",
+] as const)("keeps the original response visible for %s", (condition) => {
+ const fixture = makeStreamingTimelineFixture("Response");
+ const visibleTurnItems = fixture.visibleTurnItems.map((row): OrchestrationV2ProjectedTurnItem => {
+ if (row.item.runId !== fixture.runId) return row;
+ if (row.item.type === "user_message" && condition !== "user") {
+ return {
+ ...row,
+ item: {
+ ...row.item,
+ type: "notification",
+ source: { kind: "monitor" },
+ outcome: condition === "notification-failure" ? "failed" : "updated",
+ summary: "Monitor updated",
+ },
+ };
+ }
+ if (row.item.type !== "dynamic_tool") return row;
+ if (condition === "system-notice")
+ return {
+ ...row,
+ item: {
+ ...row.item,
+ type: "system_notice",
+ status: "completed",
+ message: "Usage limit reached. Resets at 3pm.",
+ },
+ };
+ if (condition === "approval")
+ return {
+ ...row,
+ item: {
+ ...row.item,
+ type: "approval_request",
+ status: "waiting",
+ requestId: RuntimeRequestId.make("approval"),
+ requestKind: "command",
+ prompt: "Allow command?",
+ },
+ };
+ if (condition === "question")
+ return {
+ ...row,
+ item: {
+ ...row.item,
+ type: "user_input_request",
+ status: "waiting",
+ requestId: RuntimeRequestId.make("question"),
+ questions: [],
+ },
+ };
+ if (condition === "failure") return { ...row, item: { ...row.item, status: "failed" } };
+ return row;
+ });
+ const entries = deriveTimelineEntriesFromVisibleTurnItems({
+ visibleTurnItems,
+ optimisticMessages: [],
+ });
+ const input = {
+ timelineEntries: entries,
+ runningRunId: fixture.runId,
+ isWorking: true,
+ activeTurnStartedAt: fixture.time(5),
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ hasPendingRequest: condition === "pending",
+ hasThreadError: condition === "thread-error",
+ };
+ const original = applyWorkingWindow(deriveMessagesTimelineRows(input), input);
+ expect(applyBackgroundActivityGroups(original, input)).toBe(original);
+ if (condition === "system-notice") {
+ expect(original).toContainEqual(
+ expect.objectContaining({
+ kind: "work",
+ groupedEntries: expect.arrayContaining([
+ expect.objectContaining({ itemType: "system_notice" }),
+ ]),
+ }),
+ );
+ }
+});
+
+describe("live subagents after their parent turn settles", () => {
+ const runId = RunId.make("parent-run");
+ const threadId = ThreadId.make("parent-thread");
+ const providerTurnId = ProviderTurnId.make("parent-provider-turn");
+ const at = (second: number) =>
+ DateTime.makeUnsafe(`2026-10-06T10:00:${String(second).padStart(2, "0")}Z`);
+ const base = (id: string, second: number) => ({
+ id: TurnItemId.make(id),
+ threadId,
+ runId,
+ nodeId: null,
+ providerThreadId: null,
+ providerTurnId,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal: second,
+ title: null,
+ startedAt: at(second),
+ completedAt: at(second),
+ updatedAt: at(second),
+ });
+ const projected = (item: OrchestrationV2TurnItem): OrchestrationV2ProjectedTurnItem => ({
+ position: item.ordinal,
+ visibility: "local",
+ sourceThreadId: threadId,
+ sourceItemId: item.id,
+ item,
+ });
+ const child = (
+ id: string,
+ second: number,
+ status: OrchestrationV2TurnItem["status"],
+ origin: "app_owned" | "provider_native" = "app_owned",
+ ): OrchestrationV2TurnItem => ({
+ ...base(id, second),
+ status,
+ completedAt: status === "running" ? null : at(second),
+ type: "subagent",
+ subagentId: NodeId.make(id),
+ origin,
+ driver: ProviderDriverKind.make("codex"),
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ childThreadId: origin === "app_owned" ? ThreadId.make(`${id}-thread`) : null,
+ prompt: `Inspect ${id}`,
+ result: null,
+ });
+ const rowsFor = (children: ReadonlyArray) =>
+ deriveMessagesTimelineRows({
+ timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
+ visibleTurnItems: (
+ [
+ {
+ ...base("user", 0),
+ status: "completed",
+ type: "user_message",
+ messageId: MessageId.make("user"),
+ createdBy: "user",
+ creationSource: "web",
+ inputIntent: "turn_start",
+ text: "Delegate the review",
+ attachments: [],
+ },
+ {
+ ...base("reasoning", 1),
+ status: "completed",
+ type: "reasoning",
+ text: "Two children can split this.",
+ streaming: false,
+ },
+ {
+ ...base("command", 2),
+ status: "completed",
+ type: "command_execution",
+ input: "pwd",
+ output: "/repo",
+ exitCode: 0,
+ },
+ ...children,
+ {
+ ...base("assistant", 8),
+ status: "completed",
+ type: "assistant_message",
+ messageId: MessageId.make("assistant"),
+ text: "Both children are running.",
+ streaming: false,
+ },
+ ] satisfies OrchestrationV2TurnItem[]
+ ).map(projected),
+ optimisticMessages: [],
+ }),
+ latestRun: {
+ runId,
+ status: "completed",
+ startedAt: DateTime.formatIso(at(0)),
+ completedAt: DateTime.formatIso(at(8)),
+ },
+ isWorking: false,
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ });
+
+ it.each(["app_owned", "provider_native"] as const)(
+ "keeps a running %s child visible while the rest of the settled turn folds",
+ (origin) => {
+ const rows = rowsFor([child("child", 3, "running", origin)]);
+ expect(rows.map((row) => row.id)).toEqual([
+ "user",
+ `turn-fold:${runId}`,
+ "child",
+ "assistant",
+ ]);
+ },
+ );
+
+ it("keeps a launch batch visible while any member is still running", () => {
+ const rows = rowsFor([child("done", 3, "completed"), child("live", 4, "running")]);
+ expect(rows.map((row) => row.id)).toEqual(["user", `turn-fold:${runId}`, "done", "assistant"]);
+ expect(rows.find((row) => row.id === "done")).toMatchObject({
+ subagents: [{ item: { id: "done" } }, { item: { id: "live" } }],
+ });
+ });
+
+ it("keeps a running child visible when a steer supersedes its attempt", () => {
+ const attempt = {
+ id: RunAttemptId.make("superseded-attempt"),
+ runId,
+ attemptOrdinal: 1,
rootNodeId: NodeId.make("superseded-root"),
status: "superseded" as const,
completedAt: null,
@@ -4777,3 +5742,191 @@ describe("subagent continued by a later turn", () => {
]);
});
});
+
+describe("slash command prompts", () => {
+ const runId = RunId.make("command-run");
+ const threadId = ThreadId.make("command-thread");
+ const at = DateTime.makeUnsafe("2026-09-20T12:00:00Z");
+ const base = {
+ threadId,
+ runId,
+ nodeId: null,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ title: null,
+ startedAt: at,
+ completedAt: at,
+ updatedAt: at,
+ status: "completed" as const,
+ };
+ const prompt = (text: string): OrchestrationV2TurnItem => ({
+ ...base,
+ id: TurnItemId.make("prompt"),
+ ordinal: 0,
+ type: "user_message",
+ messageId: MessageId.make("prompt"),
+ createdBy: "user",
+ creationSource: "web",
+ inputIntent: "turn_start",
+ text,
+ attachments: [],
+ });
+ const compaction: OrchestrationV2TurnItem = {
+ ...base,
+ id: TurnItemId.make("compaction"),
+ ordinal: 1,
+ type: "compaction",
+ driver: null,
+ beforeTokenCount: 182_400,
+ afterTokenCount: 24_100,
+ };
+ const rowsFor = (
+ items: ReadonlyArray,
+ status: "running" | "completed" | "failed" | "interrupted" = "completed",
+ ) =>
+ deriveMessagesTimelineRows({
+ timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
+ visibleTurnItems: items.map((item, position) => ({
+ position,
+ visibility: "local",
+ sourceThreadId: threadId,
+ sourceItemId: item.id,
+ item,
+ })),
+ optimisticMessages: [],
+ }),
+ isWorking: status === "running",
+ activeTurnStartedAt: status === "running" ? "2026-09-20T12:00:00Z" : null,
+ latestRun: {
+ runId,
+ status,
+ startedAt: "2026-09-20T12:00:00Z",
+ completedAt: status === "running" ? null : "2026-09-20T12:00:04Z",
+ },
+ turnDiffSummaries: [],
+ supportsConversationRollback: false,
+ slashCommands: {
+ commands: [{ name: "doctor" }],
+ skills: [{ name: "file-pr" }, { name: "compact" }],
+ },
+ });
+ const commandOf = (rows: ReadonlyArray) => {
+ const row = rows.find((candidate) => candidate.kind === "message");
+ return row?.kind === "message" ? row.command : undefined;
+ };
+
+ it("shows a lone /compact and its compaction as one row with the token counts", () => {
+ const rows = rowsFor([prompt("/compact"), compaction]);
+ expect(rows.some((row) => row.kind === "context-compaction")).toBe(false);
+ const command = commandOf(rows)!;
+ expect(command).toMatchObject({ name: "compact", standalone: true, state: "done" });
+ expect(presentSlashCommand(command, command.state, command.result)).toMatchObject({
+ verb: "Compacted",
+ target: "182K → 24.1K tokens",
+ });
+ expect(commandOf(rowsFor([prompt("/compact")], "running"))?.state).toBe("running");
+ });
+
+ it("keeps a running /compact's compaction out of the working window", () => {
+ const running = { ...compaction, status: "running" as const, completedAt: null };
+ const items = [prompt("/compact"), running];
+ const rows = rowsFor(items, "running");
+ const entries = deriveTimelineEntriesFromVisibleTurnItems({
+ visibleTurnItems: items.map((item, position) => ({
+ position,
+ visibility: "local",
+ sourceThreadId: threadId,
+ sourceItemId: item.id,
+ item,
+ })),
+ optimisticMessages: [],
+ });
+ const working = applyWorkingWindow(rows, {
+ timelineEntries: entries,
+ latestRun: { runId, status: "running", startedAt: "2026-09-20T12:00:00Z", completedAt: null },
+ isWorking: true,
+ }).find((row) => row.kind === "working");
+ expect(working?.kind === "working" ? working.window?.items : null).toEqual([]);
+ expect(working?.kind === "working" ? working.window?.compactCommand : false).toBe(true);
+ });
+
+ it.each([
+ { text: "/compact", skill: false, ownsProgress: true, verb: "Compacting the conversation" },
+ { text: "$compact", skill: true, ownsProgress: false, verb: "Using skill" },
+ ])(
+ "keeps the correct progress owner for $text with no work yet",
+ ({ text, skill, ownsProgress, verb }) => {
+ const items = [prompt(text)];
+ const rows = rowsFor(items, "running");
+ const entries = deriveTimelineEntriesFromVisibleTurnItems({
+ visibleTurnItems: items.map((item, position) => ({
+ position,
+ visibility: "local",
+ sourceThreadId: threadId,
+ sourceItemId: item.id,
+ item,
+ })),
+ optimisticMessages: [],
+ });
+ const command = commandOf(rows)!;
+ expect(command).toMatchObject({ name: "compact", skill, standalone: true, state: "running" });
+ expect(presentSlashCommand(command, command.state, command.result).verb).toBe(verb);
+ const working = applyWorkingWindow(rows, {
+ timelineEntries: entries,
+ latestRun: {
+ runId,
+ status: "running",
+ startedAt: "2026-09-20T12:00:00Z",
+ completedAt: null,
+ },
+ isWorking: true,
+ }).find((row) => row.kind === "working");
+ expect(working).toMatchObject({
+ createdAt: "2026-09-20T12:00:00Z",
+ window: { items: [], compactCommand: ownsProgress },
+ });
+ },
+ );
+
+ it("keeps a command followed by text as the user's message", () => {
+ expect(commandOf(rowsFor([prompt("/goal ship snake with pause")]))).toMatchObject({
+ sigil: "/",
+ name: "goal",
+ args: "ship snake with pause",
+ standalone: false,
+ });
+ const pause = commandOf(rowsFor([prompt("/goal pause")]))!;
+ expect(pause.standalone).toBe(true);
+ expect(presentSlashCommand(pause, "done", null)).toMatchObject({
+ verb: "Goal paused",
+ target: null,
+ });
+ });
+
+ it("presents a provider command without its own presentation as a generic run", () => {
+ const command = commandOf(rowsFor([prompt("/doctor --fix")]))!;
+ expect(presentSlashCommand(command, "done", null)).toMatchObject({
+ verb: "Ran",
+ target: "--fix",
+ });
+ expect(presentSlashCommand(command, "failed", null).verb).toBe("Couldn't run");
+ expect(commandOf(rowsFor([prompt("/doctor")], "failed"))?.state).toBe("failed");
+ expect(commandOf(rowsFor([prompt("/doctor")], "interrupted"))?.state).toBe("stopped");
+ });
+
+ it("presents a skill sent alone as a used skill", () => {
+ for (const text of ["/file-pr", "$file-pr"]) {
+ const command = commandOf(rowsFor([prompt(text)]))!;
+ expect(command).toMatchObject({ name: "file-pr", skill: true, standalone: true });
+ expect(presentSlashCommand(command, "done", null).verb).toBe("Used skill");
+ }
+ });
+
+ it("leaves paths and unlisted names as plain prompts", () => {
+ for (const text of ["/usr/bin/foo is broken", "/usr is broken", "/tmp", "$doctor"]) {
+ expect(commandOf(rowsFor([prompt(text)])), text).toBeUndefined();
+ }
+ });
+});
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index c422656647863..cddf39f31ada0 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -1,6 +1,7 @@
import * as DateTime from "effect/DateTime";
import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
+import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints";
import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime";
import * as Equal from "effect/Equal";
import { shallow } from "zustand/vanilla/shallow";
@@ -30,7 +31,6 @@ import {
} from "@t3tools/client-runtime/work-log/presentation";
import {
deriveRevertTurnCountByUserMessageId,
- formatDuration,
isStreamingMessageTextUpdate,
isStreamingTurnItemTextUpdate,
timelineEntryIsPersistentResourceCard,
@@ -52,6 +52,7 @@ import {
isCompactionCommandMessage,
type MessageId,
type OrchestrationV2IdleCompaction,
+ type OrchestrationV2Notification,
type WorktreeSetupSnapshot,
type OrchestrationV2ProjectedTurnItem,
type RunAttemptId,
@@ -63,7 +64,7 @@ import {
import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell";
import type { ToolCallVisibility } from "@t3tools/contracts/settings";
import { resolveT3McpToolDefinition } from "@t3tools/shared/t3McpToolPresentation";
-import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
+import { commandRunsTests, compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
import { htmlRenderReferencesEqual, type HtmlRenderReference } from "@t3tools/shared/htmlRender";
import { mcpAppReferencesEqual, type McpAppReference } from "@t3tools/shared/mcpApp";
import { dynamicToolTitle } from "@t3tools/shared/toolActivity";
@@ -74,6 +75,15 @@ import {
formatSearchToolLabel,
} from "@t3tools/shared/toolActivity";
import { isWindowsAbsolutePath } from "@t3tools/shared/path";
+import { formatTokens } from "@t3tools/shared/usageFormat";
+import { formatWorkingDuration } from "./TimelineWorkGroupRows.logic";
+import {
+ EMPTY_SLASH_COMMAND_CATALOG,
+ parseSlashCommandMessage,
+ type SlashCommandCatalog,
+ type SlashCommandMessage,
+ type SlashCommandState,
+} from "./timelineSlashCommands";
export function timelineEntryRunId(entry: TimelineEntry): RunId | null {
if (entry.kind === "message") {
@@ -250,21 +260,24 @@ export function liveWorkEntryLabel(
if (toolPresentation) return toolPresentation.displayName;
const command = entry.command?.trim();
if (command) {
- const verb =
- status === "inProgress"
- ? "Running"
- : status === "failed"
- ? "Failed"
- : status === "declined"
- ? "Declined"
- : status === "stopped"
- ? "Stopped"
- : "Ran";
- return `${verb} ${commandProgramName(command) ?? "command"}`;
+ return `${commandEntryVerb(entry.toolLifecycleStatus, active)} ${commandProgramName(command) ?? "command"}`;
}
return workEntryDisplayLabel(entry, workspaceRoot);
}
+function commandEntryVerb(lifecycleStatus: WorkLogEntry["toolLifecycleStatus"], active: boolean) {
+ const status = liveActivityToolStatus(lifecycleStatus, active);
+ return status === "inProgress"
+ ? "Running"
+ : status === "failed"
+ ? "Failed"
+ : status === "declined"
+ ? "Declined"
+ : status === "stopped"
+ ? "Stopped"
+ : "Ran";
+}
+
export function workEntryIsVisibleInGroup(
entry: WorkLogEntry,
expandedToolGroupEntry = false,
@@ -489,9 +502,21 @@ const LIVE_ACTIVITY_ROW_ID = "live-activity-row";
export type MessagesTimelineRow = MessagesTimelineRowContent & {
readonly continuesWorkLog?: boolean;
+ /** A user prompt followed directly by another prompt sits in the same prompt stack. */
+ readonly continuesPromptStack?: boolean;
};
type MessagesTimelineRowContent =
+ | {
+ kind: "background-activity";
+ id: string;
+ createdAt: string | null;
+ runIds: readonly RunId[];
+ updateCount: number;
+ updates: readonly OrchestrationV2Notification[];
+ active: boolean;
+ expanded: boolean;
+ }
| {
kind: "worktree-setup";
id: string;
@@ -522,6 +547,8 @@ type MessagesTimelineRowContent =
kind: "working";
id: string;
createdAt: string | null;
+ /** The running turn's live work, drawn under the bar instead of as rows. */
+ window?: WorkingWindow;
/** A running compaction owns the working row, so its divider appears only once it ends. */
compacting?: true;
}
@@ -560,7 +587,14 @@ type MessagesTimelineRowContent =
createdAt: string;
runId: RunId;
label: string;
+ ending: TurnFoldEnding;
+ diff: DiffLineStat | null;
expanded: boolean;
+ /** False when the fold holds only what its ending line already says. */
+ foldable: boolean;
+ failureMessage: string | null;
+ noticeMessage: string | null;
+ resetAt: string | null;
}
| {
kind: "attempt-fold";
@@ -601,8 +635,8 @@ type MessagesTimelineRowContent =
groupedWithNext?: boolean;
/** Another of your messages sits directly above, so the joined corners tighten. */
groupedWithPrevious?: boolean;
- /** Set when the message above is a steer too, so the label shows once. */
- continuesSteer?: boolean;
+ /** A prompt that opens with a provider command. A standalone one renders as a work row. */
+ command?: TimelineSlashCommand | undefined;
}
| {
kind: "assistant-meta";
@@ -643,6 +677,12 @@ type MessagesTimelineRowContent =
mcpApp: McpAppReference;
};
+export interface TimelineSlashCommand extends SlashCommandMessage {
+ readonly state: SlashCommandState;
+ /** What the provider reported, such as a compaction's token counts. */
+ readonly result: string | null;
+}
+
export interface StableMessagesTimelineRowsState {
byId: Map;
result: MessagesTimelineRow[];
@@ -720,14 +760,26 @@ function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray;
- /** Every entry in the segment, including the cards a fold never hides. */
+ /** Every entry in the turn, including the cards a fold never hides. */
entryIds: ReadonlySet;
label: string;
+ ending: TurnFoldEnding;
+ diff: DiffLineStat | null;
+ /**
+ * The run's terminal error, usage-limit notices and stop. They stay out of
+ * the timeline at any fold state; the fold's ending line carries their text.
+ */
+ endingEntryIds: ReadonlySet;
+ failureMessage: string | null;
+ noticeMessage: string | null;
+ resetAt: string | null;
}
interface SupersededAttemptFold {
@@ -905,7 +957,10 @@ function timelineEntryFoldRunId(entry: TimelineEntry, runlessKey: RunId | null):
}
if (
entry.kind === "event" &&
- (timelineEntryIsPersistentResourceCard(entry) || entry.projectedItem.item.type === "subagent")
+ (timelineEntryIsPersistentResourceCard(entry) ||
+ entry.projectedItem.item.type === "subagent" ||
+ // The stopped fold says so itself and drops the divider.
+ entry.projectedItem.item.type === "run_interrupt_result")
) {
return entry.projectedItem.item.runId ?? runlessKey;
}
@@ -928,6 +983,35 @@ export function timelineEntryStartsResponse(entry: TimelineEntry): boolean {
);
}
+function steerReplyEntryIds(entries: ReadonlyArray): ReadonlySet {
+ const ids = new Set();
+ let lastReply: string | null = null;
+ for (const entry of entries) {
+ if (timelineEntryStartsResponse(entry)) {
+ lastReply = null;
+ } else if (timelineEntryIsSteer(entry)) {
+ if (lastReply !== null) ids.add(lastReply);
+ lastReply = null;
+ } else if (
+ entry.kind === "message" &&
+ entry.message.role === "assistant" &&
+ entry.attempt?.status !== "superseded" &&
+ steerReplyPhase(entry)
+ ) {
+ lastReply = entry.id;
+ }
+ }
+ return ids;
+}
+
+// Commentary and partial output before a steer stay folded; only replies stay visible.
+function steerReplyPhase(entry: Extract): boolean {
+ const item = entry.projectedItem?.item;
+ return (
+ item?.type !== "assistant_message" || item.phase === undefined || item.phase === "final_answer"
+ );
+}
+
function timelineEntryEnd(entry: TimelineEntry): string {
return entry.kind === "message" ? entry.message.updatedAt : entry.createdAt;
}
@@ -942,6 +1026,17 @@ function responseBoundaryRunId(entry: TimelineEntry): RunId | null {
return null;
}
+function runlessTurnFoldRunId(promptEntryId: string): RunId {
+ return RunId.make(`runless:${promptEntryId}`);
+}
+
+export function liveRunlessTurnFoldRunId(
+ timelineEntries: ReadonlyArray,
+): RunId | null {
+ const prompt = timelineEntries.findLast(timelineEntryStartsResponse);
+ return prompt && responseBoundaryRunId(prompt) === null ? runlessTurnFoldRunId(prompt.id) : null;
+}
+
// Group promptless restarts under the initiating prompt until settled.
function lastResponseBoundaryIndex(timelineEntries: ReadonlyArray): number {
return timelineEntries.findLastIndex(timelineEntryStartsResponse);
@@ -1019,16 +1114,109 @@ function itemizedFailedRunIds(
);
}
+/** Runs the user stopped: their interrupt result, or the latest run's own status. */
+function stoppedTimelineRunIds(
+ entries: ReadonlyArray,
+ latestRun: TimelineLatestRun | null,
+) {
+ const stopped = new Set();
+ if (latestRun?.status === "interrupted" || latestRun?.status === "cancelled") {
+ stopped.add(latestRun.runId);
+ }
+ for (const entry of entries) {
+ if (entry.kind !== "event") continue;
+ const item = entry.projectedItem.item;
+ if (item.type === "run_interrupt_result" && item.runId) stopped.add(item.runId);
+ }
+ return stopped;
+}
+
+function timelineEntryTurnItem(entry: TimelineEntry) {
+ return entry.kind === "event"
+ ? entry.projectedItem.item
+ : entry.kind === "work"
+ ? entry.entry.projectedItem?.item
+ : undefined;
+}
+
+/** Runs whose terminal failure is a provider usage limit. */
+function usageLimitedTimelineRunIds(entries: ReadonlyArray): ReadonlySet {
+ const limited = new Set();
+ for (const entry of entries) {
+ const item = timelineEntryTurnItem(entry);
+ if (
+ item?.type === "error" &&
+ item.status === "failed" &&
+ item.failure.class === "usage_limit" &&
+ item.parentItemId === null &&
+ item.runId !== null
+ ) {
+ limited.add(item.runId);
+ }
+ }
+ return limited;
+}
+
+function timelineEntryIsFailure(entry: TimelineEntry): boolean {
+ const item = timelineEntryTurnItem(entry);
+ return (
+ (item?.type === "error" && item.status === "failed") ||
+ (entry.kind === "work" &&
+ (entry.entry.tone === "error" || entry.entry.sourceActivityKind === "runtime.error"))
+ );
+}
+
+export interface DiffLineStat {
+ readonly additions: number;
+ readonly deletions: number;
+}
+
+/** Sums the counts providers report on file_change items; null when none report any. */
+function sumFileChangeLineStat(entries: Iterable): DiffLineStat | null {
+ let additions = 0;
+ let deletions = 0;
+ let reported = false;
+ for (const entry of entries) {
+ const item = entry.projectedItem?.item;
+ if (item?.type !== "file_change") continue;
+ if (item.additions === undefined && item.deletions === undefined) continue;
+ reported = true;
+ additions += item.additions ?? 0;
+ deletions += item.deletions ?? 0;
+ }
+ return reported ? { additions, deletions } : null;
+}
+
+function sumTimelineFileChangeLineStat(entries: ReadonlyArray): DiffLineStat | null {
+ return sumFileChangeLineStat(
+ entries.flatMap((entry) => (entry.kind === "work" ? [entry.entry] : [])),
+ );
+}
+
+function sumCheckpointLineStat(summary: TurnDiffSummary): DiffLineStat | null {
+ const { count, additions, deletions } = checkpointFileTotals(summary);
+ return count === 0 ? null : { additions, deletions };
+}
+
+/**
+ * and work still in progress stay visible. A stopped, failed or limited turn
+ * folds the same way and names how it ended. A prompt without a run (a
+ */
function deriveTurnFolds(input: {
timelineEntries: ReadonlyArray;
terminalAssistantMessageIds: ReadonlySet;
latestRun: TimelineLatestRun | null;
unfoldedRunIds: ReadonlySet;
runlessWorkActive: boolean;
+ failedRunIds: ReadonlySet;
+ turnDiffSummaries: ReadonlyArray;
liveSubagentEntryIds: ReadonlySet;
retainedAssistantReplyEntryIds: ReadonlySet;
+ /** Hide all keeps main: a reply with nothing to expand gets no duration row. */
+ durationRowForBareReply: boolean;
}): ReadonlyMap {
const interruptedRunIds = new Set();
+ const usageLimitedRunIds = usageLimitedTimelineRunIds(input.timelineEntries);
const promptIndexByRunId = new Map();
for (const [index, entry] of input.timelineEntries.entries()) {
if (timelineEntryStartsResponse(entry)) {
@@ -1038,8 +1226,7 @@ function deriveTurnFolds(input: {
if (
entry.kind === "event" &&
entry.projectedItem.item.runId !== null &&
- (entry.projectedItem.item.type === "run_interrupt_request" ||
- entry.projectedItem.item.type === "run_interrupt_result")
+ entry.projectedItem.item.type === "run_interrupt_result"
) {
interruptedRunIds.add(entry.projectedItem.item.runId);
}
@@ -1052,11 +1239,6 @@ function deriveTurnFolds(input: {
// Entry timestamps omit startup and can collapse steered turns to an instant.
startBoundary: string | null;
anchorEntryId: string;
- /**
- * Each steer after the turn has produced output starts a new segment at
- * this index into `entries`, so the steer sits between the work it split.
- */
- steerSegments: Array<{ startIndex: number; steer: TimelineEntry }>;
}
const groupsByRunId = new Map();
const runlessFailedKeys = new Set();
@@ -1072,23 +1254,29 @@ function deriveTurnFolds(input: {
? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id }
: null;
const boundaryRunId = responseBoundaryRunId(entry);
- runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null;
+ runlessKey = boundaryRunId == null ? runlessTurnFoldRunId(entry.id) : null;
responseKey = boundaryRunId ?? runlessKey;
continue;
}
if (entry.kind === "message" && timelineEntryIsSteer(entry)) {
const steerRunId = entry.message.runId ?? responseKey;
const group = steerRunId === null ? undefined : groupsByRunId.get(steerRunId);
- if (group !== undefined && group.entries.length > 0) {
- // Back-to-back steers split once, after the last of them.
- if (group.steerSegments.at(-1)?.startIndex === group.entries.length) {
- group.steerSegments.pop();
- }
- group.steerSegments.push({ startIndex: group.entries.length, steer: entry });
+ const nextEntry = input.timelineEntries[index + 1];
+ if (nextEntry !== undefined) {
+ if (group !== undefined) group.anchorEntryId = nextEntry.id;
+ else if (pendingBoundary !== null) pendingBoundary.anchorEntryId = nextEntry.id;
}
continue;
}
- const ownRunId = timelineEntryFoldRunId(entry, runlessKey);
+ // A failed run's notices join its fold, which names the failure itself.
+ const ownRunId =
+ timelineEntryFoldRunId(entry, runlessKey) ??
+ (entry.kind === "work" &&
+ entry.entry.itemType === "system_notice" &&
+ entry.entry.runId &&
+ input.failedRunIds.has(entry.entry.runId)
+ ? entry.entry.runId
+ : null);
if (!ownRunId) {
continue;
}
@@ -1108,7 +1296,6 @@ function deriveTurnFolds(input: {
hasStreamingMessage: false,
startBoundary: pendingBoundary?.createdAt ?? null,
anchorEntryId: pendingBoundary?.anchorEntryId ?? entry.id,
- steerSegments: [],
};
pendingBoundary = null;
groupsByRunId.set(runId, group);
@@ -1124,34 +1311,72 @@ function deriveTurnFolds(input: {
}
}
+ const readyCheckpointByRunId = new Map();
+ for (const summary of input.turnDiffSummaries) {
+ if (summary.runId && summary.status === "ready")
+ readyCheckpointByRunId.set(summary.runId, summary);
+ }
const foldsByAnchorEntryId = new Map();
+ const replyIds = steerReplyEntryIds(input.timelineEntries);
for (const [runId, group] of groupsByRunId) {
- if (
- input.unfoldedRunIds.has(runId) ||
- interruptedRunIds.has(runId) ||
- runlessFailedKeys.has(runId) ||
- (input.runlessWorkActive && runId === runlessKey)
- ) {
+ if (input.unfoldedRunIds.has(runId) || (input.runlessWorkActive && runId === runlessKey)) {
continue;
}
if (group.hasStreamingMessage) {
continue;
}
+ const failed = input.failedRunIds.has(runId) || runlessFailedKeys.has(runId);
+ const prompt = input.timelineEntries[promptIndexByRunId.get(runId) ?? -1];
+ const compactionHasStopRow =
+ prompt?.kind === "message" &&
+ isCompactionCommandMessage(prompt.message) &&
+ idleCompactionOf(prompt.message) !== undefined &&
+ group.entries.some(timelineEntryIsCompaction);
const hiddenEntryIds = new Set();
+ const endingEntryIds = new Set();
+ let failureMessage: string | null = null;
+ let noticeMessage: string | null = null;
+ let resetAt: string | null = null;
const terminalEntryIndex = group.terminalEntry
? group.entries.findIndex((entry) => entry.id === group.terminalEntry?.id)
: group.entries.length;
// Folded dividers lose their place in the turn, so only the latest stays visible.
const visibleCompactionId = group.entries.findLast(timelineEntryIsCompaction)?.id;
for (const [index, entry] of group.entries.entries()) {
- if (entry.id === group.terminalEntry?.id) {
+ if (entry.id === group.terminalEntry?.id || replyIds.has(entry.id)) {
+ continue;
+ }
+ if (failed) {
+ const failedItem = timelineEntryFailedItem(entry);
+ const notice =
+ entry.kind === "work" && entry.entry.itemType === "system_notice" ? entry.entry : null;
+ if (failedItem !== null || notice !== null) {
+ if (failedItem !== null) {
+ failureMessage = failedItem.failure.message;
+ resetAt =
+ failedItem.failure.class === "usage_limit"
+ ? (failedItem.failure.resetAt ?? null)
+ : null;
+ }
+ if (notice !== null) noticeMessage = notice.label;
+ hiddenEntryIds.add(entry.id);
+ endingEntryIds.add(entry.id);
+ continue;
+ }
+ }
+ // A failed turn keeps its other failures below the fold; the rest folds away.
+ if (failed && timelineEntryIsFailure(entry)) {
continue;
}
const isCompaction = timelineEntryIsCompaction(entry);
+ const isInterruptResult =
+ entry.kind === "event" && entry.projectedItem.item.type === "run_interrupt_result";
+ if (isInterruptResult && compactionHasStopRow) continue;
const isFoldableTrailingActivity =
- entry.kind === "work" &&
- entry.entry.toolLifecycleStatus !== "inProgress" &&
- !workEntryDisplayIndicatesToolFailure(entry.entry);
+ isInterruptResult ||
+ (entry.kind === "work" &&
+ entry.entry.toolLifecycleStatus !== "inProgress" &&
+ !workEntryDisplayIndicatesToolFailure(entry.entry));
if (!isCompaction && index > terminalEntryIndex && !isFoldableTrailingActivity) {
continue;
}
@@ -1166,13 +1391,17 @@ function deriveTurnFolds(input: {
if (entry.kind === "work" && entry.entry.itemType === "notification") continue;
if (entry.kind === "work" && entry.entry.questionAnswer !== undefined) continue;
hiddenEntryIds.add(entry.id);
+ // A stopped fold says so itself; its interrupt divider never shows.
+ if (isInterruptResult && !failed) endingEntryIds.add(entry.id);
}
- if (hiddenEntryIds.size === 0) {
- continue;
- }
- const hidesNonCompactionWork = (entries: ReadonlyArray) =>
- entries.some((entry) => hiddenEntryIds.has(entry.id) && !timelineEntryIsCompaction(entry));
- if (!hidesNonCompactionWork(group.entries)) {
+ const hidesNonCompactionWork = group.entries.some(
+ (entry) => hiddenEntryIds.has(entry.id) && !timelineEntryIsCompaction(entry),
+ );
+ // A reply still owns its duration bar when there was no work to expand.
+ if (
+ !hidesNonCompactionWork &&
+ (group.terminalEntry === null || !input.durationRowForBareReply)
+ ) {
continue;
}
@@ -1195,51 +1424,83 @@ function deriveTurnFolds(input: {
maxIsoTimestamp(group.terminalEntry?.message.updatedAt ?? null, lastEntryEnd) ??
lastEntryEnd,
);
- const duration = elapsedMs !== null ? formatDuration(elapsedMs) : null;
- const label = isLatestInterruptedTurn
- ? duration
- ? `You stopped after ${duration}`
- : "You stopped this response"
- : duration
- ? `Worked for ${duration}`
- : "Worked";
-
- // The top row keeps the whole turn's time, steers included. Work after a
- // steer folds under its own row below that steer, keyed apart from the run
- // so each segment opens on its own.
- const segmentHiddenIds = (entries: ReadonlyArray) =>
- new Set(entries.flatMap((entry) => (hiddenEntryIds.has(entry.id) ? [entry.id] : [])));
- const firstSegment = group.entries.slice(0, group.steerSegments[0]?.startIndex);
+ const ending: TurnFoldEnding = failed
+ ? usageLimitedRunIds.has(runId)
+ ? "limited"
+ : "failed"
+ : isLatestInterruptedTurn || interruptedRunIds.has(runId)
+ ? "stopped"
+ : "worked";
+ const label = turnFoldLabel(ending, elapsedMs);
+ // The checkpoint diff is what the changed-files card shows; provider
+ // counts are the fallback for turns without a checkpoint.
+ const checkpoint = readyCheckpointByRunId.get(runId);
+ const diff =
+ (checkpoint ? sumCheckpointLineStat(checkpoint) : null) ??
+ sumTimelineFileChangeLineStat(group.entries);
+
foldsByAnchorEntryId.set(group.anchorEntryId, {
runId,
anchorEntryId: group.anchorEntryId,
createdAt: group.startBoundary ?? firstEntry.createdAt,
- hiddenEntryIds: segmentHiddenIds(firstSegment),
- entryIds: new Set(firstSegment.map((entry) => entry.id)),
+ hiddenEntryIds,
+ entryIds: new Set(
+ group.entries.flatMap((entry) => (replyIds.has(entry.id) ? [] : [entry.id])),
+ ),
label,
+ ending,
+ diff,
+ endingEntryIds,
+ failureMessage,
+ noticeMessage,
+ resetAt,
});
- for (const [index, { startIndex, steer }] of group.steerSegments.entries()) {
- const entries = group.entries.slice(startIndex, group.steerSegments[index + 1]?.startIndex);
- const anchorEntry = entries[0];
- if (anchorEntry === undefined || !hidesNonCompactionWork(entries)) continue;
- const segmentMs = computeElapsedMs(
- steer.createdAt,
- entries.map(timelineEntryEnd).reduce(maxIsoTimestamp, null) ??
- steer.createdAt,
- );
- foldsByAnchorEntryId.set(anchorEntry.id, {
- runId: RunId.make(`${runId}:steer:${steer.id}`),
- anchorEntryId: anchorEntry.id,
- createdAt: steer.createdAt,
- hiddenEntryIds: segmentHiddenIds(entries),
- entryIds: new Set(entries.map((entry) => entry.id)),
- label: segmentMs !== null ? `Continued for ${formatDuration(segmentMs)}` : "Continued",
- });
- }
}
return foldsByAnchorEntryId;
}
+function turnFoldLabel(ending: TurnFoldEnding, elapsedMs: number | null): string {
+ const duration = elapsedMs !== null ? formatWorkingDuration(elapsedMs) : null;
+ switch (ending) {
+ case "stopped":
+ return duration ? `Stopped at ${duration}` : "Stopped";
+ case "failed":
+ return duration ? `Failed after ${duration}` : "Failed";
+ case "limited":
+ return duration ? `Limited at ${duration}` : "Limited";
+ case "worked":
+ return duration ? `Worked for ${duration}` : "Worked";
+ }
+}
+
+function gatherOpenFoldWork(
+ entries: ReadonlyArray,
+ folds: Iterable,
+ isOpen: (fold: TurnFold) => boolean,
+) {
+ const gatheredByAnchorId = new Map();
+ const foldsByAnchorEntryId = new Map();
+ for (const fold of folds) {
+ const anchorIndex = isOpen(fold)
+ ? entries.findIndex((entry) => entry.id === fold.anchorEntryId)
+ : -1;
+ const gathered =
+ anchorIndex > 0
+ ? entries.slice(0, anchorIndex).filter((entry) => fold.hiddenEntryIds.has(entry.id))
+ : [];
+ if (gathered.length > 0) gatheredByAnchorId.set(fold.anchorEntryId, gathered);
+ foldsByAnchorEntryId.set(gathered[0]?.id ?? fold.anchorEntryId, fold);
+ }
+ if (gatheredByAnchorId.size === 0) return { entries, foldsByAnchorEntryId };
+ const gatheredIds = new Set([...gatheredByAnchorId.values()].flat().map((entry) => entry.id));
+ return {
+ entries: entries.flatMap((entry) =>
+ gatheredIds.has(entry.id) ? [] : [...(gatheredByAnchorId.get(entry.id) ?? []), entry],
+ ),
+ foldsByAnchorEntryId,
+ };
+}
+
function attachTrailingToolGroupsToAssistant(
rows: ReadonlyArray,
): MessagesTimelineRow[] {
@@ -1514,10 +1775,14 @@ function deriveTimelineTurnFolds(
timelineEntries,
terminalAssistantMessageIds: deriveTerminalAssistantMessageIds(timelineEntries),
latestRun: input.latestRun ?? null,
- unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
+ unfoldedRunIds: activeVisualResponseRunIds,
+ failedRunIds,
runlessWorkActive: input.isWorking && input.runlessWorkActive === true,
+ // Fold membership does not depend on the diff stat the fold header shows.
+ turnDiffSummaries: [],
liveSubagentEntryIds: liveSubagentCardEntryIds(timelineEntries),
retainedAssistantReplyEntryIds: retainedAssistantReplyEntryIds(timelineEntries),
+ durationRowForBareReply: input.toolCallVisibility !== "hideAll",
});
}
@@ -1540,6 +1805,8 @@ export function deriveMessagesTimelineRows(input: {
worktreeSetup?: WorktreeSetupSnapshot | null;
toolCallVisibility?: ToolCallVisibility;
revealedMessageId?: MessageId | null;
+ /** Commands and skills of the thread's provider, for telling a command from a path. */
+ slashCommands?: SlashCommandCatalog;
}): MessagesTimelineRow[] {
const timelineEntries = timelineRowEntries(input);
const embeddedResultRunIds = new Set();
@@ -1575,6 +1842,15 @@ export function deriveMessagesTimelineRows(input: {
const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(timelineEntries);
const failedRunIds = failedTimelineRunIds(timelineEntries, input.latestRun ?? null);
const itemizedRunIds = itemizedFailedRunIds(timelineEntries, failedRunIds);
+ const stoppedRunIds = stoppedTimelineRunIds(timelineEntries, input.latestRun ?? null);
+ // A standalone `/compact` and the compaction its run reports are one row.
+ const compactionByRunId = new Map();
+ for (const entry of timelineEntries) {
+ if (entry.kind !== "work" || entry.entry.sourceActivityKind !== "context-compaction") continue;
+ const runId = entry.entry.runId;
+ if (runId != null && !compactionByRunId.has(runId)) compactionByRunId.set(runId, entry.entry);
+ }
+ const mergedCompactionIds = new Set();
const liveSubagentEntryIds = liveSubagentCardEntryIds(timelineEntries);
const retainedAssistantReplyIds = retainedAssistantReplyEntryIds(timelineEntries);
const supersededFoldsByAnchorEntryId = deriveSupersededAttemptFolds(
@@ -1592,26 +1868,36 @@ export function deriveMessagesTimelineRows(input: {
isWorking: input.isWorking,
});
const runlessWorkActive = input.isWorking && input.runlessWorkActive === true;
- const foldsByAnchorEntryId =
+ const turnFolds =
input.toolCallVisibility === "zen"
? new Map()
: deriveTurnFolds({
timelineEntries: timelineEntries,
terminalAssistantMessageIds,
latestRun: input.latestRun ?? null,
- unfoldedRunIds: new Set([...activeVisualResponseRunIds, ...failedRunIds]),
+ unfoldedRunIds: activeVisualResponseRunIds,
+ failedRunIds,
runlessWorkActive,
+ turnDiffSummaries: input.turnDiffSummaries,
liveSubagentEntryIds,
retainedAssistantReplyEntryIds: retainedAssistantReplyIds,
+ durationRowForBareReply: input.toolCallVisibility !== "hideAll",
});
const timelineEntryById = new Map(timelineEntries.map((entry) => [entry.id, entry]));
+ const turnFoldExpanded = (fold: TurnFold) =>
+ (input.expandedRunIds?.has(fold.runId) ?? false) !== turnFoldStartsOpen(fold.ending);
+ const { entries: rowEntries, foldsByAnchorEntryId } = gatherOpenFoldWork(
+ timelineEntries,
+ turnFolds.values(),
+ turnFoldExpanded,
+ );
const collapsedEntryIds = new Set();
const collapsedFoldMemberIds = new Set();
for (const fold of foldsByAnchorEntryId.values()) {
- if (!input.expandedRunIds?.has(fold.runId)) {
- for (const entryId of fold.hiddenEntryIds) {
- collapsedEntryIds.add(entryId);
- }
+ for (const entryId of turnFoldExpanded(fold) ? fold.endingEntryIds : fold.hiddenEntryIds) {
+ collapsedEntryIds.add(entryId);
+ }
+ if (!turnFoldExpanded(fold)) {
for (const entryId of fold.entryIds) {
collapsedFoldMemberIds.add(entryId);
}
@@ -1734,7 +2020,8 @@ export function deriveMessagesTimelineRows(input: {
entry.message.runId !== null &&
isCompactionCommandMessage(entry.message)
) {
- compactionRuns.set(entry.message.runId, idleCompactionOf(entry.message));
+ const reason = idleCompactionOf(entry.message);
+ if (reason !== undefined) compactionRuns.set(entry.message.runId, reason);
} else if (entry.kind === "work" && entry.entry.runId) {
if (timelineEntryIsCompaction(entry)) {
compactedRunIds.add(entry.entry.runId);
@@ -1837,8 +2124,47 @@ export function deriveMessagesTimelineRows(input: {
);
};
- for (let index = 0; index < timelineEntries.length; index += 1) {
- const timelineEntry = timelineEntries[index];
+ const timelineSlashCommand = (message: ChatMessage): TimelineSlashCommand | null => {
+ const parsed = parseSlashCommandMessage(
+ message.text,
+ input.slashCommands ?? EMPTY_SLASH_COMMAND_CATALOG,
+ );
+ if (!parsed) return null;
+ const standalone = parsed.standalone && (message.attachments?.length ?? 0) === 0;
+ const runId = message.runId ?? null;
+ const compaction =
+ standalone && !parsed.skill && parsed.name === "compact" && runId !== null
+ ? compactionByRunId.get(runId)
+ : undefined;
+ if (compaction) mergedCompactionIds.add(compaction.id);
+ const compactionItem = compaction?.projectedItem?.item;
+ const state: SlashCommandState =
+ runId === null
+ ? input.isWorking
+ ? "running"
+ : "done"
+ : runId === unsettledRunId
+ ? "running"
+ : failedRunIds.has(runId)
+ ? "failed"
+ : stoppedRunIds.has(runId)
+ ? "stopped"
+ : "done";
+ return {
+ ...parsed,
+ standalone,
+ state,
+ result:
+ compactionItem?.type === "compaction" &&
+ compactionItem.beforeTokenCount !== undefined &&
+ compactionItem.afterTokenCount !== undefined
+ ? `${formatTokens(compactionItem.beforeTokenCount)} → ${formatTokens(compactionItem.afterTokenCount)} tokens`
+ : null,
+ };
+ };
+
+ for (let index = 0; index < rowEntries.length; index += 1) {
+ const timelineEntry = rowEntries[index];
if (!timelineEntry) {
continue;
}
@@ -1851,18 +2177,6 @@ export function deriveMessagesTimelineRows(input: {
appendActiveWorkRows();
}
- // A stopped compaction's own divider already says it stopped.
- if (
- timelineEntry.kind === "event" &&
- (timelineEntry.projectedItem.item.type === "run_interrupt_request" ||
- (timelineEntry.projectedItem.item.type === "run_interrupt_result" &&
- timelineEntry.projectedItem.item.runId !== null &&
- compactionRuns.has(timelineEntry.projectedItem.item.runId) &&
- compactedRunIds.has(timelineEntry.projectedItem.item.runId)))
- ) {
- continue;
- }
-
const turnFold = foldsByAnchorEntryId.get(timelineEntry.id);
if (turnFold) {
const deferredWorkItemIds = [...turnFold.entryIds].flatMap((entryId) => {
@@ -1880,11 +2194,28 @@ export function deriveMessagesTimelineRows(input: {
createdAt: turnFold.createdAt,
runId: turnFold.runId,
label: turnFold.label,
- expanded: input.expandedRunIds?.has(turnFold.runId) ?? false,
+ ending: turnFold.ending,
+ diff: turnFold.diff,
+ expanded: turnFoldExpanded(turnFold),
+ foldable: [...turnFold.hiddenEntryIds].some(
+ (entryId) => !turnFold.endingEntryIds.has(entryId),
+ ),
+ failureMessage: turnFold.failureMessage,
+ noticeMessage: turnFold.noticeMessage,
+ resetAt: turnFold.resetAt,
});
}
- if (collapsedEntryIds.has(timelineEntry.id)) {
+ // A turn stopped before any work anchors its fold here, so it goes after it.
+ if (
+ collapsedEntryIds.has(timelineEntry.id) ||
+ (timelineEntry.kind === "event" &&
+ (timelineEntry.projectedItem.item.type === "run_interrupt_request" ||
+ (timelineEntry.projectedItem.item.type === "run_interrupt_result" &&
+ timelineEntry.projectedItem.item.runId !== null &&
+ compactionRuns.has(timelineEntry.projectedItem.item.runId) &&
+ compactedRunIds.has(timelineEntry.projectedItem.item.runId))))
+ ) {
continue;
}
@@ -1915,6 +2246,7 @@ export function deriveMessagesTimelineRows(input: {
timelineEntry.entry.sourceActivityKind === "context-compaction"
) {
const active = workEntryIsInActiveRun(timelineEntry.entry);
+ if (mergedCompactionIds.has(timelineEntry.id)) continue;
if (
active &&
activeCompactionRunId !== null &&
@@ -1976,8 +2308,8 @@ export function deriveMessagesTimelineRows(input: {
}
const groupedEntries = [timelineEntry.entry];
let cursor = index + 1;
- while (cursor < timelineEntries.length) {
- const nextEntry = timelineEntries[cursor];
+ while (cursor < rowEntries.length) {
+ const nextEntry = rowEntries[cursor];
if (
!nextEntry ||
nextEntry.kind !== "work" ||
@@ -2168,7 +2500,9 @@ export function deriveMessagesTimelineRows(input: {
const compactionRunId = timelineEntry.message.runId;
const compactionRun =
- compactionRunId !== null && isCompactionCommandMessage(timelineEntry.message);
+ compactionRunId !== null &&
+ isCompactionCommandMessage(timelineEntry.message) &&
+ idleCompactionOf(timelineEntry.message) !== undefined;
if (compactionRun) {
if (!compactedRunIds.has(compactionRunId) && compactionRunId !== activeCompactionRunId) {
const pending = compactionRunId === runningCompactionRunId;
@@ -2198,11 +2532,17 @@ export function deriveMessagesTimelineRows(input: {
terminalAssistantMessageIds.has(timelineEntry.message.id) &&
!assistantResponseStillInProgress;
+ const command =
+ timelineEntry.message.role === "user" && !userMessageIsSteer(timelineEntry.message)
+ ? timelineSlashCommand(timelineEntry.message)
+ : null;
+
nextRows.push({
kind: "message",
id: timelineEntry.id,
createdAt: timelineEntry.createdAt,
message: timelineEntry.message,
+ ...(command ? { command } : {}),
...(timelineEntry.projectedItem === undefined
? {}
: { projectedItem: timelineEntry.projectedItem }),
@@ -2307,32 +2647,56 @@ export function deriveMessagesTimelineRows(input: {
const result = attachTrailingToolGroupsToAssistant(
attachCreatedThreadSummaries(nextRows, timelineEntries, collapsedFoldMemberIds),
);
- return result.map((row, index) => {
- if (timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1])) {
- return { ...row, continuesWorkLog: true };
- }
- if (!timelineRowIsOwnUserMessage(row)) return row;
- const previous = result[index - 1];
- const groupedWithNext = timelineRowIsOwnUserMessage(result[index + 1]);
- const groupedWithPrevious = timelineRowIsOwnUserMessage(previous);
- const continuesSteer =
- groupedWithPrevious && messageIsSteer(row.message) && messageIsSteer(previous.message);
- if (!groupedWithNext && !groupedWithPrevious) return row;
- return { ...row, groupedWithNext, groupedWithPrevious, continuesSteer };
+ return withPromptGrouping(
+ result.map((row, index) =>
+ timelineRowIsWorkLog(row) && timelineRowIsWorkLog(result[index + 1])
+ ? { ...row, continuesWorkLog: true }
+ : row,
+ ),
+ );
+}
+
+/** Flags prompts by their neighbours; rerun whenever rows move, so stale flags never split a group. */
+function withPromptGrouping(rows: ReadonlyArray): MessagesTimelineRow[] {
+ return rows.map((row, index) => {
+ if (row.kind !== "message" || !timelineRowIsUserPrompt(row)) return row;
+ const next = rows[index + 1];
+ const own = timelineRowIsOwnUserMessage(row);
+ const continuesPromptStack = timelineRowIsUserPrompt(next);
+ const groupedWithPrevious = own && timelineRowIsOwnUserMessage(rows[index - 1]);
+ const groupedWithNext = own && timelineRowIsOwnUserMessage(next);
+ if (
+ (row.continuesPromptStack ?? false) === continuesPromptStack &&
+ (row.groupedWithPrevious ?? false) === groupedWithPrevious &&
+ (row.groupedWithNext ?? false) === groupedWithNext
+ ) {
+ return row;
+ }
+ return { ...row, continuesPromptStack, groupedWithNext, groupedWithPrevious };
});
}
+function timelineRowIsUserPrompt(row: MessagesTimelineRow | undefined): boolean {
+ return (
+ row !== undefined &&
+ row.kind === "message" &&
+ row.message.role === "user" &&
+ !row.command?.standalone
+ );
+}
+
function timelineRowIsOwnUserMessage(
row: MessagesTimelineRow | undefined,
): row is Extract {
return (
row?.kind === "message" &&
row.message.role === "user" &&
+ !row.command?.standalone &&
resolveUserMessagePresentation(row.message).attribution === null
);
}
-function messageIsSteer(message: ChatMessage): boolean {
+function userMessageIsSteer(message: ChatMessage): boolean {
return message.inputIntent === "steer" || message.inputIntent === "promoted_queued_to_steer";
}
@@ -2400,15 +2764,489 @@ const WORKTREE_SETUP_ROW_ID = "worktree-setup-row";
export type MessagesTimelineRowsInput = Parameters[0];
+export type WorkingWindowItem =
+ | { readonly kind: "work"; readonly id: string; readonly entry: WorkLogEntry }
+ | {
+ readonly kind: "text";
+ readonly id: string;
+ readonly message: ChatMessage;
+ readonly projectedItemId?: string;
+ }
+ | {
+ readonly kind: "agents";
+ readonly id: string;
+ readonly row: Extract;
+ };
+
+export interface WorkingWindow {
+ /** Changes with each prompt or steer, which closes an open history. */
+ readonly key: string;
+ readonly items: ReadonlyArray;
+ readonly diff: DiffLineStat | null;
+ /** The latest test command failed; the count comes from the runner summary. */
+ readonly failedTests: { readonly count: number | null } | null;
+ /** Attempt number while the provider retries a dropped stream. */
+ readonly retryAttempt: number | null;
+ /** Stop was requested and the provider has not let go yet. */
+ readonly stopping: boolean;
+ readonly answering: boolean;
+ readonly historyOpen: boolean;
+ /** A standalone /compact owns its progress row and clock. */
+ readonly compactCommand?: boolean;
+}
+
+function sameWorkingWindowItem(a: WorkingWindowItem, b: WorkingWindowItem): boolean {
+ if (a.kind !== b.kind || a.id !== b.id) return false;
+ switch (a.kind) {
+ case "work":
+ return Equal.equals(a.entry, (b as typeof a).entry);
+ case "text":
+ return (
+ a.message === (b as typeof a).message &&
+ a.projectedItemId === (b as typeof a).projectedItemId
+ );
+ case "agents":
+ return isRowUnchanged(a.row, (b as typeof a).row);
+ }
+}
+
+function sameDiffLineStat(a: DiffLineStat | null, b: DiffLineStat | null): boolean {
+ return a === b || (a?.additions === b?.additions && a?.deletions === b?.deletions);
+}
+
+function sameWorkingWindow(a: WorkingWindow | undefined, b: WorkingWindow | undefined): boolean {
+ if (a === b) return true;
+ if (a === undefined || b === undefined) return false;
+ return (
+ a.key === b.key &&
+ a.historyOpen === b.historyOpen &&
+ a.stopping === b.stopping &&
+ a.answering === b.answering &&
+ a.compactCommand === b.compactCommand &&
+ a.retryAttempt === b.retryAttempt &&
+ a.failedTests?.count === b.failedTests?.count &&
+ (a.failedTests === null) === (b.failedTests === null) &&
+ sameDiffLineStat(a.diff, b.diff) &&
+ a.items.length === b.items.length &&
+ a.items.every((item, index) => sameWorkingWindowItem(item, b.items[index]!))
+ );
+}
+
+function workEntryCommandInput(entry: WorkLogEntry): string | null {
+ const item = entry.projectedItem?.item;
+ return item?.type === "command_execution" ? item.input : (entry.command ?? null);
+}
+
+function deriveFailedTests(items: ReadonlyArray): WorkingWindow["failedTests"] {
+ // The latest test run decides: a passing rerun clears the slot.
+ for (let index = items.length - 1; index >= 0; index -= 1) {
+ const item = items[index]!;
+ if (item.kind !== "work" || item.entry.itemType !== "command_execution") continue;
+ const input = workEntryCommandInput(item.entry);
+ if (input === null || !commandRunsTests(input)) continue;
+ if (item.entry.toolLifecycleStatus === "inProgress") return null;
+ if (!workEntryDisplayIndicatesToolFailure(item.entry)) return null;
+ const turnItem = item.entry.projectedItem?.item;
+ return {
+ count: turnItem?.type === "command_execution" ? (turnItem.failedTestCount ?? null) : null,
+ };
+ }
+ return null;
+}
+
+function deriveRetryAttempt(items: ReadonlyArray): number | null {
+ const last = items.findLast(
+ (item) => item.kind === "work" && item.entry.projectedItem?.item.type === "error",
+ );
+ const turnItem = last?.kind === "work" ? last.entry.projectedItem?.item : undefined;
+ return turnItem?.type === "error" && turnItem.retry !== undefined && turnItem.status === "running"
+ ? turnItem.retry.attempt
+ : null;
+}
+
+/** Row ids and work entry ids that a working window item replaces. */
+function collectWindowIds(items: ReadonlyArray) {
+ const ids = new Set();
+ for (const item of items) {
+ ids.add(item.id);
+ if (item.kind === "work") ids.add(item.entry.id);
+ if (item.kind === "agents") {
+ for (const member of item.row.subagents ?? []) ids.add(member.item.id);
+ }
+ }
+ return ids;
+}
+
+function rowIsReplacedByWindow(row: MessagesTimelineRow, ids: ReadonlySet): boolean {
+ switch (row.kind) {
+ case "thinking":
+ return true;
+ case "work":
+ return (
+ row.groupedEntries.length > 0 && row.groupedEntries.every((entry) => ids.has(entry.id))
+ );
+ case "work-live":
+ return ids.has(row.entry.id);
+ case "work-toggle":
+ return ids.has(row.id.slice("work-toggle:".length));
+ case "context-compaction":
+ case "event":
+ return ids.has(row.id);
+ case "message":
+ return row.message.role === "assistant" && ids.has(row.id);
+ case "assistant-meta":
+ return ids.has(row.message.id);
+ default:
+ return false;
+ }
+}
+
+/**
+ * Moves the running turn's tool calls, commentary and subagents under the working
+ * bar. Trailing text stays below it as the answer. The bar sits after the latest
+ * steer; steers and their replies stay above it in timeline order.
+ * Everything else (plans, resource cards, notices) keeps its row.
+ */
+export function applyWorkingWindow(
+ rows: ReadonlyArray,
+ input: Pick<
+ MessagesTimelineRowsInput,
+ | "timelineEntries"
+ | "latestRun"
+ | "runningRunId"
+ | "isWorking"
+ | "runlessWorkActive"
+ | "pendingApprovalRequestIds"
+ | "toolCallVisibility"
+ | "revealedMessageId"
+ >,
+ historyOpenKey: string | null = null,
+): MessagesTimelineRow[] {
+ const workingIndex = input.isWorking ? rows.findIndex((row) => row.kind === "working") : -1;
+ if (workingIndex < 0) return rows as MessagesTimelineRow[];
+ const entries = timelineRowEntries(input);
+ const workingRow = rows[workingIndex] as Extract;
+ const compactionRowIds = new Set(
+ rows.filter((row) => row.kind === "context-compaction").map((row) => row.id),
+ );
+ const compactedRunIds = new Set(
+ entries.flatMap((entry) =>
+ timelineEntryIsCompaction(entry) ? [timelineEntryRunId(entry)] : [],
+ ),
+ );
+ const unsettledRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);
+ const runIds = deriveActiveVisualResponseRunIds({
+ timelineEntries: entries,
+ unsettledRunId,
+ isWorking: true,
+ });
+ const boundaryIndex = lastResponseBoundaryIndex(entries);
+ const replyIds = steerReplyEntryIds(entries);
+ let key = entries[boundaryIndex]?.id ?? "working";
+ const items: WorkingWindowItem[] = [];
+ // A standalone `/compact` row already shows its run's compaction.
+ const compactCommandRunIds = new Set(
+ rows.flatMap((row) =>
+ row.kind === "message" &&
+ row.command?.standalone &&
+ row.command.name === "compact" &&
+ !row.command.skill
+ ? [row.message.runId ?? null]
+ : [],
+ ),
+ );
+ let interruptRequested = false;
+ let interruptSettled = false;
+ for (let index = boundaryIndex + 1; index < entries.length; index += 1) {
+ const entry = entries[index]!;
+ if (entry.kind === "message" && entry.message.role === "user") {
+ key = entry.id;
+ continue;
+ }
+ if (entry.attempt?.status === "superseded" || replyIds.has(entry.id)) continue;
+ const runId =
+ entry.kind === "event" ? entry.projectedItem.item.runId : timelineEntryRunId(entry);
+ // A provider-native subagent thread's live work has no app run.
+ if (runId === null ? input.runlessWorkActive !== true : !runIds.has(runId)) continue;
+ if (entry.kind === "work") {
+ const item = entry.entry.projectedItem?.item;
+ if (
+ (item?.type === "approval_request" &&
+ input.pendingApprovalRequestIds?.has(item.requestId)) ||
+ entry.entry.itemType === "notification" ||
+ entry.entry.itemType === "system_notice" ||
+ (entry.entry.sourceActivityKind === "context-compaction" &&
+ (compactCommandRunIds.has(runId) ||
+ compactionRowIds.has(entry.id) ||
+ workingRow.compacting !== undefined)) ||
+ (compactedRunIds.has(runId) &&
+ entry.entry.questionAnswer !== undefined &&
+ claudeResumeCompactionAge(entry.entry.questionAnswer) !== null) ||
+ !workEntryIsVisibleInGroup(entry.entry, true)
+ ) {
+ continue;
+ }
+ items.push({ kind: "work", id: entry.id, entry: entry.entry });
+ } else if (entry.kind === "message" && entry.message.role === "assistant") {
+ items.push({
+ kind: "text",
+ id: entry.id,
+ message: entry.message,
+ ...(entry.projectedItem ? { projectedItemId: entry.projectedItem.item.id } : {}),
+ });
+ } else if (entry.kind === "event") {
+ const turnItem = entry.projectedItem.item;
+ if (turnItem.type === "run_interrupt_request") interruptRequested = true;
+ if (turnItem.type === "run_interrupt_result") interruptSettled = true;
+ if (turnItem.type !== "subagent") continue;
+ const previous = items.at(-1);
+ if (
+ previous?.kind === "agents" &&
+ previous.row.projectedItem.item.providerTurnId === turnItem.providerTurnId
+ ) {
+ items[items.length - 1] = {
+ ...previous,
+ row: {
+ ...previous.row,
+ subagents: [
+ ...(previous.row.subagents ?? [previous.row.projectedItem]),
+ entry.projectedItem,
+ ],
+ },
+ };
+ continue;
+ }
+ items.push({
+ kind: "agents",
+ id: entry.id,
+ row: {
+ kind: "event",
+ id: entry.id,
+ createdAt: entry.createdAt,
+ projectedItem: entry.projectedItem,
+ subagents: [entry.projectedItem],
+ },
+ });
+ }
+ }
+
+ const answer = items.at(-1)?.kind === "text" ? items.pop() : undefined;
+ const ids = collectWindowIds(items);
+ const windowStartIndex = Math.max(
+ workingIndex,
+ rows.findLastIndex((row) => row.kind === "message" && row.message.role === "user"),
+ );
+ const keptRows = (start: number, end?: number) =>
+ rows.slice(start, end).filter((row) => !rowIsReplacedByWindow(row, ids));
+ const before = keptRows(workingIndex + 1, windowStartIndex + 1);
+ const after = keptRows(windowStartIndex + 1);
+ const window: WorkingWindow = {
+ key,
+ items,
+ compactCommand: compactCommandRunIds.has(unsettledRunId),
+ diff: sumFileChangeLineStat(
+ items.flatMap((item) => (item.kind === "work" ? [item.entry] : [])),
+ ),
+ failedTests: deriveFailedTests(items),
+ retryAttempt: deriveRetryAttempt(items),
+ stopping: interruptRequested && !interruptSettled,
+ answering: answer?.kind === "text" && answer.message.streaming,
+ historyOpen: historyOpenKey === key,
+ };
+ // Steers move above the bar, next to the prompt they continue.
+ return withPromptGrouping([
+ ...rows.slice(0, workingIndex),
+ ...before,
+ { ...workingRow, window },
+ ...after.map((row) => (row.continuesWorkLog ? { ...row, continuesWorkLog: false } : row)),
+ ]);
+}
+
+/** `expanded` with every "Handled N updates" group that holds the run open. */
+export function withRunGroupsOpen(
+ expanded: ReadonlySet,
+ rows: ReadonlyArray,
+ runId: RunId,
+): ReadonlySet {
+ const closed = rows.flatMap((row) =>
+ row.kind === "background-activity" && row.runIds.includes(runId) && !expanded.has(row.id)
+ ? [row.id]
+ : [],
+ );
+ return closed.length === 0 ? expanded : new Set([...expanded, ...closed]);
+}
+
+/** Fold persisted notification deliveries; ordinary prompts and actionable runs stay visible. */
+export function applyBackgroundActivityGroups(
+ rows: MessagesTimelineRow[],
+ input: Pick<
+ MessagesTimelineRowsInput,
+ "timelineEntries" | "latestRun" | "runningRunId" | "expandedWorkGroupIds"
+ > & {
+ hasPendingRequest: boolean;
+ hasThreadError: boolean;
+ },
+ previousRows: ReadonlyArray = [],
+): MessagesTimelineRow[] {
+ const notifications = new Map>();
+ const excluded = new Set();
+ const entryRuns = new Map();
+ const activeRunId = deriveUnsettledRunId(input.latestRun ?? null, input.runningRunId ?? null);
+ for (const entry of input.timelineEntries) {
+ const item =
+ timelineEntryTurnItem(entry) ??
+ (entry.kind === "work" ? entry.entry.structuredPayload : undefined);
+ const runId =
+ entry.kind === "message" ? entry.message.runId : (item?.runId ?? timelineEntryRunId(entry));
+ if (runId == null) continue;
+ entryRuns.set(entry.id, runId);
+ if (item?.type === "notification") {
+ const ids = notifications.get(runId) ?? new Map();
+ ids.set(item.id, item);
+ notifications.set(runId, ids);
+ }
+ if (
+ (entry.kind === "message" && entry.message.role === "user") ||
+ entry.kind === "proposed-plan" ||
+ item?.type === "error" ||
+ item?.type === "system_notice" ||
+ item?.status === "failed" ||
+ // A failed check is the wake's subject, not a failure of the agent's response.
+ (item?.type === "notification" && item.outcome === "failed" && runId === activeRunId) ||
+ ((item?.type === "approval_request" || item?.type === "user_input_request") &&
+ (item.status === "pending" || item.status === "running" || item.status === "waiting")) ||
+ (entry.kind === "work" &&
+ (entry.entry.tone === "error" || workEntryDisplayIndicatesToolFailure(entry.entry)))
+ ) {
+ excluded.add(runId);
+ }
+ }
+ const latestRunId = input.latestRun?.runId ?? activeRunId;
+ if (
+ latestRunId &&
+ (input.hasPendingRequest || input.hasThreadError || input.latestRun?.status === "failed")
+ ) {
+ excluded.add(latestRunId);
+ }
+ for (const runId of excluded) notifications.delete(runId);
+ if (notifications.size === 0) return rows;
+ const pendingRunId =
+ activeRunId !== null && ![...entryRuns.values()].includes(activeRunId) ? activeRunId : null;
+ const previousGroupIds = new Map();
+ for (const row of previousRows) {
+ if (row.kind !== "background-activity") continue;
+ for (const runId of row.runIds) previousGroupIds.set(runId, row.id);
+ }
+
+ const rowRunId = (row: MessagesTimelineRow): RunId | null => {
+ switch (row.kind) {
+ case "message":
+ case "assistant-meta":
+ return row.message.runId ?? null;
+ case "event":
+ return row.projectedItem.item.runId;
+ case "turn-fold":
+ case "attempt-fold":
+ return row.runId;
+ case "work": {
+ const runId = row.groupedEntries[0]?.runId;
+ return runId && row.groupedEntries.every((entry) => entry.runId === runId) ? runId : null;
+ }
+ case "work-live":
+ return row.entry.runId ?? null;
+ case "working":
+ case "thinking":
+ return activeRunId;
+ case "work-toggle":
+ return row.runId ?? entryRuns.get(row.id.slice("work-toggle:".length)) ?? null;
+ default:
+ return entryRuns.get(row.id) ?? null;
+ }
+ };
+ const result: MessagesTimelineRow[] = [];
+ for (let index = 0; index < rows.length;) {
+ const first = rows[index]!;
+ const runId = rowRunId(first);
+ // Start at the delivery itself, never at unrelated rows belonging to its run.
+ if (runId === null || !notifications.get(runId)?.has(first.id)) {
+ result.push(first);
+ index += 1;
+ continue;
+ }
+ const start = index;
+ const runIds = new Set();
+ const updateIds = new Set();
+ const updates: OrchestrationV2Notification[] = [];
+ while (index < rows.length) {
+ const nextRow = rows[index]!;
+ const nextRunId = rowRunId(nextRow);
+ if (nextRunId === null || (!notifications.has(nextRunId) && nextRunId !== pendingRunId)) {
+ break;
+ }
+ runIds.add(nextRunId);
+ const update = notifications.get(nextRunId)?.get(nextRow.id);
+ if (update) {
+ updateIds.add(nextRow.id);
+ updates.push(update);
+ }
+ index += 1;
+ }
+ // Keep a mounted anchor across history prepends; restored open groups keep their key too.
+ const expandedId = [...updateIds]
+ .map((id) => `background-activity:${id}`)
+ .find((id) => input.expandedWorkGroupIds?.has(id));
+ const previousId = [...runIds]
+ .map((id) => previousGroupIds.get(id))
+ .find((id) => id !== undefined && updateIds.has(id.slice("background-activity:".length)));
+ const id = expandedId ?? previousId ?? `background-activity:${first.id}`;
+ const expanded = input.expandedWorkGroupIds?.has(id) ?? false;
+ result.push({
+ kind: "background-activity",
+ id,
+ createdAt: first.createdAt,
+ runIds: [...runIds],
+ updateCount: updateIds.size,
+ updates,
+ active: activeRunId !== null && runIds.has(activeRunId),
+ expanded,
+ });
+ // Expansion returns the original virtual rows, identities, disclosures and actions.
+ const groupRows = rows.slice(start, index);
+ if (expanded) {
+ result.push(...groupRows);
+ } else {
+ const finalAnswer = groupRows.findLast(
+ (row) =>
+ row.kind === "message" &&
+ row.message.role === "assistant" &&
+ (row.message.runId === activeRunId || !row.message.streaming),
+ );
+ if (finalAnswer?.kind === "message") {
+ result.push(
+ ...groupRows.filter(
+ (row) =>
+ row === finalAnswer ||
+ (row.kind === "assistant-meta" && row.message.id === finalAnswer.message.id),
+ ),
+ );
+ }
+ }
+ }
+ return result;
+}
+
interface MessagesTimelineRowsProjection {
readonly input: MessagesTimelineRowsInput;
readonly rows: MessagesTimelineRow[];
}
/**
- * Runless (imported V1) turns and work after a steer fold under synthetic
- * keys, so the entry's own run id is not enough to open them.
+ * Runless (imported V1) turns fold under synthetic keys, so the entry's own
+ * run id is not enough to open them.
*/
+export type TimelineTurnFoldKey = Pick;
+
+/** Every folded entry's fold, computed once for callers that check many entries. */
export function timelineTurnFoldRunIdsByEntryId(
input: Pick<
MessagesTimelineRowsInput,
@@ -2420,14 +3258,39 @@ export function timelineTurnFoldRunIdsByEntryId(
| "toolCallVisibility"
| "revealedMessageId"
>,
-): ReadonlyMap {
- const byEntryId = new Map();
+): ReadonlyMap {
+ const byEntryId = new Map();
for (const fold of deriveTimelineTurnFolds(input).values()) {
- for (const entryId of fold.hiddenEntryIds) byEntryId.set(entryId, fold.runId);
+ const key = { runId: fold.runId, ending: fold.ending };
+ for (const entryId of fold.hiddenEntryIds) {
+ // Ending entries stay hidden at any fold state; the fold line carries them.
+ if (!fold.endingEntryIds.has(entryId)) byEntryId.set(entryId, key);
+ }
}
return byEntryId;
}
+/**
+ * A failed turn stays open by default: its fold names how it ended,
+ * so its key in `expandedRunIds` means the user collapsed it.
+ */
+export function turnFoldStartsOpen(ending: TurnFoldEnding): boolean {
+ return ending === "failed" || ending === "limited";
+}
+
+/** `expanded` with the fold open. */
+export function withTurnFoldOpen(
+ expanded: ReadonlySet,
+ fold: TimelineTurnFoldKey,
+): ReadonlySet {
+ const startsOpen = turnFoldStartsOpen(fold.ending);
+ if (expanded.has(fold.runId) !== startsOpen) return expanded;
+ const next = new Set(expanded);
+ if (startsOpen) next.delete(fold.runId);
+ else next.add(fold.runId);
+ return next;
+}
+
function sameCheckpointSummaries(
previous: MessagesTimelineRowsInput["turnDiffSummaries"],
next: MessagesTimelineRowsInput["turnDiffSummaries"],
@@ -2545,7 +3408,8 @@ export function computeStableMessagesTimelineRows(
const result = rows.map((row, index) => {
const prevRow = previous.byId.get(row.id);
- const nextRow = prevRow && isRowUnchanged(prevRow, row) ? prevRow : row;
+ const nextRow =
+ prevRow && isRowUnchanged(prevRow, row) ? prevRow : withPreviousWindowItems(row, prevRow);
next.set(row.id, nextRow);
if (!anyChanged && previous.result[index] !== nextRow) {
anyChanged = true;
@@ -2556,13 +3420,46 @@ export function computeStableMessagesTimelineRows(
return anyChanged ? { byId: next, result } : previous;
}
+/** A changed working window keeps its unchanged items, so their rows skip rendering. */
+function withPreviousWindowItems(
+ row: MessagesTimelineRow,
+ prevRow: MessagesTimelineRow | undefined,
+): MessagesTimelineRow {
+ if (row.kind !== "working" || prevRow?.kind !== "working" || !row.window || !prevRow.window) {
+ return row;
+ }
+ const previousItems = new Map(prevRow.window.items.map((item) => [item.id, item]));
+ let reused = false;
+ const items = row.window.items.map((item) => {
+ const previousItem = previousItems.get(item.id);
+ if (previousItem === undefined || !sameWorkingWindowItem(previousItem, item)) return item;
+ reused = true;
+ return previousItem;
+ });
+ return reused ? { ...row, window: { ...row.window, items } } : row;
+}
+
function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean {
if (a.kind !== b.kind || a.id !== b.id) return false;
+ if (a.continuesPromptStack !== b.continuesPromptStack) return false;
switch (a.kind) {
+ case "background-activity": {
+ const bg = b as typeof a;
+ return (
+ a.createdAt === bg.createdAt &&
+ a.updateCount === bg.updateCount &&
+ shallow(a.updates, bg.updates) &&
+ a.active === bg.active &&
+ a.expanded === bg.expanded &&
+ shallow(a.runIds, bg.runIds)
+ );
+ }
case "working":
return (
- a.createdAt === (b as typeof a).createdAt && a.compacting === (b as typeof a).compacting
+ a.createdAt === (b as typeof a).createdAt &&
+ sameWorkingWindow(a.window, (b as typeof a).window) &&
+ a.compacting === (b as typeof a).compacting
);
case "thinking": {
const bt = b as typeof a;
@@ -2582,17 +3479,31 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
);
}
- case "attempt-fold":
case "turn-fold": {
const bf = b as typeof a;
- const anchors = a.deferredWorkItemIds ?? [];
- const nextAnchors = bf.deferredWorkItemIds ?? [];
+ return (
+ a.createdAt === bf.createdAt &&
+ a.label === bf.label &&
+ a.ending === bf.ending &&
+ sameDiffLineStat(a.diff, bf.diff) &&
+ a.expanded === bf.expanded &&
+ a.foldable === bf.foldable &&
+ a.failureMessage === bf.failureMessage &&
+ a.noticeMessage === bf.noticeMessage &&
+ a.resetAt === bf.resetAt &&
+ (a.deferredWorkItemIds ?? []).length === (bf.deferredWorkItemIds ?? []).length &&
+ (a.deferredWorkItemIds ?? []).every((id, index) => id === bf.deferredWorkItemIds?.[index])
+ );
+ }
+
+ case "attempt-fold": {
+ const bf = b as typeof a;
return (
a.createdAt === bf.createdAt &&
a.label === bf.label &&
a.expanded === bf.expanded &&
- anchors.length === nextAnchors.length &&
- anchors.every((id, index) => id === nextAnchors[index])
+ (a.deferredWorkItemIds ?? []).length === (bf.deferredWorkItemIds ?? []).length &&
+ (a.deferredWorkItemIds ?? []).every((id, index) => id === bf.deferredWorkItemIds?.[index])
);
}
@@ -2692,12 +3603,39 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
a.revertTurnCount === bm.revertTurnCount &&
a.groupedWithNext === bm.groupedWithNext &&
a.groupedWithPrevious === bm.groupedWithPrevious &&
- a.continuesSteer === bm.continuesSteer
+ shallow(a.command, bm.command)
);
}
}
}
+/** The working bar's short name for a pending approval, like "Approve git push". */
+export function pendingApprovalSlotLabel(approval: {
+ readonly requestKind: string;
+ readonly detail?: string | undefined;
+ readonly appName?: string | undefined;
+}): string {
+ switch (approval.requestKind) {
+ case "command": {
+ const words = (approval.detail ?? "")
+ .trim()
+ .split(/\s+/u)
+ .filter((word) => word.length > 0 && !/^[A-Z_][A-Z0-9_]*=/u.test(word))
+ .slice(0, 2)
+ .join(" ");
+ return words ? `Approve ${words}` : "Approve command";
+ }
+ case "file-read":
+ return "Approve file read";
+ case "file-change":
+ return "Approve file change";
+ case "mcp-elicitation":
+ return approval.appName ? `Approve ${approval.appName}` : "Approve app access";
+ default:
+ return "Approve permission";
+ }
+}
+
export function handoffRunsSignature(runs: ReadonlyArray): string {
return runs
.map(
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 09dd285d1d414..6d0a9a72d9b7e 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -1,15 +1,58 @@
+import { ViewedImagesTimelineRow } from "./TimelineWorkEntryRow";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import type { ToolCallDiffTarget } from "../../diffPanelStore";
import { AudioPreviewUnmountContext } from "../files/AudioPreview";
+import {
+ ActivityShimmerOverlay,
+ WORKING_BAR_CLASS_NAME,
+ WorkFoldToggle,
+ WorkingBarChip,
+} from "./TimelineRowParts";
+import {
+ AssistantMetaTimelineRow,
+ AssistantTimelineRow,
+ HtmlRenderTimelineRow,
+ McpAppTimelineRow,
+ ProposedPlanTimelineRow,
+} from "./TimelineAssistantRows";
+import {
+ type TimelinePendingRequest,
+ type TimelineRow,
+ TimelineRowActivityCtx,
+ type TimelineRowActivityState,
+ TimelineRowCtx,
+ type TimelineRowSharedState,
+ type TimelineUsageLimit,
+ type WorkGroupViewState,
+} from "./timelineRowContext";
+import {
+ BackgroundActivityTimelineRow,
+ LiveWorkEntryTimelineRow,
+ SlashCommandTimelineRow,
+ ThinkingTimelineRow,
+ WorkGroupSection,
+ WorkGroupToggleTimelineRow,
+ WorkingTimelineRow,
+} from "./TimelineWorkGroupRows";
+import {
+ useIncrementalTimelineRows,
+ useStableHandoffRuns,
+ useStableRows,
+ useTimelineSlashCommandCatalog,
+} from "./useTimelineRows";
+import { startTimelinePositionRestore } from "./useTimelinePositionRestore";
+import { UserMessageRow } from "./TimelineUserMessageRow";
+import { useThreadFindPositionReader } from "./useThreadFindPositionReader";
+import { V2EventTimelineRow } from "./TimelineV2EventRow";
+import { TimelineMinimap } from "./TimelineMinimap";
import { ThreadFindTimelineContext } from "./ThreadFindProvider";
import { MarkdownFindContext } from "./markdownFindContext";
import { useChatCanvas } from "./ChatCanvasContext";
import { WorkLogBlock } from "./WorkLog";
import { TimelinePendingApprovalRow } from "./TimelinePendingApprovalRow";
-import type { WorktreeSetupSnapshot } from "@t3tools/contracts";
-import { deriveTimelineMinimapItems } from "./timelineMinimapItems";
import {
+ type WorktreeSetupSnapshot,
type AssistantCitation,
type EnvironmentId,
type MessageId,
@@ -19,27 +62,21 @@ import {
type RunId,
ThreadId,
} from "@t3tools/contracts";
+import { deriveTimelineMinimapItems } from "./timelineMinimapItems";
import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
-import type { WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime";
+import { type WorkflowOpening } from "@t3tools/client-runtime/state/subagentRuntime";
import { observeResize } from "~/lib/observeResize";
import { useStableCallback } from "~/hooks/useStableCallback";
-
-const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
-const EMPTY_PENDING_APPROVALS: ReadonlyArray = [];
-const EMPTY_RESPONDING_REQUEST_IDS: TimelineRowSharedState["respondingRequestIds"] = [];
-
-const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
-
import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
import { observeVisibleAnimation } from "../../lib/visibleAnimation";
import {
memo,
use,
useContext,
- useLayoutEffect,
useEffect,
useEffectEvent,
+ useLayoutEffect,
useRef,
useState,
type ReactNode,
@@ -51,16 +88,21 @@ import {
type MaintainScrollAtEndOptions,
} from "@legendapp/list/react";
import { type PendingApproval, type TimelineEntry } from "../../session-logic";
-import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
+import { type CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
+
+const EMPTY_PENDING_APPROVALS: ReadonlyArray = [];
+const EMPTY_RESPONDING_REQUEST_IDS: TimelineRowSharedState["respondingRequestIds"] = [];
import { type ChatFileAttachment, type TurnDiffSummary } from "../../types";
import {
ArrowRightIcon,
BotIcon,
- CircleAlertIcon,
- MessagesSquareIcon,
Minimize2Icon,
ChevronDownIcon,
ChevronRightIcon,
+ CircleAlertIcon,
+ Clock3Icon,
+ MessageSquareIcon,
+ MessagesSquareIcon,
} from "lucide-react";
import { Skeleton } from "../ui/skeleton";
import { ExpandedImagePreview } from "./ExpandedImagePreview";
@@ -72,7 +114,7 @@ import {
resolveDisclosureRevealScrollDelta,
} from "./timelineScrollAnchoring";
import { AssistantSelectionToolbar } from "./AssistantSelectionToolbar";
-import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
+import { type AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection";
import { type AssistantCitationRequest } from "./AssistantCitationSource";
import { useAssistantCitationTarget } from "./useAssistantCitationTarget";
import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
@@ -88,6 +130,10 @@ import {
liveAnswerPreview,
type MessagesTimelineRow,
type TimelineLatestRun,
+ turnFoldStartsOpen,
+ withRunGroupsOpen,
+ withTurnFoldOpen,
+ liveRunlessTurnFoldRunId,
} from "./MessagesTimeline.logic";
import { type ThreadFindMatch } from "./threadFind";
import { useThreadFindNavigation } from "./useThreadFindNavigation";
@@ -99,47 +145,24 @@ import { cn } from "~/lib/utils";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { type HandoffTimelineRun } from "./V2LifecycleRow";
import { TimelineSystemDivider } from "./TimelineSystemDivider";
-
import { forwardWheelToTimeline } from "./timelineMinimapWheel";
-import { TimelineMinimap } from "./TimelineMinimap";
-import {
- type TimelineRowSharedState,
- type TimelineRowActivityState,
- TimelineRowCtx,
- TimelineRowActivityCtx,
- type WorkGroupViewState,
- type TimelineRow,
-} from "./timelineRowContext";
-import { TimelineRowTimestamp, ActivityShimmerOverlay } from "./TimelineRowParts";
-import {
- WorkGroupSection,
- WorkingTimelineRow,
- ThinkingTimelineRow,
- LiveWorkEntryTimelineRow,
- WorkGroupToggleTimelineRow,
-} from "./TimelineWorkGroupRows";
-import { ViewedImagesTimelineRow } from "./TimelineWorkEntryRow";
-import { UserMessageRow } from "./TimelineUserMessageRow";
-import {
- AssistantTimelineRow,
- AssistantMetaTimelineRow,
- ProposedPlanTimelineRow,
- HtmlRenderTimelineRow,
- McpAppTimelineRow,
-} from "./TimelineAssistantRows";
-import { V2EventTimelineRow } from "./TimelineV2EventRow";
-import { startTimelinePositionRestore } from "./useTimelinePositionRestore";
import {
attachTimelineViewport,
nextListOpening,
type TimelineListOpening,
type TimelineViewport,
} from "./timelineViewport";
-import { useThreadFindPositionReader } from "./useThreadFindPositionReader";
import { ThreadActivityAnnouncer } from "./ThreadActivityAnnouncer";
-import { useStableHandoffRuns, useIncrementalTimelineRows, useStableRows } from "./useTimelineRows";
+import { formatUpcomingTimestamp } from "../../timestampFormat";
+import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty";
+import { DiffStatBlocks } from "./DiffStatLabel";
+import { useNowMinute } from "../../hooks/useNowMinute";
+import { useDeadlinePassed } from "../../hooks/useDeadline";
import { useClientSettings } from "../../hooks/useSettings";
+const NOOP_USE_ARTIFACT_TEMPLATE = () => {};
+const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {};
+
function timelineListStateFor(identity: string): {
readonly identity: string;
readonly rememberedPosition: RememberedTimelinePosition | undefined;
@@ -245,6 +268,13 @@ interface MessagesTimelineProps {
awaitingUser?: "approval" | "input" | null;
/** Which request is waiting, so a second one of the same kind is announced too. */
awaitingRequestId?: string | null;
+ pendingRequest?: TimelinePendingRequest | null;
+ usageLimit?: TimelineUsageLimit | null;
+ /** The composer banner's error; a failed fold does not repeat it. */
+ threadErrorMessage?: string | null;
+ /** Takes the user to a pending question in the composer. */
+ onFocusComposer?: () => void;
+
footer?: ReactNode;
listRef: React.RefObject;
@@ -302,6 +332,8 @@ interface MessagesTimelineProps {
timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
skills?: ReadonlyArray;
+ /** The provider's slash commands. With `skills`, they decide which prompts are commands. */
+ slashCommands?: ReadonlyArray<{ readonly name: string }>;
runs: ReadonlyArray;
anchorMessageId: MessageId | null;
onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
@@ -363,6 +395,11 @@ const ConversationTimeline = memo(function ConversationTimeline({
isCompacting = false,
awaitingUser = null,
awaitingRequestId = null,
+ pendingRequest: pendingRequestProp = null,
+ usageLimit = null,
+ threadErrorMessage = null,
+ onFocusComposer,
+
listRef,
timelineEntries,
findOpen = false,
@@ -403,6 +440,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
timestampFormat,
workspaceRoot,
skills = EMPTY_TIMELINE_SKILLS,
+ slashCommands = EMPTY_TIMELINE_SLASH_COMMANDS,
runs: runsProp,
anchorMessageId,
onAnchorReady,
@@ -417,6 +455,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
topFadeEnabled = false,
historyControls,
}: MessagesTimelineProps) {
+ const pendingRequest = pendingRequestProp?.blocking === false ? null : pendingRequestProp;
const listIdentityKey = displayThreadKey ?? routeThreadKey;
// Snapshot restore positions on identity changes; ongoing scroll writes must not move them.
// Find open at the opening is part of the snapshot: opening find later
@@ -428,6 +467,8 @@ const ConversationTimeline = memo(function ConversationTimeline({
const [expandedRunIds, setExpandedRunIds] = useState>(
() => listState.rememberedPosition?.disclosures?.runs ?? new Set(),
);
+ const [openHistoryRunId, setOpenHistoryRunId] = useState(null);
+ const [foldRevealBaseIds, setFoldRevealBaseIds] = useState | null>(null);
const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(
() => listState.rememberedPosition?.disclosures?.workGroups ?? new Set(),
);
@@ -466,6 +507,8 @@ const ConversationTimeline = memo(function ConversationTimeline({
// A viewer pinned in another thread must not keep live follow off here,
// nor when that thread comes back.
setFullscreenAppRowId(null);
+ setOpenHistoryRunId(null);
+ setFoldRevealBaseIds(null);
paintedExpandedRunIds = rememberedPosition?.disclosures?.runs ?? new Set();
paintedExpandedWorkGroupIds = rememberedPosition?.disclosures?.workGroups ?? new Set();
paintedExpandedAttemptIds = rememberedPosition?.disclosures?.attempts ?? new Set();
@@ -476,6 +519,9 @@ const ConversationTimeline = memo(function ConversationTimeline({
const citationThreadRef = parseScopedThreadKey(routeThreadKey);
const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined);
const [minimapStripMap] = useState(() => new Map());
+ // The open history belongs to the prompt that opened it; sending closes it.
+ const [workingHistoryKey, setWorkingHistoryKey] = useState(null);
+ const [workingHistoryCollapsing, setWorkingHistoryCollapsing] = useState(false);
const normalizedFindQuery = findQuery.trim();
const findActive = findExpanded;
@@ -552,9 +598,16 @@ const ConversationTimeline = memo(function ConversationTimeline({
list.anchorVisibleContent({ key: anchorKey });
};
+ const revealFoldRows = (opening: boolean) =>
+ setFoldRevealBaseIds(
+ opening && !prefersReducedMotion ? new Set(rows.map((row) => row.id)) : null,
+ );
// Turn and attempt folds reveal later rows rather than growing their own row.
const onToggleTurnFold = (runId: RunId) => {
preserveDisclosurePosition(`turn-fold:${runId}`);
+ revealFoldRows(
+ rows.some((row) => row.kind === "turn-fold" && row.runId === runId && !row.expanded),
+ );
setExpandedRunIds((existing) => {
const next = new Set(existing);
if (next.has(runId)) {
@@ -570,6 +623,8 @@ const ConversationTimeline = memo(function ConversationTimeline({
anchorKey,
expandedWorkGroupIds.has(groupId) ? undefined : { contentKey: `${groupId}:details` },
);
+ const wakeUps = rows.find((row) => row.kind === "background-activity" && row.id === groupId);
+ if (wakeUps?.kind === "background-activity") revealFoldRows(!wakeUps.expanded);
setExpandedWorkGroupIds((existing) => {
const next = new Set(existing);
if (next.has(groupId)) {
@@ -580,6 +635,15 @@ const ConversationTimeline = memo(function ConversationTimeline({
return next;
});
};
+ const onToggleWorkingHistory = (key: string) => {
+ // Either way the bar holds still: the history grows and shrinks below it.
+ const list = listRef.current;
+ if (list) {
+ list.cancelScroll();
+ list.anchorVisibleContent({ key: "working-indicator-row" });
+ }
+ setWorkingHistoryKey((current) => (current === key ? null : key));
+ };
const onToggleAttemptFold = (attemptId: RunAttemptId) => {
preserveDisclosurePosition(`attempt-fold:${attemptId}`);
setExpandedAttemptIds((existing) => {
@@ -592,44 +656,6 @@ const ConversationTimeline = memo(function ConversationTimeline({
return next;
});
};
-
- // An interrupted run opens its fold; a newer run closes the previous run's.
- const [observedLatestRun, setObservedLatestRun] = useState({
- identity: listIdentityKey,
- runId: latestRun?.runId,
- status: latestRun?.status,
- });
- if (
- observedLatestRun.identity !== listIdentityKey ||
- observedLatestRun.runId !== latestRun?.runId ||
- observedLatestRun.status !== latestRun?.status
- ) {
- setObservedLatestRun({
- identity: listIdentityKey,
- runId: latestRun?.runId,
- status: latestRun?.status,
- });
- const previousRunId = observedLatestRun.runId;
- if (
- observedLatestRun.identity === listIdentityKey &&
- latestRun &&
- previousRunId !== undefined
- ) {
- if (latestRun.runId === previousRunId) {
- if (observedLatestRun.status === "running" && latestRun.status === "interrupted") {
- setExpandedRunIds((existing) => new Set(existing).add(latestRun.runId));
- }
- } else {
- setExpandedRunIds((existing) => {
- if (!existing.has(previousRunId)) return existing;
- const next = new Set(existing);
- next.delete(previousRunId);
- return next;
- });
- }
- }
- }
-
const preferredToolCallVisibility = useClientSettings((settings) => settings.toolCallVisibility);
const screenReaderAnnouncements = useClientSettings(
(settings) => settings.screenReaderAnnouncements,
@@ -637,78 +663,40 @@ const ConversationTimeline = memo(function ConversationTimeline({
const toolCallVisibility =
findActive && preferredToolCallVisibility === "zen" ? "hideAll" : preferredToolCallVisibility;
const revealedMessageId = citationRequest?.citation.messageId ?? null;
- // Imported turns and post-steer work fold under synthetic keys, not the entries' run ids.
- const findFoldRunIds = findActive
- ? timelineTurnFoldRunIdsByEntryId({
- timelineEntries,
- latestRun,
- runningRunId,
- isWorking,
- runlessWorkActive,
- toolCallVisibility,
- revealedMessageId,
- })
- : null;
- const activeFindRunId =
- findActive && activeFindMatch ? findFoldRunIds?.get(activeFindMatch.entryId) : undefined;
- const [expandedFindRunId, setExpandedFindRunId] = useState();
- if (expandedFindRunId !== activeFindRunId) {
- setExpandedFindRunId(activeFindRunId);
- if (activeFindRunId) {
- setExpandedRunIds((previous) =>
- previous.has(activeFindRunId) ? previous : new Set(previous).add(activeFindRunId),
- );
- }
- }
- // A cited response folded inside a turn opens its fold once per activation;
- // the user can fold it again. Only a citation that can still land opens one.
- // The marker stops the O(entries) fold lookup once the entry is found, since
- // the href stays in the URL after the citation settles.
- const [citationExpandedFor, setCitationExpandedFor] = useState(null);
- // Back/Forward can restore an earlier activation key, so the marker clears
- // between activations and a revisit opens the fold again.
- if (!citationRequest && citationExpandedFor !== null) setCitationExpandedFor(null);
- const citedEntry =
- citationRequest &&
- citationExpandedFor !== citationRequest.key &&
- citationHistory._tag !== "failed"
- ? timelineEntries.find(
- (entry) =>
- entry.kind === "message" &&
- entry.message.role === "assistant" &&
- entry.message.id === citationRequest.citation.messageId,
- )
- : undefined;
- if (citationRequest && citedEntry) {
- const citedRunId = (
- findFoldRunIds ??
- timelineTurnFoldRunIdsByEntryId({
- timelineEntries,
- latestRun,
- runningRunId,
- isWorking,
- runlessWorkActive,
- toolCallVisibility,
- revealedMessageId,
- })
- ).get(citedEntry.id);
- setCitationExpandedFor(citationRequest.key);
- if (citedRunId) {
- setExpandedRunIds((previous) =>
- previous.has(citedRunId) ? previous : new Set(previous).add(citedRunId),
- );
- }
- const citedAttempt = citedEntry.attempt;
- if (citedAttempt?.status === "superseded") {
- setExpandedAttemptIds((previous) =>
- previous.has(citedAttempt.id) ? previous : new Set(previous).add(citedAttempt.id),
- );
+ // Find the fold that hides the match: imported turns have no run id but still
+ // fold under a synthetic key, so the match's own run id cannot open them.
+ const findFoldRunIds = (() =>
+ findActive
+ ? timelineTurnFoldRunIdsByEntryId({
+ timelineEntries,
+ latestRun,
+ runningRunId,
+ isWorking,
+ runlessWorkActive,
+ toolCallVisibility,
+ revealedMessageId,
+ })
+ : null)();
+ const activeFindFold = activeFindMatch ? findFoldRunIds?.get(activeFindMatch.entryId) : undefined;
+ const activeFindFoldRunId = activeFindFold?.runId;
+ const activeFindFoldEnding = activeFindFold?.ending;
+ const [expandedFindFold, setExpandedFindFold] = useState();
+ if (
+ expandedFindFold?.runId !== activeFindFoldRunId ||
+ expandedFindFold?.ending !== activeFindFoldEnding
+ ) {
+ setExpandedFindFold(activeFindFold);
+ if (activeFindFold) {
+ setExpandedRunIds((previous) => withTurnFoldOpen(previous, activeFindFold));
}
}
- const visibleExpandedRunIds =
- !activeFindRunId || paintedExpandedRunIds.has(activeFindRunId)
- ? paintedExpandedRunIds
- : new Set(paintedExpandedRunIds).add(activeFindRunId);
+ const visibleExpandedRunIds = (() =>
+ activeFindFoldRunId && activeFindFoldEnding
+ ? withTurnFoldOpen(paintedExpandedRunIds, {
+ runId: activeFindFoldRunId,
+ ending: activeFindFoldEnding,
+ })
+ : paintedExpandedRunIds)();
const activeFindAttemptId = activeFindMatch
? timelineEntries.find((entry) => entry.id === activeFindMatch.entryId)?.attempt?.id
@@ -732,6 +720,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
const pendingApprovalRequestIds = new Set(
pendingApprovals.filter(isVisiblePendingApproval).map((approval) => approval.requestId),
);
+ const slashCommandCatalog = useTimelineSlashCommandCatalog(slashCommands, skills);
const rawRows = useIncrementalTimelineRows(`${listIdentityKey}\0${workspaceRoot ?? ""}`, {
timelineEntries,
pendingApprovalRequestIds,
@@ -750,8 +739,145 @@ const ConversationTimeline = memo(function ConversationTimeline({
worktreeSetup,
toolCallVisibility,
revealedMessageId,
+ slashCommands: slashCommandCatalog,
+ });
+ // A find match or citation inside the running turn needs its rows, not the window that replaces them.
+ const [windowlessCitationKey, setWindowlessCitationKey] = useState(null);
+ const windowlessCitation =
+ citationRequest !== null && citationRequest.key === windowlessCitationKey;
+ const activeFindEntryId = activeFindMatch?.entryId ?? null;
+ const rows = useStableRows(rawRows, listIdentityKey, {
+ timelineEntries,
+ pendingApprovalRequestIds,
+ latestRun,
+ runningRunId,
+ expandedWorkGroupIds,
+ hasPendingRequest: pendingRequest !== null,
+ hasThreadError: threadErrorMessage !== null,
+ isWorking,
+ runlessWorkActive,
+ toolCallVisibility,
+ revealedMessageId,
+ workingHistoryKey,
+ windowlessCitation,
+ activeFindEntryId,
});
- const rows = useStableRows(rawRows, listIdentityKey);
+ const workingHistoryOpen = rows.some(
+ (row) => row.kind === "working" && row.window?.historyOpen === true,
+ );
+ if (rows.some((row) => row.kind === "working")) {
+ const historyRunId = workingHistoryOpen
+ ? ((runlessWorkActive ? liveRunlessTurnFoldRunId(timelineEntries) : null) ??
+ latestRun?.runId ??
+ null)
+ : null;
+ if (historyRunId !== openHistoryRunId) setOpenHistoryRunId(historyRunId);
+ } else if (openHistoryRunId !== null) {
+ const fold = rows.find((row) => row.kind === "turn-fold" && row.runId === openHistoryRunId);
+ if (fold?.kind === "turn-fold") {
+ setOpenHistoryRunId(null);
+ setExpandedRunIds((existing) => withTurnFoldOpen(existing, fold));
+ }
+ }
+ // Opens whatever hides a cited response. False when nothing does, so the citation stops waiting.
+ const revealCitedEntry = (entryId: string, runId: RunId | null) => {
+ const fold = timelineTurnFoldRunIdsByEntryId({
+ timelineEntries,
+ latestRun,
+ runningRunId,
+ isWorking,
+ runlessWorkActive,
+ toolCallVisibility,
+ revealedMessageId,
+ }).get(entryId);
+ const runs = fold ? withTurnFoldOpen(expandedRunIds, fold) : expandedRunIds;
+ const groups = runId
+ ? withRunGroupsOpen(expandedWorkGroupIds, rows, runId)
+ : expandedWorkGroupIds;
+ const attemptId = timelineEntries.find((entry) => entry.id === entryId)?.attempt?.id;
+ const attempts =
+ attemptId && !expandedAttemptIds.has(attemptId)
+ ? new Set(expandedAttemptIds).add(attemptId)
+ : expandedAttemptIds;
+ const windowed =
+ citationRequest !== null &&
+ rows.some(
+ (row) => row.kind === "working" && row.window?.items.some((item) => item.id === entryId),
+ );
+ if (
+ runs === expandedRunIds &&
+ groups === expandedWorkGroupIds &&
+ attempts === expandedAttemptIds &&
+ !windowed
+ ) {
+ return false;
+ }
+ setExpandedRunIds(runs);
+ setExpandedWorkGroupIds(groups);
+ setExpandedAttemptIds(attempts);
+ if (windowed) setWindowlessCitationKey(citationRequest.key);
+ return true;
+ };
+ // Reveal nested folds before resolving the citation phase; later refolding stays manual.
+ const [citationExpandedFor, setCitationExpandedFor] = useState(null);
+ if (!citationRequest && citationExpandedFor !== null) setCitationExpandedFor(null);
+ const citedEntry =
+ citationRequest &&
+ citationExpandedFor !== citationRequest.key &&
+ citationHistory._tag !== "failed"
+ ? timelineEntries.find(
+ (entry) =>
+ entry.kind === "message" &&
+ entry.message.role === "assistant" &&
+ entry.message.id === citationRequest.citation.messageId,
+ )
+ : undefined;
+ if (citationRequest && citedEntry?.kind === "message") {
+ if (!revealCitedEntry(citedEntry.id, citedEntry.message.runId ?? null)) {
+ setCitationExpandedFor(citationRequest.key);
+ }
+ }
+ // The next turn folds the previous one back, even if the user opened it. A
+ // failed or limited fold starts open, so its key is the user's collapse.
+ // Raw rows still hold folds that an update group collapsed away.
+ const [observedLatestRun, setObservedLatestRun] = useState({
+ identity: listIdentityKey,
+ runId: latestRun?.runId,
+ });
+ if (
+ observedLatestRun.identity !== listIdentityKey ||
+ observedLatestRun.runId !== latestRun?.runId
+ ) {
+ setObservedLatestRun({ identity: listIdentityKey, runId: latestRun?.runId });
+ const previousRunId = observedLatestRun.runId;
+ if (
+ observedLatestRun.identity === listIdentityKey &&
+ latestRun &&
+ previousRunId !== undefined &&
+ latestRun.runId !== previousRunId &&
+ !rawRows.some(
+ (row) =>
+ row.kind === "turn-fold" && row.runId === previousRunId && turnFoldStartsOpen(row.ending),
+ )
+ ) {
+ setExpandedRunIds((existing) => {
+ if (!existing.has(previousRunId)) return existing;
+ const next = new Set(existing);
+ next.delete(previousRunId);
+ return next;
+ });
+ }
+ }
+ // A match inside a folded "Handled N updates" group opens the group.
+ const activeFindRunId = activeFindMatch?.runId ?? null;
+ const findGroupKey = `${activeFindRunId ?? ""}:${activeFindEntryId ?? ""}`;
+ const [expandedFindGroupKey, setExpandedFindGroupKey] = useState(null);
+ if (expandedFindGroupKey !== findGroupKey) {
+ setExpandedFindGroupKey(findGroupKey);
+ if (activeFindRunId) {
+ setExpandedWorkGroupIds((current) => withRunGroupsOpen(current, rows, activeFindRunId));
+ }
+ }
const matchedApprovalRequestIds = new Set();
for (const row of pendingApprovalRequestIds.size > 0 ? rows : []) {
const entries =
@@ -1142,11 +1268,14 @@ const ConversationTimeline = memo(function ConversationTimeline({
onRetryWorkspacePreparation: onRetryWorkspacePreparation ?? null,
onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocally ?? null,
onOpenWorktreeSetupTerminal: onOpenWorktreeSetupTerminal ?? null,
+ onToggleWorkingHistory,
+ onWorkingHistoryCollapsing: setWorkingHistoryCollapsing,
+ onFocusComposer: onFocusComposer ?? null,
workGroupViewState,
};
const compactionAwaitingRow =
isCompacting &&
- !rows.some(
+ !rawRows.some(
(row) =>
(row.kind === "context-compaction" && row.active) ||
(row.kind === "working" && row.compacting),
@@ -1163,10 +1292,15 @@ const ConversationTimeline = memo(function ConversationTimeline({
isCompacting: compactionAwaitingRow,
isRevertingCheckpoint,
backgroundWorktreeSetup,
+ turnEnded: latestRun?.turnEnded === true,
+ activeTurnStartedAt,
awaitingUser: awaitingUser !== null,
isPreparingWorktree,
providerStartingLabel,
latestRunId: latestRun?.runId ?? null,
+ pendingRequest: isWorking ? pendingRequest : null,
+ usageLimit,
+ threadErrorMessage,
liveAnswerPreview:
isWorking && toolCallVisibility === "zen" ? liveAnswerPreview(timelineEntries) : null,
};
@@ -1337,6 +1471,10 @@ const ConversationTimeline = memo(function ConversationTimeline({
{
+ if (event.target === event.currentTarget) setFoldRevealBaseIds(null);
+ }}
onPlayCapture={(event) => onAudioPlaybackChange(item.id, event)}
onPauseCapture={(event) => onAudioPlaybackChange(item.id, event)}
onEndedCapture={(event) => onAudioPlaybackChange(item.id, event)}
@@ -1394,10 +1532,18 @@ const ConversationTimeline = memo(function ConversationTimeline({
<>
{activityAnnouncer}
-
Send a message to start the conversation.
+
+
+
+
+
+ No messages yet
+ Send a message to start the conversation.
+
+
>
);
@@ -1442,7 +1588,9 @@ const ConversationTimeline = memo(function ConversationTimeline({
(restoringThreadPosition && rememberedPosition?.atEnd === false) ||
anchoredEndSpace ||
!liveFollowEnabled ||
- fullscreenAppRowId !== null
+ fullscreenAppRowId !== null ||
+ workingHistoryOpen ||
+ workingHistoryCollapsing
? false
: isWorking && !prefersReducedMotion && settlingListIdentity === null
? TIMELINE_MAINTAIN_SCROLL_AT_END_SMOOTH
@@ -1586,7 +1734,9 @@ function getItemType(item: MessagesTimelineRow) {
const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: TimelineRow }) {
const isExpandedToolGroup = row.kind === "work" && row.isExpandedToolGroup;
const isSubagentGroup = row.kind === "event" && row.projectedItem.item.type === "subagent";
+ const command = row.kind === "message" && row.command?.standalone ? row.command : null;
const isWorkLogRow =
+ command !== null ||
row.kind === "work" ||
row.kind === "work-live" ||
row.kind === "work-toggle" ||
@@ -1600,21 +1750,25 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
className={cn(
isWorkLogRow || isSubagentGroup
? undefined
- : row.kind === "turn-fold" || row.kind === "working"
+ : row.kind === "turn-fold" || row.kind === "working" || row.kind === "background-activity"
? "pb-1.5"
: row.kind === "message" && row.groupedWithNext
? "pb-1"
- : (row.kind === "message" &&
- row.message.role === "assistant" &&
- !row.showAssistantMeta) ||
- row.kind === "worktree-setup" ||
- row.kind === "event" ||
- row.kind === "attempt-fold" ||
- row.kind === "html-render" ||
- row.kind === "mcp-app" ||
- row.kind === "viewed-images"
- ? "pb-2"
- : "pb-4",
+ : row.kind === "message" && row.message.role === "user"
+ ? row.continuesPromptStack
+ ? "pb-2"
+ : "pb-3"
+ : (row.kind === "message" &&
+ row.message.role === "assistant" &&
+ !row.showAssistantMeta) ||
+ row.kind === "worktree-setup" ||
+ row.kind === "event" ||
+ row.kind === "attempt-fold" ||
+ row.kind === "html-render" ||
+ row.kind === "mcp-app" ||
+ row.kind === "viewed-images"
+ ? "pb-2"
+ : "pb-4",
(row.kind === "message" && row.message.role === "assistant") ||
row.kind === "assistant-meta"
? "group/assistant"
@@ -1649,12 +1803,16 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
{row.kind === "work-live" ?
: null}
{row.kind === "work-toggle" ?
: null}
{row.kind === "thinking" ?
: null}
+ {command ?
: null}
) : null}
{row.kind === "turn-fold" ?
: null}
+ {row.kind === "background-activity" ?
: null}
{row.kind === "attempt-fold" ?
: null}
{row.kind === "context-compaction" ?
: null}
- {row.kind === "message" && row.message.role === "user" ?
: null}
+ {row.kind === "message" && row.message.role === "user" && command === null ? (
+
+ ) : null}
{row.kind === "message" && row.message.role === "assistant" ? (
) : null}
@@ -1727,7 +1885,7 @@ function ContextCompactionTimelineRow({
role="separator"
aria-label={row.failure ? `${label}: ${row.failure}` : label}
className={cn(
- "mx-auto flex w-full max-w-(--chat-content-max-width) items-center gap-3 py-1 text-xs",
+ "mx-auto flex w-full max-w-(--chat-content-max-width) items-center gap-3 py-1 text-meta",
row.failed ? "text-destructive" : "text-muted-foreground",
)}
>
@@ -1804,35 +1962,60 @@ function TurnFoldTimelineRow({ row }: { row: Extract
ctx.onToggleTurnFold(row.runId),
);
+ const { usageLimit, latestRunId, threadErrorMessage } = use(TimelineRowActivityCtx);
+ const isLatestRun = latestRunId === row.runId;
+ // One line says why the run failed: its error, unless the composer banner
+ // already does, then the provider notice that carries the limit's reset.
+ const failureMessage =
+ row.failureMessage !== null && !(isLatestRun && row.failureMessage === threadErrorMessage)
+ ? row.failureMessage
+ : row.noticeMessage;
+ const limit = row.ending === "limited" && usageLimit?.runId === row.runId ? usageLimit : null;
return (
-
-
-
+
+
+ {row.ending === "failed" ? (
+
+ ) : null}
{row.label}
{error ? {error} : null}
- {row.expanded ? (
-
- ) : (
-
- )}
-
-
+
+
+ {limit?.resetAt ? (
+
+ ) : null}
+ {row.diff ? (
+
+ ) : null}
{loading ? : null}
+ {row.ending === "stopped" ? (
+
+ {row.diff ? "Stopped by you. Changes so far are kept." : "Stopped by you."}
+
+ ) : row.ending === "limited" && row.resetAt ? (
+
+ ) : failureMessage ? (
+
+ {failureMessage}
+
+ ) : null}
);
}
@@ -1869,3 +2052,57 @@ function AttemptFoldTimelineRow({ row }: { row: Extract
);
}
+
+const EMPTY_TIMELINE_SLASH_COMMANDS: ReadonlyArray<{ readonly name: string }> = [];
+export type { TimelinePendingRequest, TimelineUsageLimit } from "./timelineRowContext";
+
+function TurnFoldRetryNotice({
+ message,
+ resetAt,
+ timestampFormat,
+}: {
+ message: string | null;
+ resetAt: string;
+ timestampFormat: TimestampFormat;
+}) {
+ const nowMinute = useNowMinute();
+ const nowMs = Date.parse(`${nowMinute}:00Z`);
+ const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const resetTime = formatUpcomingTimestamp(resetAt, timestampFormat, nowMs, timeZone);
+ return (
+
+ {message ? `${message} ` : ""}Retry after {resetTime} ({timeZone}).
+
+ );
+}
+
+function TurnFoldUsageLimit({
+ limit,
+ timestampFormat,
+}: {
+ limit: TimelineUsageLimit;
+ timestampFormat: TimestampFormat;
+}) {
+ const nowMinute = useNowMinute();
+ const nowMs = Date.parse(`${nowMinute}:00Z`);
+ const resetPassed = useDeadlinePassed(limit.resetAt === null ? null : Date.parse(limit.resetAt));
+ if (!limit.resetAt || resetPassed || !(Date.parse(limit.resetAt) > nowMs)) return null;
+ const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
+ return (
+ <>
+
+ resumes {formatUpcomingTimestamp(limit.resetAt, timestampFormat, nowMs, timeZone)} (
+ {timeZone})
+
+ {limit.onToggleSnooze ? (
+ }
+ aria-pressed={limit.snoozed}
+ onClick={limit.onToggleSnooze}
+ >
+ {limit.snoozed ? "Snoozed" : "Snooze"}
+
+ ) : null}
+ >
+ );
+}
diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx
index da23b090ec0ff..f70d321ffc184 100644
--- a/apps/web/src/components/chat/ModelPickerContent.tsx
+++ b/apps/web/src/components/chat/ModelPickerContent.tsx
@@ -59,6 +59,7 @@ import { cn } from "~/lib/utils";
import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
import { TooltipProvider } from "../ui/tooltip";
import { InlineButton } from "../ui/button";
+import { RELAXED_PICKER_CLASS } from "../ui/picker-styles";
import {
isProviderInstancePickerReady,
isProviderInstancePickerVisible,
@@ -838,6 +839,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
ref={pickerContentRef}
className={cn(
"relative flex w-screen max-w-90 flex-row overflow-hidden",
+ RELAXED_PICKER_CLASS,
props.docked ? "max-sm:h-56 max-sm:w-full max-sm:max-w-none" : "max-h-86.5",
)}
// Docked, the popup sets the height; a minimum here would overflow short windows.
@@ -919,6 +921,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
)}
>
{label}
- {value === preferredEditor && openFavoriteEditorShortcutLabel && (
- {openFavoriteEditorShortcutLabel}
+ {value !== preferredEditor ? null : presentation === "menu" ? (
+ openFavoriteEditorShortcutLabel && (
+ {openFavoriteEditorShortcutLabel}
+ )
+ ) : (
+ // The popup marks the preferred editor with a check and its shortcut key cap.
+
+
+ {openFavoriteEditorShortcutLabel ? (
+ isPanel ? (
+ {openFavoriteEditorShortcutLabel}
+ ) : (
+ {openFavoriteEditorShortcutLabel}
+ )
+ ) : null}
+
)}
))}
@@ -469,7 +487,12 @@ export const OpenInPicker = memo(function OpenInPicker({
Choose editor
- {editorItems}
+
+
+ Open in…
+ {editorItems}
+
+
);
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx
index 79c72fd595a59..2c47ac98739c5 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.tsx
@@ -5,6 +5,7 @@ import {
type ProviderDriverKind,
type ResolvedKeybindingsConfig,
} from "@t3tools/contracts";
+import { formatModelSlugName } from "@t3tools/shared/model";
import { RegistryContext } from "@effect/atom-react";
import {
createContext,
@@ -174,7 +175,9 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
? getTriggerDisplayModelName(selectedModel, activeEntry?.driverKind)
: props.model === ANTIGRAVITY_DEFAULT_MODEL
? "Choose model"
- : props.model || "Choose model";
+ : props.model
+ ? formatModelSlugName(props.model)
+ : "Choose model";
const triggerLabel = selectedModel
? `${getDisplayModelName(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}`
: triggerTitle;
diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx
index 8a59587e382c3..9f5d791c75b40 100644
--- a/apps/web/src/components/chat/ProviderStatusBanner.tsx
+++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx
@@ -1,18 +1,17 @@
+import { useAtomValue } from "@effect/atom-react";
+import { useRef, useState } from "react";
+import { serverEnvironment } from "../../state/server";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { RefreshIcon } from "../ui/refresh-icon";
import {
type EnvironmentId,
type ProviderInstanceId,
type ServerProvider,
} from "@t3tools/contracts";
-import { useAtomValue } from "@effect/atom-react";
-import { memo, useRef, useState } from "react";
-import { InfoIcon, XIcon } from "lucide-react";
-import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert";
-import { Button, InlineButton } from "../ui/button";
+import { BotOffIcon, CircleAlertIcon, KeyRoundIcon, TriangleAlertIcon } from "lucide-react";
+import { Button } from "../ui/button";
import { formatProviderDriverKindLabel } from "../../providerModels";
-import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
-import { serverEnvironment } from "../../state/server";
-import { useAtomCommand } from "../../state/use-atom-command";
-import { RefreshIcon } from "../ui/refresh-icon";
+import type { ComposerBannerStackItem } from "./ComposerBannerStack";
function getIncompatibleVersion(status: ServerProvider) {
const compatibility = status.compatibilityAdvisory;
@@ -93,16 +92,102 @@ export function getProviderStatusMessage(status: ServerProvider): string {
: `${providerName} provider has limited availability.`;
}
-export const ProviderStatusBanner = memo(function ProviderStatusBanner({
+/**
+ * The selected provider's status as a tray notice: signed out, no models,
+ * unsupported version, or degraded. The provider's own message follows the
+ * title; a clipped one opens in the notice's details popover.
+ */
+export function providerStatusBannerItem({
environmentId,
onDismiss,
onOpenProviderSetup,
+ onOpenTerminal,
status,
}: {
environmentId: EnvironmentId;
onDismiss: () => void;
onOpenProviderSetup?: (instanceId: ProviderInstanceId) => void;
+ /** Offered for a signed-out provider whose sign-in runs in its CLI. */
+ onOpenTerminal?: () => void;
status: ServerProvider | null;
+}): ComposerBannerStackItem | null {
+ const bannerKey = getProviderStatusBannerKey(status);
+ if (!status || bannerKey === null) {
+ return null;
+ }
+
+ const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
+ const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";
+ const incompatible = getIncompatibleVersion(status);
+ const hasNoModels =
+ status.status === "warning" &&
+ status.installed &&
+ status.auth.status !== "unauthenticated" &&
+ status.models.length === 0;
+ const title = isUnauthenticated
+ ? `${providerName} signed out`
+ : incompatible
+ ? `${providerName} ${status.version ?? ""} is ${incompatible.status === "broken" ? "known to be broken" : "unsupported"}`
+ : hasNoModels
+ ? `${providerName} has no models`
+ : status.status === "error"
+ ? `${providerName} unavailable`
+ : `${providerName} limited`;
+ const message = incompatible?.message ?? getProviderStatusMessage(status);
+ const isWarning =
+ isUnauthenticated ||
+ hasNoModels ||
+ (incompatible?.status !== "broken" && (status.status === "warning" || incompatible !== null));
+ const openSetup = onOpenProviderSetup && hasProviderSetup(status) ? onOpenProviderSetup : null;
+ const { instanceId } = status;
+
+ return {
+ id: `provider-status:${bannerKey}`,
+ textSize: "body",
+ variant: isWarning ? "warning" : "error",
+ icon: isUnauthenticated ? (
+
+ ) : hasNoModels ? (
+
+ ) : isWarning ? (
+
+ ) : (
+
+ ),
+ title,
+ description: message,
+ actions: (
+ <>
+ {openSetup ? (
+ openSetup(instanceId)}>
+ Provider setup
+
+ ) : isUnauthenticated && onOpenTerminal ? (
+
+ Open terminal
+
+ ) : null}
+
+ >
+ ),
+ dismissLabel: `Dismiss ${providerName} provider ${status.status}`,
+ onDismiss,
+ };
+}
+
+function ProviderStatusRefreshButton({
+ environmentId,
+ instanceId,
+ providerName,
+}: {
+ environmentId: EnvironmentId;
+ instanceId: ProviderInstanceId;
+ providerName: string;
}) {
const canRefresh = useAtomValue(serverEnvironment.refreshProviders.permissionAtom(environmentId));
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
@@ -110,83 +195,24 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
});
const refreshingRef = useRef(false);
const [isRefreshing, setIsRefreshing] = useState(false);
-
const refresh = async () => {
- if (!status || !canRefresh || refreshingRef.current) return;
+ if (!canRefresh || refreshingRef.current) return;
refreshingRef.current = true;
setIsRefreshing(true);
- await refreshProviders({
- environmentId,
- input: { instanceId: status.instanceId, refreshModels: true },
- });
+ await refreshProviders({ environmentId, input: { instanceId, refreshModels: true } });
refreshingRef.current = false;
setIsRefreshing(false);
};
-
- if (!status || getProviderStatusBannerKey(status) === null) {
- return null;
- }
-
- const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver);
- const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated";
- const incompatible = getIncompatibleVersion(status);
- const title = isUnauthenticated
- ? `${providerName} is unauthenticated`
- : incompatible
- ? `${providerName} ${status.version ?? ""} is ${incompatible.status === "broken" ? "known to be broken" : "unsupported"}`
- : `${providerName} provider status`;
- const message = incompatible?.message ?? getProviderStatusMessage(status);
- const isWarning =
- incompatible?.status !== "broken" && (status.status === "warning" || incompatible !== null);
-
return (
-
-
-
- {title}
-
-
-
- }>{message}
-
- {message}
-
-
-
- {onOpenProviderSetup && hasProviderSetup(status) ? (
- onOpenProviderSetup(status.instanceId)}>
- Open provider setup
-
- ) : null}
- void refresh()}
- size="xs"
- variant="secondary"
- >
-
- {isRefreshing ? "Refreshing..." : "Refresh"}
-
-
-
-
-
-
-
-
-
-
-
+ void refresh()}
+ size="xs"
+ variant="secondary"
+ >
+
+ {isRefreshing ? "Refreshing..." : "Refresh"}
+
);
-});
+}
diff --git a/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx b/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx
index f2ddde4b72a6a..29ec94d7e86c2 100644
--- a/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx
+++ b/apps/web/src/components/chat/QueuedRunsControl.browser.test.tsx
@@ -8,6 +8,7 @@ import { page } from "vite-plus/test/browser";
const state = vi.hoisted(() => ({
projection: null as unknown,
workflow: null as unknown,
+ command: vi.fn(async (_input: unknown) => undefined),
}));
vi.mock(import("@t3tools/client-runtime/state/thread-workflows"), async (importOriginal) => ({
@@ -40,7 +41,7 @@ vi.mock(import("../../state/query"), async (importOriginal) => ({
vi.mock(import("../../state/use-atom-command"), async (importOriginal) => ({
...(await importOriginal()),
- useAtomCommand: (() => async () => undefined) as never,
+ useAtomCommand: (() => state.command) as never,
}));
vi.mock(import("../../assets/assetUrls"), async (importOriginal) => ({
@@ -50,12 +51,13 @@ vi.mock(import("../../assets/assetUrls"), async (importOriginal) => ({
}));
import "~/index.css";
-import { render as renderUi } from "../../../test/render";
+import { act, render as renderUi } from "../../../test/render";
import { QueuedRunsControl } from "./QueuedRunsControl";
function setup() {
state.projection = null;
state.workflow = null;
+ state.command.mockClear();
}
const baseProps = {
@@ -200,6 +202,42 @@ it("shows who sent a queued message and folds automation and agent prompts", asy
await expect.element(page.getByText("Sent by T3 Code")).toBeVisible();
await expect.element(page.getByText("Continue where you left off.")).toBeVisible();
+
+ const dataTransfer = new DataTransfer();
+ await act(() => {
+ page
+ .getByRole("button", { name: "Reorder queued message 1" })
+ .element()
+ .dispatchEvent(new DragEvent("dragstart", { bubbles: true, dataTransfer }));
+ });
+ await act(() => {
+ const last = page.getByRole("listitem").last().element();
+ const bounds = last.getBoundingClientRect();
+ last.dispatchEvent(
+ new DragEvent("dragover", {
+ bubbles: true,
+ cancelable: true,
+ dataTransfer,
+ clientY: bounds.bottom - 1,
+ }),
+ );
+ });
+ await act(() => {
+ page
+ .getByRole("list")
+ .element()
+ .dispatchEvent(
+ new DragEvent("drop", {
+ bubbles: true,
+ cancelable: true,
+ dataTransfer,
+ }),
+ );
+ });
+ expect(state.command).toHaveBeenLastCalledWith({
+ environmentId: baseProps.environmentId,
+ input: { threadId: baseProps.threadId, runId: "run:automation", beforeRunId: null },
+ });
});
it("stays inside the timeline scroller instead of growing the chat column", async () => {
diff --git a/apps/web/src/components/chat/QueuedRunsControl.tsx b/apps/web/src/components/chat/QueuedRunsControl.tsx
index f12c66327c337..582c720618928 100644
--- a/apps/web/src/components/chat/QueuedRunsControl.tsx
+++ b/apps/web/src/components/chat/QueuedRunsControl.tsx
@@ -15,7 +15,8 @@ import type {
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
import { Link } from "@tanstack/react-router";
import {
- ArrowUpIcon,
+ GripVerticalIcon,
+ CornerUpRightIcon,
CalendarClockIcon,
ChevronDownIcon,
Clock3Icon,
@@ -24,7 +25,15 @@ import {
XIcon,
} from "lucide-react";
import * as DateTime from "effect/DateTime";
-import { useId, useImperativeHandle, useRef, useState, type ReactNode, type Ref } from "react";
+import {
+ useId,
+ useImperativeHandle,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type ReactNode,
+ type Ref,
+} from "react";
import { useAssetUrls } from "../../assets/assetUrls";
import { threadEnvironment } from "../../state/threads";
@@ -34,6 +43,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { isImageAttachment, type ChatMessage } from "../../types";
import { cn } from "~/lib/utils";
import { Button, InlineButton } from "../ui/button";
+import { AttachmentImage } from "../media/AttachmentImage";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import type { TimestampFormat } from "@t3tools/contracts/settings";
import { GuestMessageName } from "./guestMessageStyle";
@@ -100,8 +110,14 @@ export function QueuedRunsControl({
const promote = useAtomCommand(threadEnvironment.promoteQueuedRun);
const resume = useAtomCommand(threadEnvironment.resumeThreadQueue);
const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun);
+ const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun);
+ // The row being dragged and the row it would land before (null: the end).
+ const [drag, setDrag] = useState<{ runId: RunId; beforeRunId: RunId | null } | null>(null);
+ const draggedRunId = drag?.runId ?? null;
const [showAll, setShowAll] = useState(false);
const queueListId = useId();
+ const queueListRef = useRef(null);
+ const previousRowTops = useRef(new Map());
const [busyRunId, setBusyRunId] = useState(null);
const steerInFlightRef = useRef(false);
// Run-only acknowledgements can resurrect pending rows after cancellation or startup.
@@ -224,6 +240,26 @@ export function QueuedRunsControl({
},
}));
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- animates queue rows from their previous layout after a drag preview or persisted reorder
+ useLayoutEffect(() => {
+ const nextTops = new Map();
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ for (const row of queueListRef.current?.querySelectorAll("[data-queue-key]") ??
+ []) {
+ const key = row.dataset.queueKey!;
+ const nextTop = row.offsetTop;
+ const previousTop = previousRowTops.current.get(key);
+ nextTops.set(key, nextTop);
+ if (!reducedMotion && previousTop !== undefined && previousTop !== nextTop) {
+ row.animate(
+ [{ transform: `translateY(${previousTop - nextTop}px)` }, { transform: "translateY(0)" }],
+ { duration: 160, easing: "ease-out" },
+ );
+ }
+ }
+ previousRowTops.current = nextTops;
+ });
+
if (items.length === 0) return null;
const remove = async (runId: RunId) => {
@@ -235,20 +271,48 @@ export function QueuedRunsControl({
setBusyRunId(null);
};
+ const previewItems = [...items];
+ const draggedIndex = previewItems.findIndex((item) => item.runId === draggedRunId);
+ if (draggedRunId !== null && draggedIndex >= 0) {
+ const [dragged] = previewItems.splice(draggedIndex, 1);
+ const beforeIndex = previewItems.findIndex((item) => item.runId === drag?.beforeRunId);
+ previewItems.splice(beforeIndex < 0 ? previewItems.length : beforeIndex, 0, dragged!);
+ }
const collapsible = items.length > QUEUE_VISIBLE_ROWS;
- const visibleItems = collapsible && !showAll ? items.slice(0, QUEUE_VISIBLE_ROWS - 1) : items;
+ const visibleItems =
+ collapsible && !showAll ? previewItems.slice(0, QUEUE_VISIBLE_ROWS - 1) : previewItems;
return (
{
+ if (event.key === "Escape") {
+ setDrag(null);
+ }
+ }}
+ onDragOver={(event) => {
+ if (draggedRunId) {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ }
+ }}
+ onDrop={(event) => {
+ event.preventDefault();
+ if (drag && drag.runId !== drag.beforeRunId)
+ void reorder({
+ environmentId: props.environmentId,
+ input: { threadId: props.threadId, runId: drag.runId, beforeRunId: drag.beforeRunId },
+ });
+ setDrag(null);
+ }}
>
{items.length} queued
-
+
{visibleItems.map((item, position) => {
const previewText = isAttachmentOnlyBootstrapPrompt(item.text)
? ""
@@ -261,12 +325,65 @@ export function QueuedRunsControl({
return (
{
+ if (drag && item.runId !== drag.runId) {
+ event.preventDefault();
+ const bounds = event.currentTarget.getBoundingClientRect();
+ setDrag({
+ runId: drag.runId,
+ beforeRunId:
+ event.clientY < bounds.top + bounds.height / 2
+ ? item.runId
+ : (previewItems
+ .slice(position + 1)
+ .find((next) => next.runId !== drag.runId)?.runId ?? null),
+ });
+ }
+ }}
>
+ {
+ if (!item.runId) return;
+ event.dataTransfer.setData("text/plain", item.runId);
+ event.dataTransfer.effectAllowed = "move";
+ setShowAll(true);
+ setDrag({ runId: item.runId, beforeRunId: items[position + 1]?.runId ?? null });
+ }}
+ onDragEnd={() => setDrag(null)}
+ onKeyDown={(event) => {
+ if (!item.runId || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
+ event.preventDefault();
+ const next = position + (event.key === "ArrowUp" ? -1 : 2);
+ if (next < 0 || (position === items.length - 1 && event.key === "ArrowDown"))
+ return;
+ void reorder({
+ environmentId: props.environmentId,
+ input: {
+ threadId: props.threadId,
+ runId: item.runId,
+ beforeRunId: items[next]?.runId ?? null,
+ },
+ });
+ }}
+ >
+
+
-
- Send now
+
+ {workflow?.canSendNow ? "Send now" : "Steer"}
@@ -359,6 +476,7 @@ export function QueuedRunsControl({
size="icon-xs"
variant="ghost-muted"
aria-label="Remove queued message"
+ className="-me-1 sm:-me-[3px]"
disabled={item.runId === null || busyRunId !== null}
onClick={() => {
if (item.runId !== null) void remove(item.runId);
@@ -378,7 +496,7 @@ export function QueuedRunsControl({
})}
{collapsible ? (
-
+
@@ -457,13 +576,13 @@ function QueuedMessage(props: {
{/* Dashed: a turn that has not been sent yet. */}
@@ -479,20 +598,18 @@ function QueuedMessage(props: {
{bodyVisible ? (
- {props.thumbnails.length > 0 ? (
-
- {props.thumbnails.map((thumbnail) => (
-
+ {thumbnail ? (
+
+ }>
+
{thumbnail.url ? (
-
@@ -500,19 +617,29 @@ function QueuedMessage(props: {
)}
- ))}
-
+ {props.thumbnails.length > 1 ? +{props.thumbnails.length - 1} : null}
+
+
+ {props.thumbnails.map((image) => image.name).join("\n")}
+
+
) : null}
{/* In full, like a sent turn: a queued message is read before it is sent. */}
{props.previewText ? (
- {props.previewText}
+
+ {props.previewText}
+
) : null}
) : null}
-
+
{props.message ? (
-
+
{attribution === "agent" &&
senderRef &&
// Only an environment this client is connected to can open the sender's thread there.
@@ -537,11 +664,13 @@ function QueuedMessage(props: {
Open automation
) : null}
-
+
+
+
) : null}
{props.children}
diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx
index e0ac9fd87f199..ea18f8191e96e 100644
--- a/apps/web/src/components/chat/SkillInlineText.tsx
+++ b/apps/web/src/components/chat/SkillInlineText.tsx
@@ -144,6 +144,7 @@ export function SkillChipPopover(props: {
);
}
+/** The skill glyph; the surrounding chip sizes and colors its svg. */
export function SkillChipIcon() {
return (
,
- title: (
-
- Branch changed from
-
-
- {mismatch.threadBranch}
-
- }
- />
-
- This thread last ran on {mismatch.threadBranch}. Sending will continue on{" "}
- {mismatch.currentBranch}.
-
-
-
+ title: "Branch changed",
+ description: (
+
+ }>
+ was {mismatch.threadBranch}
+
+
+ This thread last ran on {mismatch.threadBranch}. Sending will continue on{" "}
+ {mismatch.currentBranch}.
+
+
),
actions: (
-
+
{input.restoring ? "Restoring..." : "Restore branch"}
),
diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx
index 48f4f9609b6d4..2869ac97e26f3 100644
--- a/apps/web/src/components/chat/ThreadErrorBanner.tsx
+++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx
@@ -4,15 +4,13 @@ import {
CHATGPT_WORKSPACE_ADMIN_REQUEST,
CHATGPT_WORKSPACE_USAGE_LIMIT_CODE,
} from "@t3tools/shared/usageLimits";
-import { memo } from "react";
+import { CircleAlertIcon } from "lucide-react";
import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
-import { Alert, AlertAction, AlertDescription } from "../ui/alert";
-import { Button } from "../ui/button";
import { toastManager } from "../ui/toast";
-import { CircleAlertIcon, XIcon } from "lucide-react";
+import { Button } from "../ui/button";
import { OpenAI } from "../Icons";
import { ChatGptUsageButton } from "../settings/ChatGptUsageButton";
-import { ExpandableText } from "../settings/ExpandableText";
+import type { ComposerBannerStackItem } from "./ComposerBannerStack";
export function copyAdminRequest() {
void writeTextToClipboard(CHATGPT_WORKSPACE_ADMIN_REQUEST, "admin request").then(
@@ -69,67 +67,58 @@ export function isThreadErrorBannerDismissedForSession(bannerKey: string | null)
return bannerKey !== null && sessionDismissedThreadErrorBannerKeys.has(bannerKey);
}
-export const ThreadErrorBanner = memo(function ThreadErrorBanner({
+/**
+ * The thread's last error as a tray notice. Structured errors show their message;
+ * exact diagnostics open in the notice's details popover. Retry sends the failed
+ * run's prompt again when the thread can run.
+ */
+export function threadErrorBannerItem({
+ bannerKey,
error,
- onDismiss,
errorClass,
errorCode,
+ onDismiss,
+ onRetry,
+ retrying = false,
}: {
+ bannerKey: string | null;
error: string | null;
errorClass?: OrchestrationV2ProviderFailureClass | null;
errorCode?: string | null;
onDismiss?: () => void;
-}) {
- if (!error) return null;
+ onRetry?: (() => void) | null;
+ retrying?: boolean;
+}): ComposerBannerStackItem | null {
+ if (!error || bannerKey === null) return null;
+ const message = getThreadErrorBannerMessage(error);
const workspaceLimit = errorCode === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE;
const chatGptLimit = workspaceLimit || errorCode === CHATGPT_USAGE_LIMIT_CODE;
- const variant = errorClass === "usage_limit" || chatGptLimit ? "warning" : "error";
- return (
-
-
- {chatGptLimit ? (
-
- ) : (
-
- )}
-
- {chatGptLimit ? (
-
-
ChatGPT usage limit reached
- {workspaceLimit ? (
-
- {
- "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits."
- }
-
- ) : null}
-
- ) : (
-
- )}
-
- {(chatGptLimit || onDismiss) && (
-
- {workspaceLimit ? (
-
- Copy request for admin
-
- ) : chatGptLimit ? (
-
- ) : null}
- {onDismiss ? (
-
-
-
- ) : null}
-
- )}
-
-
- );
-});
+ return {
+ id: `thread-error:${bannerKey}`,
+ variant: errorClass === "usage_limit" || chatGptLimit ? "warning" : "error",
+ icon: chatGptLimit ? : ,
+ title: chatGptLimit ? "ChatGPT usage limit reached" : "Run failed",
+ description: workspaceLimit
+ ? "You've reached your workspace's limit for using your ChatGPT plan in other apps. Copy a request to ask your workspace admin for increased usage limits."
+ : chatGptLimit
+ ? null
+ : message,
+ descriptionDetails:
+ !chatGptLimit && message !== error ? (
+ {error}
+ ) : undefined,
+ actions: workspaceLimit ? (
+
+ Copy request for admin
+
+ ) : chatGptLimit ? (
+
+ ) : onRetry ? (
+
+ {retrying ? "Retrying..." : "Retry"}
+
+ ) : undefined,
+ dismissLabel: "Dismiss error",
+ ...(onDismiss ? { onDismiss } : {}),
+ };
+}
diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx
index eda8884c6f2ec..a3d1ca715ad8e 100644
--- a/apps/web/src/components/chat/TimelineAssistantRows.tsx
+++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx
@@ -1,3 +1,9 @@
+import {
+ MESSAGE_HEADING_LEVEL,
+ MessageAuthorHeading,
+ useCarriedTextFade,
+} from "./TimelineRowParts";
+import { type TimelineRow, TimelineRowActivityCtx, TimelineRowCtx } from "./timelineRowContext";
import { shouldPreserveAssistantLineBreaks } from "@t3tools/shared/markdownPipeline";
import { type RunId, type TurnItemId } from "@t3tools/contracts";
import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thread-workflows";
@@ -28,8 +34,6 @@ import { cn } from "~/lib/utils";
import { useUiStateStore } from "~/uiStateStore";
import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";
import { useV2ItemSupport } from "../../state/v2ItemSupport";
-import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext";
-import { MESSAGE_HEADING_LEVEL, MessageAuthorHeading } from "./TimelineRowParts";
export function AssistantTimelineRow({ row }: { row: Extract }) {
const ctx = use(TimelineRowCtx);
@@ -41,12 +45,13 @@ export function AssistantTimelineRow({ row }: { row: Extract
T3 Code
-
+
button:first-child]:-ms-1.5",
alwaysVisible
? "opacity-100"
: "opacity-0 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover/assistant:opacity-100",
diff --git a/apps/web/src/components/chat/TimelineOverlays.tsx b/apps/web/src/components/chat/TimelineOverlays.tsx
index 9784152102c29..90e7392211205 100644
--- a/apps/web/src/components/chat/TimelineOverlays.tsx
+++ b/apps/web/src/components/chat/TimelineOverlays.tsx
@@ -1,25 +1,8 @@
-import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
+import { ChevronDownIcon } from "lucide-react";
import { Button } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
-export function WorkspaceFileDropOverlay() {
- return (
-
-
-
- Drop files to attach
-
-
- );
-}
-
/**
* Sits `bottom` pixels up, clear of the composer and its attached banners. The summary names
* what waits at the end of the timeline, which is out of view while it shows.
diff --git a/apps/web/src/components/chat/TimelinePendingApprovalRow.tsx b/apps/web/src/components/chat/TimelinePendingApprovalRow.tsx
index c3f745dcaeb46..e2fbc4b9f06c3 100644
--- a/apps/web/src/components/chat/TimelinePendingApprovalRow.tsx
+++ b/apps/web/src/components/chat/TimelinePendingApprovalRow.tsx
@@ -2,15 +2,47 @@ import {
FULL_ACCESS_OVERRIDE_QUESTION,
fullAccessOverrideExplanation,
} from "@t3tools/client-runtime/state/thread-requests";
-import { ShieldIcon } from "lucide-react";
-import { memo, use } from "react";
+import { FileDiffIcon, FileMinusIcon, FilePlusIcon, ShieldIcon } from "lucide-react";
+import { memo, use, useState } from "react";
import { type PendingApproval } from "../../session-logic";
+import { cn } from "../../lib/utils";
import { InlineButton } from "../ui/button";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
-import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { ToolActivityIconView, type WorkEntryIconName } from "./TimelineToolActivityIcon";
import { TimelineRowCtx } from "./timelineRowContext";
import { WorkLogRow } from "./WorkLog";
+import { useTurnItemDetail } from "../../state/queries";
+
+function ApprovalFileDiff({ file }: { file: NonNullable[number] }) {
+ const ctx = use(TimelineRowCtx);
+ const detail = useTurnItemDetail(
+ !file.diff && file.source
+ ? { environmentId: ctx.activeThreadEnvironmentId, ...file.source }
+ : null,
+ ctx.turnItemSource?.detail,
+ );
+ const item = detail.data?.item;
+ const diff = file.diff || (item?.type === "file_change" ? item.diffStr : undefined);
+ return (
+
+
{file.path}
+ {diff ? (
+
+
+
+ ) : (
+
+ {detail.isPending
+ ? "Loading diff…"
+ : detail.error
+ ? "Could not load diff."
+ : "Diff is unavailable."}
+
+ )}
+
+ );
+}
const APPROVAL_PRESENTATION = {
command: { icon: "terminal", label: "Command approval", detailLabel: "Command" },
@@ -31,14 +63,47 @@ const APPROVAL_PRESENTATION = {
{ icon: WorkEntryIconName; label: string; detailLabel: string }
>;
+function ApprovalDiffLines({ diff }: { diff: string }) {
+ let offset = 0;
+ return diff.match(/[^\n]*\n|[^\n]+$/g)?.map((line) => {
+ const key = offset;
+ offset += line.length;
+ return (
+
+ {line}
+
+ );
+ });
+}
+
export const TimelinePendingApprovalRow = memo(function TimelinePendingApprovalRow({
approval,
}: {
approval: PendingApproval;
}) {
- const { respondingRequestIds, canOperateThread, onRespondToApproval } = use(TimelineRowCtx);
+ const [diffRequestId, setDiffRequestId] = useState(null);
+ const diffOpen = diffRequestId === approval.requestId;
+ const { onRespondToApproval } = use(TimelineRowCtx);
const presentation = APPROVAL_PRESENTATION[approval.requestKind];
const canRespond = approval.responseCapability === "live";
+ const files = approval.requestKind === "file-change" ? (approval.files ?? []) : [];
+ const diffFiles = files.filter((file, index) =>
+ file.source
+ ? files.findIndex((other) => other.source?.itemId === file.source?.itemId) === index
+ : Boolean(file.diff),
+ );
+ const hasDiff = canRespond && diffFiles.length > 0;
return (
{approval.appName}
) : null}
+ {files.length > 0 ? (
+
+ {files.map((file) => (
+
+ {file.operation?.toLowerCase() === "add" ||
+ file.operation?.toLowerCase() === "create" ||
+ file.operation?.toLowerCase() === "added" ? (
+
+ ) : file.operation?.toLowerCase() === "delete" ||
+ file.operation?.toLowerCase() === "remove" ||
+ file.operation?.toLowerCase() === "deleted" ? (
+
+ ) : (
+
+ )}
+
+ }>
+ {file.path}
+
+ {file.path}
+
+ {file.additions !== undefined || file.deletions !== undefined ? (
+
+ +{file.additions ?? 0} {" "}
+ −{file.deletions ?? 0}
+
+ ) : null}
+
+ ))}
+
+ ) : null}
+ {diffOpen ? (
+
+ {diffFiles.map((file) => (
+
+ ))}
+
+ ) : null}
{approval.reason ? (
{approval.reason}
) : null}
@@ -80,15 +196,15 @@ export const TimelinePendingApprovalRow = memo(function TimelinePendingApprovalR
{onRespondToApproval ? (
<>
-
+ {hasDiff ? (
+ setDiffRequestId(diffOpen ? null : approval.requestId)}
+ >
+ {diffOpen ? "Hide diff" : "Review diff"}
+
+ ) : null}
>
) : null}
>
diff --git a/apps/web/src/components/chat/TimelineRowParts.tsx b/apps/web/src/components/chat/TimelineRowParts.tsx
index 2a43e9fbe4dac..fab269ddf4c5b 100644
--- a/apps/web/src/components/chat/TimelineRowParts.tsx
+++ b/apps/web/src/components/chat/TimelineRowParts.tsx
@@ -1,11 +1,45 @@
-import { type ReactNode } from "react";
+import { type ReactNode, type ComponentProps, useLayoutEffect, useRef } from "react";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { cn } from "~/lib/utils";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";
+import { TerminalIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";
+import { ContextChip, ContextChipLabel } from "../ContextChip";
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
export const MESSAGE_HEADING_LEVEL = 3;
+const TEXT_FADE_MS = 200;
+const fadesInFlight = new Map();
+
+/**
+ * A message's text moves from the answer row into the work window when a tool call follows,
+ * which remounts it. The new copy resumes the fade the old one was in the middle of.
+ */
+export function useCarriedTextFade(messageId: string) {
+ const ref = useRef(null);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reads and resumes the old copy's computed opacity across a remount
+ useLayoutEffect(() => {
+ const root = ref.current;
+ const lastBlock = () => root?.querySelector(".chat-markdown")?.lastElementChild ?? null;
+ const carried = fadesInFlight.get(messageId);
+ fadesInFlight.delete(messageId);
+ if (carried && performance.now() - carried.at < TEXT_FADE_MS) {
+ lastBlock()?.animate(
+ { opacity: [carried.opacity, 1] },
+ { duration: TEXT_FADE_MS, easing: "ease-out" },
+ );
+ }
+ return () => {
+ const block = lastBlock();
+ const opacity = block ? Number(getComputedStyle(block).opacity) : 1;
+ if (opacity < 1) fadesInFlight.set(messageId, { opacity, at: performance.now() });
+ };
+ }, [messageId]);
+ return ref;
+}
+
export function MessageAuthorHeading({ children }: { children: string }) {
return {children} ;
}
@@ -27,7 +61,7 @@ export function TimelineRowTimestamp({
render={
);
}
+
+/** The bar's quiet action chip (Snooze), a feature look rather than a button variant. */
+export function WorkingBarChip({
+ icon,
+ children,
+ tone = "neutral",
+ ...buttonProps
+}: {
+ icon: ReactNode;
+ children: ReactNode;
+ tone?: "neutral" | "warning";
+} & Omit, "className" | "children" | "type">) {
+ return (
+
+ {icon}
+ {children}
+
+ );
+}
+
+/**
+ * The working bar's frame: a 32px line over a hairline, shared by the bar and the fold.
+ * The label starts on the assistant text's edge, 4px into the lane.
+ */
+export const WORKING_BAR_CLASS_NAME =
+ "group/timeline-row relative flex h-8 min-w-0 items-center gap-2.5 border-b border-(--timeline-working-rule,var(--border)) ps-1.25 pe-1 min-[640px]:[--type-body:0.8125rem] min-[640px]:[--type-label:0.8125rem]";
+
+/** A provider command in the timeline: in a work row's trailing slot, or opening a prompt. */
+export function SlashCommandChip({ name }: { name: string }) {
+ return (
+
+
+ {name}
+
+ );
+}
+
+export function WorkFoldCaret({
+ expanded,
+ hidden = false,
+ fade = false,
+}: {
+ expanded: boolean;
+ hidden?: boolean;
+ fade?: boolean;
+}) {
+ return expanded ? (
+
+ ) : (
+
+ );
+}
+
+export function WorkFoldToggle({
+ togglable,
+ expanded,
+ fadeCaret = false,
+ tone = "neutral",
+ children,
+ onClick,
+ ...props
+}: Omit, "className" | "type" | "disabled"> & {
+ togglable: boolean;
+ expanded: boolean;
+ fadeCaret?: boolean;
+ tone?: "neutral" | "warning" | "error";
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+/** The working bar's controls share its compact, feature-owned spacing. */
+export function WorkingBarControl({
+ variant,
+ tone = "neutral",
+ render,
+ ...props
+}: Omit, "className"> & {
+ variant: "chip" | "history" | "question";
+ tone?: "neutral" | "warning" | "error";
+}) {
+ const defaultProps = {
+ type: render ? undefined : ("button" as const),
+ className: cn(
+ variant === "chip"
+ ? "inline-flex h-[22px] shrink-0 cursor-pointer items-center gap-1.5 rounded-row px-2 text-meta whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 disabled:pointer-events-none disabled:opacity-64 [&_svg]:size-3 [&_svg]:shrink-0"
+ : variant === "history"
+ ? "flex shrink-0 cursor-pointer select-none items-center gap-2 self-stretch rounded-md text-left text-label whitespace-nowrap tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 disabled:cursor-default"
+ : "inline-flex h-[22px] min-w-0 shrink items-center gap-1.5 rounded-row text-meta focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 text-info-foreground cursor-pointer",
+ variant === "chip" &&
+ (tone === "warning"
+ ? "bg-warning-foreground/10 text-warning-foreground hover:bg-warning-foreground/15"
+ : "bg-badge-neutral text-muted-foreground hover:text-foreground"),
+ variant === "history" &&
+ (tone === "error"
+ ? "text-error-foreground"
+ : tone === "warning"
+ ? "text-warning-foreground"
+ : "text-muted-foreground enabled:hover:text-foreground"),
+ ),
+ };
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(defaultProps, props),
+ render,
+ });
+}
diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx
index 725b6b6b64175..74a906f7fbc8f 100644
--- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx
+++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx
@@ -1,6 +1,20 @@
import { InlineAudioPreview } from "../files/InlineAudioPreview";
import { filePreviewKind } from "@t3tools/shared/filePreview";
import { remarkEnableOrderedListNumbers } from "@t3tools/shared/markdownPipeline";
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { type TimelineRow, TimelineRowActivityCtx, TimelineRowCtx } from "./timelineRowContext";
+import {
+ MESSAGE_HEADING_LEVEL,
+ MessageAuthorHeading,
+ SlashCommandChip,
+ TimelineRowTimestamp,
+} from "./TimelineRowParts";
+import {
+ resolvePreviewAnnotationImage,
+ userMessageFileLabel,
+} from "./TimelineUserMessageRow.logic";
+import { UserMessageContextReferenceChip } from "./UserMessageContextChips";
import { findRevealRef } from "./markdownFindContext";
import {
type EnvironmentId,
@@ -16,7 +30,11 @@ import { useQuietThreadShell } from "../../state/entities";
import { useEnvironment } from "../../state/environments";
import { useEnvironmentQuery } from "../../state/query";
import { serverEnvironment } from "../../state/server";
-import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
+import {
+ replaceComposerContextReferences,
+ collectComposerContextReferences,
+ formatComposerContextReference,
+} from "@t3tools/shared/composerContextReferences";
import { memo, use, useState, type ReactNode } from "react";
import {
createMessageAttachmentPreviewProjector,
@@ -24,11 +42,12 @@ import {
} from "../../session-logic";
import {
type ChatFileAttachment,
+ type ChatMessage,
isFileAttachment,
isImageAttachment,
isVideoAttachment,
} from "../../types";
-import ChatMarkdown from "../ChatMarkdown";
+import ChatMarkdown, { type ChatMarkdownContextReference } from "../ChatMarkdown";
import {
CalendarClockIcon,
ChevronDownIcon,
@@ -36,9 +55,9 @@ import {
EyeIcon,
type LucideIcon,
MessagesSquareIcon,
- Redo2Icon,
PencilIcon,
LinkIcon,
+ Redo2Icon,
} from "lucide-react";
import { Button, InlineButton } from "../ui/button";
import { FreshSessionRow, ModelSwitchRow } from "./V2LifecycleRow";
@@ -65,34 +84,18 @@ import {
resolveUserMessageContext,
selectedMessageContextFragment,
} from "~/lib/composerContextRecords";
-import {
- collectComposerContextReferences,
- formatComposerContextReference,
-} from "@t3tools/shared/composerContextReferences";
import {
COMPOSER_CONTEXT_CLIPBOARD_MIME,
encodeComposerContextClipboardHtml,
encodeComposerContextFragment,
} from "@t3tools/shared/composerContextClipboard";
import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";
-import type { ChatMarkdownContextReference } from "../ChatMarkdown";
import { cn } from "~/lib/utils";
import { markdownProseDirection } from "~/textDirection";
import { useMediaQuery } from "~/hooks/useMediaQuery";
import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";
import { WorkflowOpeningBlock } from "./WorkflowOpeningBlock";
import { useTallEnoughToFold } from "./useTallEnoughToFold";
-import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext";
-import {
- MESSAGE_HEADING_LEVEL,
- MessageAuthorHeading,
- TimelineRowTimestamp,
-} from "./TimelineRowParts";
-import { UserMessageContextReferenceChip } from "./UserMessageContextChips";
-import {
- resolvePreviewAnnotationImage,
- userMessageFileLabel,
-} from "./TimelineUserMessageRow.logic";
// Drawn inside the media so dark screenshots keep an edge on the dark background.
const MEDIA_EDGE_CLASS_NAME =
@@ -101,6 +104,20 @@ const MEDIA_EDGE_CLASS_NAME =
const BUBBLE_CHIP_CLASS_NAME =
"[&_[data-slot=context-chip]]:[--context-chip-ink:var(--context-chip-message,var(--context-chip-accent))]";
+function UserFilePreview(props: useRender.ComponentProps<"button">) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ type: "button",
+ className:
+ "group/file focus-visible:ring-ring/70 flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-control text-left focus-visible:ring-2 focus-visible:ring-inset focus-visible:outline-none",
+ },
+ props,
+ ),
+ });
+}
+
function UserFileCard({
file,
copyMarkdown,
@@ -116,9 +133,9 @@ function UserFileCard({
- {label}
-
- {file.downloadable === false ? "Sending…" : formatBytes(file.sizeBytes)}
+ {label}
+
+ {file.downloadable === false ? "Sending…" : <>{formatBytes(file.sizeBytes)}>}
>
@@ -126,7 +143,7 @@ function UserFileCard({
return (
{file.downloadable === false ? (
<>
@@ -137,14 +154,9 @@ function UserFileCard({
>
) : (
<>
- ctx.onFileOpen(file)}
- className="group/file min-w-0 flex-1 text-left"
- >
- {identity}
-
+ ctx.onFileOpen(file)}>
+ {identity}
+
) {
+ return useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ type: "button",
+ className:
+ "flex w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-pane px-3.5 py-2.5 text-left text-body focus-visible:outline-2 focus-visible:outline-ring",
+ },
+ props,
+ ),
+ });
+}
+
/** Automation prompts are long, repeated runbooks; a folded bubble keeps them out of the way. */
function AutomationMessageRow({ row }: { row: Extract }) {
const ctx = use(TimelineRowCtx);
@@ -280,16 +306,13 @@ function AutomationMessageRow({ row }: { row: Extract