[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0",
- className,
- )}
- {...props}
- />
- );
-}
-
-function Dock({ className, ...props }: ComponentProps<"div">) {
- return (
-
- );
-}
-
-function Root({
- className,
- placement = "attached",
- variant = "default",
- width = "fill",
- ...props
-}: ComponentProps<"div"> & {
- placement?: "attached" | "floating";
- variant?: ComposerBannerVariant;
- width?: "fill" | "content";
-}) {
- return (
-
- );
-}
-
-/** The same row can be a status, a list item, or an entire disclosure button. */
-function Row({
- className,
- render,
- layout = "inline",
- ...props
-}: useRender.ComponentProps<"div"> & {
- layout?: "inline" | "wrap-actions";
-}) {
- const rowProps = {
- className: cn(
- "group/banner-row grid min-h-(--composer-banner-icon-column) w-full min-w-0 grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)_auto] items-center gap-x-1 text-start",
- "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]",
- "[&:is(button)]:cursor-pointer [&:is(button)]:rounded-[0.5rem] [&:is(button)]:focus-visible:outline-2 [&:is(button)]:focus-visible:-outline-offset-2 [&:is(button)]:focus-visible:outline-ring",
- layout === "wrap-actions" &&
- "@max-[400px]:flex @max-[400px]:flex-wrap @max-[400px]:gap-y-1 @max-[400px]:*:data-[slot=composer-banner-actions]:ms-auto @max-[400px]:*:data-[slot=composer-banner-actions]:max-w-full @max-[400px]:has-[>[data-slot=composer-banner-icon]]:*:data-[slot=composer-banner-actions]:max-w-[calc(100%-var(--composer-banner-icon-column)-(--spacing(1)))] @max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column) @max-[400px]:*:data-[slot=composer-banner-content]:flex-[1_1_10rem]",
- className,
- ),
- "data-composer-banner-row": "true",
- "data-composer-banner-layout": layout,
- };
- return useRender({
- defaultTagName: "div",
- render,
- props: mergeProps<"div">(rowProps, props),
- });
-}
-
-function Icon({ className, ...props }: ComponentProps<"span">) {
- return (
-
svg]:size-3",
- className,
- )}
- {...props}
- />
- );
-}
-
-function Content({ className, ...props }: ComponentProps<"span">) {
- return (
- [data-slot=composer-banner-icon]]/banner-row:col-[1/3] group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-2 sm:group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-1.5",
- "group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-2 sm:group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-1.5",
- className,
- )}
- {...props}
- />
- );
-}
-
-function Separator() {
- return (
-
- ·
-
- );
-}
-
-function Actions({ className, ...props }: ComponentProps<"span">) {
- return (
-
- );
-}
-
-/** Child rows keep their parent's columns and begin immediately after its header. */
-function Children({ className, render, ...props }: useRender.ComponentProps<"div">) {
- return useRender({
- defaultTagName: "div",
- render,
- props: mergeProps<"div">(
- { className: cn("grid gap-px [&_[data-composer-banner-row]]:min-h-5", className) },
- props,
- ),
- });
-}
-
-/** Bounded banner content uses the app's scroll area and fades only overflowing edges. */
-function Scroll({ className, ...props }: ComponentProps) {
- return (
- [data-slot=scroll-area-viewport][data-has-overflow-y]]:pe-2",
- className,
- )}
- {...props}
- />
- );
-}
-
-function Count({ className, ...props }: ComponentProps<"span">) {
- return (
-
- );
-}
-
-function Body({ className, ...props }: ComponentProps<"div">) {
- return (
-
- );
-}
-
-function Dot({ className, ...props }: ComponentProps<"span">) {
- return (
-
- );
-}
-
-function ToggleIcon({ expanded, className }: { expanded: boolean; className?: string }) {
- return (
-
-
-
- );
-}
-
-function Dismiss({ className, children, ...props }: ComponentProps) {
- return (
-
- {children ?? }
-
- );
-}
-
-export const ComposerBanner = {
- Surface,
- Peek,
- Attachment,
- Dock,
- Root,
- Row,
- Icon,
- Content,
- Separator,
- Actions,
- Children,
- Scroll,
- Count,
- Body,
- Dot,
- ToggleIcon,
- Dismiss,
-};
diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..33d0d17eed7d8bf2d6fb9878fd2eb3eaf8114fd9
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx
@@ -0,0 +1,108 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vite-plus/test";
+
+import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
+
+const banner = (
+ id: string,
+ variant: ComposerBannerStackItem["variant"] = "warning",
+): ComposerBannerStackItem => ({
+ id,
+ variant,
+ icon: ! ,
+ title: `${id} warning`,
+});
+
+describe("ComposerBannerStack", () => {
+ it("keeps expanded banners in layout flow so surrounding content moves out of their way", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ const expandedItems = markup.match(
+ //,
+ );
+
+ expect(expandedItems?.[1]).toContain("grid-rows-[0fr]");
+ expect(expandedItems?.[1]).toContain("group-hover/banner-stack:grid-rows-[1fr]");
+ expect(expandedItems?.[1]).toContain("z-20");
+ expect(expandedItems?.[1]).not.toContain("absolute");
+ expect(markup.indexOf("front warning")).toBeLessThan(markup.indexOf("stacked warning"));
+ expect(markup).toContain("invisible pointer-events-none");
+ expect(markup).toContain("group-focus-within/banner-stack:visible");
+ });
+
+ it("colors the collapsed stack cap by the hidden banner's variant, not a fixed warning", () => {
+ const neutralBehind = renderToStaticMarkup(
+
,
+ );
+ expect(neutralBehind).toContain("chat-composer-banner-stack-cap");
+ expect(neutralBehind).toContain("border-[var(--chat-composer-attached-outline)]");
+ expect(neutralBehind).not.toContain("border-border");
+ expect(neutralBehind).not.toContain("border-warning/24");
+
+ const warningBehind = renderToStaticMarkup(
+
,
+ );
+ expect(warningBehind).toContain("border-warning/24");
+ });
+
+ it("does not render an expandable region for a single banner", () => {
+ const markup = renderToStaticMarkup(
);
+
+ expect(markup).not.toContain("data-composer-banner-stack-expanded-items");
+ expect(markup).toContain("chat-composer-drawer-surface");
+ expect(markup).toContain("chat-composer-drawer-attached");
+ expect(markup).not.toContain("before:mask-none");
+ expect(markup).toContain("text-xs");
+ expect(markup).toContain('data-composer-banner-drawer="true"');
+ expect(markup).toContain('data-variant="warning"');
+ expect(markup).toContain("transform:none");
+ expect(markup).not.toContain("will-change:transform");
+ });
+ it("applies item-specific surface and action layout classes", () => {
+ const markup = renderToStaticMarkup(
+
Repair,
+ },
+ ]}
+ />,
+ );
+
+ expect(markup).toContain("branch-surface");
+ expect(markup).toContain("branch-actions");
+ });
+
+ it("renders a disabled compaction action on the shared accessible banner surface", () => {
+ const markup = renderToStaticMarkup(
+ !,
+ title: "Resume with less context",
+ description: "250k tokens from an older session",
+ actions: (
+
+ Compact
+
+ ),
+ dismissLabel: "Keep full history",
+ onDismiss: () => {},
+ },
+ ]}
+ />,
+ );
+
+ expect(markup).toContain('role="alert"');
+ expect(markup).toContain("chat-composer-drawer-attached");
+ expect(markup).toContain('disabled=""');
+ expect(markup).toContain('aria-label="Keep full history"');
+ });
+});
diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx
index 95aa605df2ba35b9f01d100c980f8ad203ae738e..d8b8761447cbd9d1595ae8f04c91b02c3ad7bd74 100644
--- a/apps/web/src/components/chat/ComposerBannerStack.tsx
+++ b/apps/web/src/components/chat/ComposerBannerStack.tsx
@@ -1,52 +1,60 @@
-import { useEffect, useId, useRef, useState, type ReactNode } from "react";
+import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
+import { XIcon } from "lucide-react";
import { cn } from "~/lib/utils";
-import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
+import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert";
+import { Button } from "../ui/button";
-// Match the duration-220 exit transition before removing a dismissed notice.
const DISMISS_TRANSITION_MS = 220;
+const frontExitStyle = {
+ opacity: 0,
+ transform: "translate3d(0, 4rem, 0)",
+} satisfies CSSProperties;
+const stackedExitStyle = {
+ opacity: 0,
+ transform: "translate3d(0, 7rem, 0)",
+} satisfies CSSProperties;
+const restingStyle = {
+ opacity: 1,
+ transform: "none",
+} satisfies CSSProperties;
+const exitTransitionStyle = {
+ transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`,
+} satisfies CSSProperties;
+
+// The collapsed cap peeking above the front banner is the only hint that more
+// banners are stacked behind it, so its border must match the severity of the
+// first hidden banner — a neutral banner must not masquerade as a warning.
+const stackCapBorderClass: Record = {
+ default: "border-[var(--chat-composer-attached-outline)]",
+ error: "border-destructive/24",
+ info: "border-info/24",
+ success: "border-success/24",
+ warning: "border-warning/24",
+};
export interface ComposerBannerStackItem {
readonly id: string;
- readonly variant: ComposerBannerVariant;
- readonly priority?: "urgent" | "activity" | "notice";
+ readonly variant: "default" | "error" | "info" | "success" | "warning";
+ // Ordering hint for stack assemblers: front this banner even though its
+ // variant is calm (e.g. live update progress). The stack itself ignores it.
+ readonly urgent?: boolean;
readonly icon: ReactNode;
readonly title: ReactNode;
readonly description?: ReactNode;
- readonly children?: ReactNode;
readonly actions?: ReactNode;
readonly className?: string;
+ readonly actionClassName?: string;
readonly dismissLabel?: string;
readonly onDismiss?: () => void;
}
-export type ComposerBannerStackContent = Pick<
- ComposerBannerStackItem,
- "id" | "variant" | "priority" | "className"
-> & { readonly content: ReactNode };
-
-type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent;
-
-function bannerPriority(item: ComposerBannerStackEntry) {
- if (item.priority === "activity") {
- return 0;
- }
- if (item.priority === "urgent" || item.variant === "error" || item.variant === "warning") {
- return 1;
- }
- return 2;
-}
-
interface ComposerBannerStackProps {
readonly className?: string;
- readonly items: ReadonlyArray;
+ readonly items: ReadonlyArray;
}
export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) {
- const [stackExpanded, setStackExpanded] = useState(false);
- const noticesRef = useRef(null);
- const peekRef = useRef(null);
- const expandedItemsId = useId();
const [requestedExitingItemId, setExitingItemId] = useState(null);
const dismissTimeoutRef = useRef | null>(null);
const exitingItemId =
@@ -62,27 +70,21 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
};
}, []);
- useEffect(() => {
- if (items.length < 2) setStackExpanded(false);
- }, [items.length]);
-
if (items.length === 0) {
return null;
}
- // Activity stays attached. Urgency and severity only order the notices behind it.
- const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b));
- const frontItem = orderedItems[0];
+ const frontItem = items[0];
if (!frontItem) {
return null;
}
- const stackedItems = orderedItems.slice(1);
+ const stackedItems = items.slice(1);
const hasStack = stackedItems.length > 0;
const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;
const firstStackedItem = stackedItems[0];
- const requestDismiss = (item: ComposerBannerStackEntry) => {
- if (!("onDismiss" in item) || !item.onDismiss || exitingItemId) {
+ const requestDismiss = (item: ComposerBannerStackItem) => {
+ if (!item.onDismiss || exitingItemId) {
return;
}
setExitingItemId(item.id);
@@ -96,28 +98,37 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
};
return (
-
-
+
+ {showCollapsedStackCap && firstStackedItem ? (
+
+ ) : null}
{
- setStackExpanded(false);
- const activeElement = document.activeElement;
- if (
- activeElement instanceof HTMLElement &&
- noticesRef.current?.contains(activeElement)
- ) {
- activeElement.blur();
- }
+ style={{
+ ...exitTransitionStyle,
+ ...(exitingItemId === frontItem.id ? frontExitStyle : restingStyle),
}}
>
{hasStack ? (
{
- if (event.pointerType !== "touch") setStackExpanded(true);
- }}
- onPointerLeave={(event) => {
- if (!event.currentTarget.contains(document.activeElement)) setStackExpanded(false);
- }}
- onFocusCapture={() => setStackExpanded(true)}
- onBlurCapture={(event) => {
- if (
- !event.currentTarget.contains(event.relatedTarget) &&
- !event.currentTarget.matches(":hover")
- ) {
- setStackExpanded(false);
- }
- }}
- onKeyDown={(event) => {
- if (event.key !== "Escape") return;
- event.stopPropagation();
- peekRef.current?.focus({ preventScroll: true });
- setStackExpanded(false);
- }}
+ data-composer-banner-stack-expanded-items="true"
+ className={cn(
+ "relative z-20 grid grid-rows-[0fr] transition-[grid-template-rows] duration-150 ease-out",
+ "group-hover/banner-stack:grid-rows-[1fr] group-focus-within/banner-stack:grid-rows-[1fr]",
+ )}
>
- {showCollapsedStackCap && firstStackedItem ? (
-
{
- event.currentTarget.focus({ preventScroll: true });
- setStackExpanded(true);
- }}
- className={cn(stackExpanded && "opacity-0")}
- />
- ) : null}
-
-
-
- {stackedItems.map((item) => (
-
- requestDismiss(item)}
- />
-
- ))}
-
+
+
+ {stackedItems.map((item) => (
+
+ requestDismiss(item)}
+ />
+
+ ))}
) : null}
-
+
);
}
@@ -218,59 +187,50 @@ function ComposerBannerStackAlert({
exiting,
onDismissRequest,
}: {
- readonly item: ComposerBannerStackEntry;
+ readonly item: ComposerBannerStackItem;
readonly attached: boolean;
readonly exiting: boolean;
readonly onDismissRequest: () => void;
}) {
- if ("content" in item) {
- return (
-
- {item.content}
-
- );
- }
+ const dismissOnly = item.onDismiss && !item.actions;
return (
-
-
- {item.icon}
- {item.title}
- {item.actions || item.onDismiss ? (
-
- {item.actions}
- {item.onDismiss ? (
-
- ) : null}
-
- ) : null}
-
- {item.description || item.children ? (
-
- {item.description ? (
-
-
-
- {item.description}
-
-
+ {item.icon}
+ {item.title}
+ {item.description ? {item.description} : null}
+ {item.actions || item.onDismiss ? (
+
+ {item.actions}
+ {item.onDismiss ? (
+
+
+
) : null}
- {item.children}
-
+
) : null}
-
+
);
}
diff --git a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx
index 6435820c2e7b65fea8026d9f59cd4cd39a4c5e46..cf0a1747dd4c0c1a1b3d0842f25cac1fd175f476 100644
--- a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx
+++ b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx
@@ -19,6 +19,8 @@ describe("ComposerCommandMenu", () => {
);
expect(markup).toContain('data-composer-command-drawer="true"');
+ expect(markup).toContain("chat-composer-drawer-surface");
+ expect(markup).toContain("chat-composer-drawer-attached");
expect(markup).not.toContain("dropdown-glass");
});
diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx
index 74cd98f361bcd7fd377454bd98b14b4875b31d68..72267fcc994855e6ebb0c58cbdfdc649bae15609 100644
--- a/apps/web/src/components/chat/ComposerCommandMenu.tsx
+++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx
@@ -24,7 +24,6 @@ import { cn } from "~/lib/utils";
import { Badge } from "../ui/badge";
import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
import { PierreEntryIcon } from "./PierreEntryIcon";
-import { ComposerBanner } from "./ComposerBanner";
export type ComposerCommandItem =
| {
@@ -89,9 +88,9 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
);
}}
>
-
{props.items.length > 0 ? (
@@ -126,7 +125,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
)}
-
+
);
});
diff --git a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx
index deb5ba54c53ce60184602013ebab66f0acbbc961..5c6aedb040c591b8f9670ad0b82092f10336ceb4 100644
--- a/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx
+++ b/apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx
@@ -31,7 +31,7 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
: "File change";
return (
-
) : null}
-
+
);
});
diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
index 596fd93a68b7320996cee4f27abe9a6718c52147..c121110bb3f0f825765682f687bf2e144a964a32 100644
--- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
+++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
@@ -5,10 +5,9 @@ import {
derivePendingUserInputProgress,
type PendingUserInputDraftAnswer,
} from "../../pendingUserInput";
-import { CheckIcon } from "lucide-react";
+import { CheckIcon, ChevronDownIcon } from "lucide-react";
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
import { cn } from "~/lib/utils";
-import { ComposerBanner } from "./ComposerBanner";
interface PendingUserInputPanelProps {
pendingUserInputs: PendingUserInput[];
@@ -168,40 +167,58 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
return (
{
setCollapsedQuestionId(open ? null : activeQuestion.id);
}}
>
- } />}
- title={
- isCollapsed ? "Show the question and its options" : "Hide the question and its options"
- }
- data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}
- >
-
-
-
+ {/* The trigger's wrapper is inset less than the card's text column, and
+ the trigger pays the difference back as padding: the hover background
+ and focus ring bleed 10px past that column on both sides, while the
+ header label and the chevron still line up with the left and right
+ edges of the question text below. The negative block margin keeps the
+ taller hit area from pushing the panel down. */}
+
+
+
{activeQuestion.header}
- {isCollapsed ? (
-
- {activeQuestion.question}
-
- ) : null}
-
-
{prompt.questions.length > 1 ? (
{questionIndex + 1}/{prompt.questions.length}
) : null}
-
-
-
-
-
+ {/* Collapsed, the header is otherwise just a section label and a
+ counter, so the question itself is echoed here as a one-line
+ reminder of what is being asked. */}
+ {isCollapsed ? (
+
+ {activeQuestion.question}
+
+ ) : null}
+ {/* The chevron points at the body: down while it is open below the
+ header, up while it is collapsed into it. */}
+
+
+
+ {/* The panel carries the horizontal padding itself: it clips its content
+ while the height animates, so the option buttons have to sit inside
+ that padding or their focus rings get shaved off at the edges. */}
+
+
{activeQuestion.question}
{activeQuestion.multiSelect ? (
Select one or more options.
@@ -259,7 +276,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
);
})}
-
+
);
diff --git a/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx
index aeffd9638065c65470e23facebbf0d3e0c8c0aab..b734a9eaea8fdf67cff1320bdfb1ac103d40005e 100644
--- a/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx
+++ b/apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx
@@ -1,5 +1,4 @@
import { memo } from "react";
-import { ComposerBanner } from "./ComposerBanner";
export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({
planTitle,
@@ -7,14 +6,16 @@ export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBann
planTitle: string | null;
}) {
return (
-
-
-
- Plan ready
+
+
+ Plan ready
{planTitle ? (
- {planTitle}
+ {planTitle}
) : null}
-
-
+
+ {/*
+ Review the plan
+
*/}
+
);
});
diff --git a/apps/web/src/components/chat/ComposerServerUpdateStatus.tsx b/apps/web/src/components/chat/ComposerServerUpdateStatus.tsx
deleted file mode 100644
index 4133bde44b2dc2b7df0d35a87dca1e6a574c07e5..0000000000000000000000000000000000000000
--- a/apps/web/src/components/chat/ComposerServerUpdateStatus.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
-import { useId, useState } from "react";
-
-import { serverUpdateStageLabel } from "../ServerUpdateAction";
-import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
-import { ComposerBanner } from "./ComposerBanner";
-
-/** One text line, clipped at the end so the error detail never squeezes its title. */
-export function ComposerServerUpdateStatus({
- state,
- serverLabel = "server",
-}: {
- readonly state: Exclude;
- readonly serverLabel?: string;
-}) {
- const [detailsOpen, setDetailsOpen] = useState(false);
- const triggerId = useId();
- const title = `${state.status === "failed" ? "Could not update" : "Updating"} ${serverLabel}`;
- const detail = state.status === "failed" ? state.message : serverUpdateStageLabel(state.stage);
- return (
-
-
- setDetailsOpen(true)}
- >
- {title}
-
- {detail}
-
- }
- />
-
- {title}: {detail}
-
-
-
- );
-}
diff --git a/apps/web/src/components/chat/ComposerStashBadge.test.tsx b/apps/web/src/components/chat/ComposerStashBadge.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..0e3839e6e02e8022b4bb854e39e950f9464eb720
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerStashBadge.test.tsx
@@ -0,0 +1,68 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vite-plus/test";
+
+import { ComposerStashBadge } from "./ComposerStashBadge";
+
+describe("ComposerStashBadge", () => {
+ it("renders as an attached composer tab instead of a floating pill", () => {
+ const markup = renderToStaticMarkup(
+ {}}
+ />,
+ );
+
+ expect(markup).toContain("chat-composer-stash-tab");
+ expect(markup).toContain("rounded-t-xl");
+ expect(markup).toContain("border-b-0");
+ expect(markup).toContain("items-center");
+ expect(markup).not.toContain("items-start");
+ expect(markup).not.toContain("rounded-full");
+ expect(markup).not.toContain("opacity-70");
+ expect(markup).not.toContain("border-border");
+ expect(markup).toContain('aria-expanded="false"');
+ });
+
+ it("reports when the stash drawer is open", () => {
+ const markup = renderToStaticMarkup(
+ {}}
+ />,
+ );
+
+ expect(markup).toContain('aria-expanded="true"');
+ expect(markup).toContain("text-foreground");
+ expect(markup).toContain("pointer-events-none");
+ expect(markup).not.toContain("invisible");
+ });
+
+ it("keeps a compact inline entry point when a drawer occupies the tab", () => {
+ const markup = renderToStaticMarkup(
+ {}}
+ />,
+ );
+
+ expect(markup).toContain('data-slot="button"');
+ expect(markup).toContain("rounded-sm");
+ expect(markup).toContain("focus-visible:ring-2");
+ expect(markup).toContain("pointer-coarse:after:min-h-11");
+ expect(markup).toContain("Stashed prompts: 3. Open stash.");
+ expect(markup).toContain('aria-expanded="true"');
+ expect(markup).not.toContain("chat-composer-stash-tab");
+ expect(markup).not.toContain("rounded-t-xl");
+ expect(markup).not.toContain(" pointer-events-none ");
+ });
+});
diff --git a/apps/web/src/components/chat/ComposerStashBadge.tsx b/apps/web/src/components/chat/ComposerStashBadge.tsx
index 4b83701b858b50ca88eb3151b6dd7685df4a70c1..7682ab562fd5693b59634e9754ea07beafa65c1b 100644
--- a/apps/web/src/components/chat/ComposerStashBadge.tsx
+++ b/apps/web/src/components/chat/ComposerStashBadge.tsx
@@ -3,7 +3,6 @@ import { memo } from "react";
import { cn } from "~/lib/utils";
import { Button } from "../ui/button";
-import { ComposerBanner } from "./ComposerBanner";
/**
* Bookmark control that shows the stash count and opens the stash menu. It
@@ -25,16 +24,17 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
if (props.count === 0) return null;
const inline = props.placement === "inline";
const count = (
-
{props.count}
-
+
);
if (inline) {
@@ -46,7 +46,7 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
aria-label={`Stashed prompts: ${props.count}. Open stash.`}
aria-expanded={props.menuOpen}
className={cn(
- "shrink-0 gap-1 px-1.5 text-xs sm:text-xs",
+ "shrink-0 gap-1 px-1.5",
(props.menuOpen || props.pulsing) &&
"[--control-icon-color:currentColor] text-foreground",
)}
@@ -60,28 +60,28 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
}
return (
-
- }
- data-prompt-stash-badge="true"
- aria-label={`Stashed prompts: ${props.count}. Open stash.`}
- aria-expanded={props.menuOpen}
- className={cn(
- "transition-colors duration-200",
- props.menuOpen && "pointer-events-none",
- props.menuOpen || props.pulsing
- ? "text-foreground"
- : "text-muted-foreground hover:text-foreground",
- )}
- onPointerDown={(event) => event.preventDefault()}
- onClick={props.onToggleMenu}
- >
-
-
-
- Stash
- {count}
-
-
+ {
+ // Keep composer focus so Escape/typing flows stay intact.
+ event.preventDefault();
+ }}
+ onClick={props.onToggleMenu}
+ >
+
+ Stash
+ {count}
+
);
});
diff --git a/apps/web/src/components/chat/ComposerStashMenu.test.tsx b/apps/web/src/components/chat/ComposerStashMenu.test.tsx
index 47b59a7f592fcda42b4199e0e915f5e10ad8f2e2..517a027d886c2b7da8a8da284ae7acb56c5ea6b0 100644
--- a/apps/web/src/components/chat/ComposerStashMenu.test.tsx
+++ b/apps/web/src/components/chat/ComposerStashMenu.test.tsx
@@ -5,6 +5,41 @@ import { describe, expect, it } from "vite-plus/test";
import { ComposerStashMenu } from "./ComposerStashMenu";
describe("ComposerStashMenu", () => {
+ it("renders saved prompts as an attached composer drawer", () => {
+ const markup = renderToStaticMarkup(
+ {}}
+ onDelete={() => {}}
+ onClose={() => {}}
+ />,
+ );
+
+ expect(markup).toContain('data-composer-stash-drawer="true"');
+ expect(markup).toContain("chat-composer-drawer-surface");
+ expect(markup).toContain("chat-composer-drawer-attached");
+ expect(markup).toContain('aria-label="Close stash"');
+ expect(markup).not.toContain("dropdown-glass");
+ expect(markup).not.toContain("Stashed prompts");
+ expect(markup).toContain("Press Ctrl+S with a prompt in the composer to stash it.");
+ });
+
+ it("does not advertise a shortcut when stash is unbound", () => {
+ const markup = renderToStaticMarkup(
+ {}}
+ onDelete={() => {}}
+ onClose={() => {}}
+ />,
+ );
+
+ expect(markup).toContain("Nothing stashed yet.");
+ expect(markup).not.toContain("Press");
+ });
+
it("shows saved image thumbnails and incomplete image states", () => {
const markup = renderToStaticMarkup(
{
expect(markup).toContain('src="data:image/png;base64,AA=="');
expect(markup).toContain("1 image dropped");
expect(markup).toContain("saving 1 image");
+ expect(markup).not.toContain("absolute top-1/2 right-2");
+ expect(markup).toContain("pointer-events-none");
+ expect(markup).toContain("pointer-coarse:pointer-events-auto");
+ expect(markup).toContain("pointer-coarse:opacity-100");
+ expect(markup).not.toContain("bg-popover!");
+ expect(markup).toContain("[--control-icon-color:currentColor]");
+ expect(markup).toContain("size-3.5 stroke-2");
+ expect(markup).not.toContain("bg-background/90");
});
it("labels mixed file and image stashes without treating images as files", () => {
@@ -86,6 +129,7 @@ describe("ComposerStashMenu", () => {
);
expect(markup).toContain("(2 attachments)");
+ expect(markup).toContain("size-3.5 text-secondary-label");
expect(markup).not.toContain("(2 files)");
});
});
diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx
index d769c9823ab85fc2bc0fb521fa3a79a5d580a201..f719e563d2338efbbeb27afcec1f2266f82704d3 100644
--- a/apps/web/src/components/chat/ComposerStashMenu.tsx
+++ b/apps/web/src/components/chat/ComposerStashMenu.tsx
@@ -1,10 +1,11 @@
-import { BookmarkIcon, FileIcon, FileTextIcon } from "lucide-react";
+import { FileIcon, XIcon } from "lucide-react";
import { memo, useEffect, useRef, useState } from "react";
import { formatRelativeTimeLabel } from "../../timestampFormat";
import { cn } from "~/lib/utils";
import { type PromptStashEntry } from "../../promptStashStore";
-import { ComposerBanner } from "./ComposerBanner";
+import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
+import { Button } from "../ui/button";
const SNIPPET_MAX_CHARS = 90;
@@ -29,7 +30,7 @@ function stashEntrySnippet(entry: PromptStashEntry): string {
}
/**
- * Attached banner listing the stashed prompts. Keyboard-first: opened by ⌘S on an
+ * Popover listing the stashed prompts. Keyboard-first: opened by ⌘S on an
* empty composer, navigated with arrows, restored with Enter, dismissed
* with Escape. The listener runs capture-phase on window so it wins over
* the Lexical editor's handlers while the menu is open.
@@ -63,6 +64,13 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true);
}, [onClose]);
+ useEffect(() => {
+ if (entries.length === 0) return;
+ if (!entries.some((entry) => entry.id === highlightedId)) {
+ setHighlightedId(entries[0]?.id ?? null);
+ }
+ }, [entries, highlightedId]);
+
useEffect(() => {
const handler = (event: KeyboardEvent) => {
if (event.key === "Escape") {
@@ -75,17 +83,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
if (entries.length === 0) return;
event.preventDefault();
event.stopPropagation();
- const currentIndex = entries.findIndex((entry) => entry.id === highlightedEntry?.id);
+ const currentIndex = entries.findIndex((entry) => entry.id === highlightedId);
const offset = event.key === "ArrowDown" ? 1 : -1;
const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0;
const nextIndex = (normalizedIndex + offset + entries.length) % entries.length;
setHighlightedId(entries[nextIndex]?.id ?? null);
- const nextButton =
- drawerRef.current?.querySelectorAll("[data-stash-restore]")[nextIndex];
- nextButton?.scrollIntoView({ block: "nearest" });
- if (drawerRef.current?.contains(document.activeElement)) {
- nextButton?.focus({ preventScroll: true });
- }
return;
}
if (event.key === "Enter") {
@@ -109,77 +111,64 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
};
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
- }, [entries, highlightedEntry, onClose, onDelete, onRestore]);
+ }, [entries, highlightedEntry, highlightedId, onClose, onDelete, onRestore]);
return (
-
- }
- aria-label="Close stash"
- aria-expanded="true"
- onPointerDown={(event) => event.preventDefault()}
- onClick={onClose}
+
+
-
-
-
-
Stash
-
- {entries.length}
-
-
-
-
- } aria-label="Stashed prompts">
- {entries.length === 0 ? (
- }>
-
-
+
+ event.preventDefault()}
+ onClick={onClose}
+ >
+
+
+
+
+
+ {entries.length === 0 ? (
+
Nothing stashed yet.
{stashShortcutLabel
? ` Press ${stashShortcutLabel} with a prompt in the composer to stash it.`
: null}
-
-
- ) : (
- entries.map((entry) => (
- }
- key={entry.id}
- data-stash-entry={entry.id}
- data-highlighted={highlightedEntry?.id === entry.id || undefined}
- className={cn(
- "relative rounded-sm",
- highlightedEntry?.id === entry.id && "bg-accent text-accent-foreground",
- )}
- onMouseMove={() => {
- if (highlightedId !== entry.id) setHighlightedId(entry.id);
- }}
- onFocus={() => setHighlightedId(entry.id)}
- >
-
-
-
-
- event.preventDefault()}
- onClick={() => onRestore(entry)}
- >
+
+ ) : (
+ entries.map((entry) => (
+ {
+ if (highlightedId !== entry.id) setHighlightedId(entry.id);
+ }}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ }}
+ onClick={() => {
+ onRestore(entry);
+ }}
+ >
+
{stashEntrySnippet(entry)}
-
-
-
+
{entry.pendingImageCount ? (
-
+
saving {entry.pendingImageCount} image
{entry.pendingImageCount === 1 ? "" : "s"}…
) : missingImageCount(entry) > 0 ? (
-
+
{missingImageCount(entry)} image
{missingImageCount(entry) === 1 ? "" : "s"} dropped
@@ -192,35 +181,38 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
src={attachment.dataUrl}
alt=""
aria-hidden="true"
- className="size-4 rounded border border-border/70 object-cover"
+ className="size-5 rounded border border-border/70 object-cover"
/>
))}
) : null}
{(entry.files?.length ?? 0) > 0 ? (
-
-
+
+
{entry.files!.length}
) : null}
-
+
{formatRelativeTimeLabel(entry.createdAt)}
-
-
+ event.preventDefault()}
- onClick={() => onDelete(entry)}
- />
-
-
- ))
- )}
-
-
-
+ onClick={(event) => {
+ event.stopPropagation();
+ onDelete(entry);
+ }}
+ >
+
+
+
+ ))
+ )}
+
+
+
+
);
});
diff --git a/apps/web/src/components/chat/ComposerSurface.tsx b/apps/web/src/components/chat/ComposerSurface.tsx
deleted file mode 100644
index 80a9a6c2d5d46010170b2659f7e5f75c24c38cbb..0000000000000000000000000000000000000000
--- a/apps/web/src/components/chat/ComposerSurface.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import type { ComponentProps } from "react";
-
-import { cn } from "~/lib/utils";
-
-/** One glass backdrop until a top attachment needs the composer to cover its overlap. */
-function Shell({
- contextStrip = false,
- className,
- ...props
-}: ComponentProps<"div"> & { contextStrip?: boolean }) {
- return (
-
- );
-}
-
-const outlineClasses =
- "after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:border after:border-(--chat-composer-outline) dark:after:shadow-[inset_0_1px_var(--chat-composer-highlight)]";
-
-// The bottom strip continues the outline, so leave the seam between its corners open.
-const contextSeamClasses =
- "group-data-with-context/composer-surface:after:[clip-path:polygon(0_0,100%_0,100%_100%,calc(100%-22px)_100%,calc(100%-22px)_calc(100%-2px),22px_calc(100%-2px),22px_100%,0_100%)]";
-
-function Host({ className, ...props }: ComponentProps<"div">) {
- return (
-
- );
-}
-
-function Main({ className, ...props }: ComponentProps<"div">) {
- return (
-
- );
-}
-
-function ContextStrip({ className, ...props }: ComponentProps<"div">) {
- return (
-
- );
-}
-
-export const ComposerSurface = { Shell, Host, Main, ContextStrip };
diff --git a/apps/web/src/components/chat/ComposerTasksBadge.test.tsx b/apps/web/src/components/chat/ComposerTasksBadge.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3f17143e53e93ba4a2d6d9448fee12038a20b1ad
--- /dev/null
+++ b/apps/web/src/components/chat/ComposerTasksBadge.test.tsx
@@ -0,0 +1,190 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vite-plus/test";
+
+import { ComposerTasksBadge, ComposerTasksDrawer } from "./ComposerTasksBadge";
+
+const progress = {
+ step: "Attach task progress",
+ completedSteps: 1,
+ totalSteps: 3,
+};
+const steps = [
+ { durationMs: 4_000, step: "Inspect the composer", status: "completed" as const },
+ { step: "Attach task progress", status: "inProgress" as const },
+ { step: "Verify the result", status: "pending" as const },
+];
+
+describe("ComposerTasksBadge", () => {
+ it("renders active progress as an attached composer tab", () => {
+ const markup = renderToStaticMarkup(
+ undefined}
+ onToggle={() => undefined}
+ progress={progress}
+ steps={steps}
+ />,
+ );
+
+ expect(markup).toContain('data-composer-tasks-badge="true"');
+ expect(markup).toContain('aria-expanded="false"');
+ expect(markup).toContain("chat-composer-shoulder-tab");
+ expect(markup).toContain("chat-composer-tasks-tab");
+ expect(markup).toContain("rounded-t-xl");
+ expect(markup).toContain("border-b-0");
+ expect(markup).toContain("left-5.5");
+ expect(markup).toContain("right-5.5");
+ expect(markup).toContain('data-composer-task-current="true"');
+ expect(markup).toContain("min-w-0 flex-1 truncate");
+ expect(markup).toContain("w-20");
+ expect(markup).toContain("Tasks");
+ expect(markup).toContain("Attach task progress");
+ expect(markup).not.toContain("·");
+ expect(markup).toContain("1/3");
+ expect(markup).toContain("Current task: Attach task progress");
+ expect(markup).toContain("lucide-list-todo");
+ expect(markup).toContain('aria-label="Dismiss tasks for this turn"');
+ expect(markup).toContain("lucide-x");
+ expect(markup).not.toContain("lucide-chevron");
+ expect(markup).toContain("bg-success");
+ expect(markup).toContain("bg-primary");
+ expect(markup).toContain("bg-muted-foreground/25");
+ });
+
+ it("leaves room for the stash tab when both shoulders are present", () => {
+ const markup = renderToStaticMarkup(
+ undefined}
+ onToggle={() => undefined}
+ progress={progress}
+ steps={steps}
+ />,
+ );
+
+ expect(markup).toContain("right-30");
+ expect(markup).not.toContain("right-5.5");
+ });
+
+ it("has a compact inline fallback for occupied composer shoulders", () => {
+ const markup = renderToStaticMarkup(
+ undefined}
+ onToggle={() => undefined}
+ placement="inline"
+ progress={progress}
+ steps={steps}
+ />,
+ );
+
+ expect(markup).toContain("rounded-sm");
+ expect(markup).toContain("1/3");
+ expect(markup).not.toContain("chat-composer-shoulder-tab");
+ expect(markup).not.toContain("rounded-t-xl");
+ });
+
+ it("expands into a read-only attached task list", () => {
+ const markup = renderToStaticMarkup(
+ undefined}
+ onDismiss={() => undefined}
+ progress={progress}
+ steps={steps}
+ />,
+ );
+
+ expect(markup).toContain('data-chat-composer-tasks-drawer="true"');
+ expect(markup).toContain('data-chat-composer-collapsed-controls="true"');
+ expect(markup).not.toContain("data-variant");
+ expect(markup).toContain('aria-expanded="true"');
+ expect(markup).toContain('aria-label="Collapse tasks. 1 of 3 complete."');
+ expect(markup).toContain('aria-label="Task list. 1 of 3 complete."');
+ expect(markup).toContain('role="list"');
+ expect(markup).toContain('data-composer-tasks-list="true"');
+ expect(markup).toContain('tabindex="0"');
+ expect(markup).toContain("max-h-[min(24rem,40dvh)]");
+ expect(markup).toContain("overflow-y-auto");
+ expect(markup).toContain("overscroll-contain");
+ expect(markup).toContain("focus-visible:ring-2");
+ expect(markup).toContain("focus-visible:ring-inset");
+ expect(markup).toContain("Inspect the composer");
+ expect(markup).toContain('data-composer-task-duration="true"');
+ expect(markup).toContain("ml-auto w-10");
+ expect(markup).toContain("4.0s");
+ expect(markup).toContain("now");
+ expect(markup).toContain("Attach task progress");
+ expect(markup).toContain("Verify the result");
+ expect(markup).toContain("lucide-list-todo");
+ expect(markup).toContain("lucide-chevron-down");
+ expect(markup).toContain('aria-label="Dismiss tasks for this turn"');
+ expect(markup).not.toContain("bg-success");
+ expect(markup).not.toContain("bg-primary");
+ expect(markup).not.toContain("bg-muted-foreground/25");
+ });
+
+ it("drops the step segments when they would render as a blank gap", () => {
+ const manySteps = Array.from({ length: 24 }, (_, index) => ({
+ step: `Step ${index + 1}`,
+ status: "pending" as const,
+ }));
+ const tab = renderToStaticMarkup(
+ undefined}
+ onToggle={() => undefined}
+ progress={{ ...progress, totalSteps: manySteps.length }}
+ steps={manySteps}
+ />,
+ );
+ const inline = renderToStaticMarkup(
+ undefined}
+ onToggle={() => undefined}
+ placement="inline"
+ progress={{ ...progress, totalSteps: manySteps.length }}
+ steps={manySteps}
+ />,
+ );
+
+ expect(tab).not.toContain("w-20");
+ expect(tab).toContain("1/24");
+ expect(inline).not.toContain("w-10");
+ expect(inline).toContain("1/24");
+ });
+
+ it("keeps every long-list task inside the bounded scroll region", () => {
+ const longSteps = Array.from({ length: 20 }, (_, index) => ({
+ step: `Task ${index + 1}`,
+ status: index === 0 ? ("inProgress" as const) : ("pending" as const),
+ }));
+ const markup = renderToStaticMarkup(
+ undefined}
+ onDismiss={() => undefined}
+ progress={{ step: "Task 1", completedSteps: 0, totalSteps: longSteps.length }}
+ steps={longSteps}
+ />,
+ );
+
+ const listStart = markup.indexOf('data-composer-tasks-list="true"');
+ expect(listStart).toBeGreaterThan(markup.indexOf('aria-label="Collapse tasks.'));
+ expect(markup.slice(listStart)).toContain("Task 20");
+ });
+
+ it("does not render an empty task count", () => {
+ const markup = renderToStaticMarkup(
+ undefined}
+ onToggle={() => undefined}
+ progress={{ ...progress, totalSteps: 0 }}
+ steps={steps}
+ />,
+ );
+
+ expect(markup).toBe("");
+ });
+});
diff --git a/apps/web/src/components/chat/ComposerTasksBadge.tsx b/apps/web/src/components/chat/ComposerTasksBadge.tsx
index b93143afeda81f84bb7229d85763a12ef8549766..d73ce819d82a12ae62f91640e66fdaa3ef030cb8 100644
--- a/apps/web/src/components/chat/ComposerTasksBadge.tsx
+++ b/apps/web/src/components/chat/ComposerTasksBadge.tsx
@@ -1,14 +1,9 @@
-import { ListTodoIcon } from "lucide-react";
-import { memo, type ComponentProps } from "react";
+import { ChevronDownIcon, ListTodoIcon, XIcon } from "lucide-react";
+import { memo } from "react";
import { formatDuration } from "../../session-logic";
import { cn } from "~/lib/utils";
-import { ComposerBanner } from "./ComposerBanner";
-import {
- ComposerActivityIcon,
- ComposerActivityLabel,
- type ComposerActivityStatus,
-} from "./ComposerActivityStatus";
+import { Button } from "../ui/button";
export interface ComposerTasksProgress {
readonly step: string;
@@ -61,185 +56,205 @@ function TaskSegments({
);
}
-function TaskSummary({
- expanded,
- progress,
- steps,
- activityStatus,
-}: {
- readonly expanded: boolean;
- readonly progress: ComposerTasksProgress;
- readonly steps: readonly ComposerTaskStep[];
- readonly activityStatus: ComposerActivityStatus | undefined;
-}) {
- return (
- <>
- {activityStatus !== undefined ? (
-
- ) : (
-
-
-
- )}
-
- {activityStatus !== undefined ? (
-
- ) : (
- Tasks
- )}
- {activityStatus !== undefined ? : null}
-
- {progress.step}
-
-
-
- = progress.totalSteps ? "text-success" : undefined}
- data-composer-task-progress="true"
- >
- {progress.completedSteps}/{progress.totalSteps}
-
-
-
-
- >
- );
-}
-
export const ComposerTasksBadge = memo(function ComposerTasksBadge({
expanded,
+ hasTrailingShoulder = false,
+ onDismiss,
onToggle,
placement = "tab",
progress,
steps,
- activityStatus,
}: {
readonly expanded: boolean;
+ readonly hasTrailingShoulder?: boolean;
+ readonly onDismiss: () => void;
readonly onToggle: () => void;
readonly placement?: "inline" | "tab";
readonly progress: ComposerTasksProgress;
readonly steps: readonly ComposerTaskStep[];
- readonly activityStatus?: ComposerActivityStatus | undefined;
}) {
if (progress.totalSteps <= 0) return null;
- const row = (
- }
- aria-expanded={expanded}
- aria-label={`${expanded ? "Collapse tasks" : "Tasks"}: ${progress.completedSteps} of ${progress.totalSteps} complete. Current task: ${progress.step}`}
- data-composer-tasks-badge="true"
- onClick={onToggle}
- onPointerDown={(event) => event.preventDefault()}
- >
-
-
- );
- return placement === "inline" ? (
- row
- ) : (
- {row}
- );
-});
-
-export const ComposerTasksContent = memo(function ComposerTasksContent({
- expanded,
- onToggle,
- progress,
- steps,
- activityStatus,
-}: {
- readonly expanded: boolean;
- readonly onToggle: () => void;
- readonly progress: ComposerTasksProgress;
- readonly steps: readonly ComposerTaskStep[];
- readonly activityStatus?: ComposerActivityStatus | undefined;
-}) {
- return (
-
-
- {expanded ? (
-
- }
- aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`}
- data-composer-tasks-list="true"
+ const allDone = progress.completedSteps >= progress.totalSteps;
+ const label = `Tasks: ${progress.completedSteps} of ${progress.totalSteps} complete. Current task: ${progress.step}`;
+ if (placement === "inline") {
+ return (
+
+ event.preventDefault()}
+ >
+
+ Tasks
+
+
- {keyedTaskSteps(steps).map(({ key, step }) => (
- }>
-
- {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}
-
-
- {step.step}
-
-
-
- {step.durationMs !== undefined
- ? formatDuration(step.durationMs)
- : step.status === "inProgress"
- ? "now"
- : null}
-
-
-
- ))}
-
-
- ) : null}
+ {progress.completedSteps}/{progress.totalSteps}
+
+
+
event.preventDefault()}
+ >
+
+
+
+ );
+ }
+
+ return (
+
+ event.preventDefault()}
+ >
+
+ Tasks
+
+ {progress.step}
+
+
+ {progress.completedSteps}/{progress.totalSteps}
+
+
+
+ event.preventDefault()}
+ >
+
+
);
});
export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
+ onDismiss,
onCollapse,
- ...props
-}: Omit
, "expanded" | "onToggle"> & {
+ progress,
+ steps,
+}: {
+ readonly onDismiss: () => void;
readonly onCollapse: () => void;
+ readonly progress: ComposerTasksProgress;
+ readonly steps: readonly ComposerTaskStep[];
}) {
return (
-
-
-
-
-
+
+
+ event.preventDefault()}
+ >
+
+ Tasks
+
+ {progress.completedSteps}/{progress.totalSteps}
+
+
+
+ event.preventDefault()}
+ >
+
+
+
+
+ {keyedTaskSteps(steps).map(({ key, step }) => (
+
+
+ {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}
+
+
+ {step.step}
+
+
+ {step.durationMs !== undefined
+ ? formatDuration(step.durationMs)
+ : step.status === "inProgress"
+ ? "now"
+ : null}
+
+
+ ))}
+
+
);
});
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
index bd6ffffe15dce9ae7ec60c8428076c54e0da0354..81c9fdacdb9a0da75c6ffd7802714d544247fad3 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts
@@ -321,6 +321,7 @@ describe("deriveMessagesTimelineRows", () => {
],
expandedTurnIds: new Set(["turn-1" as never]),
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -374,6 +375,7 @@ describe("deriveMessagesTimelineRows", () => {
completedAt: null,
},
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -430,6 +432,7 @@ describe("deriveMessagesTimelineRows", () => {
},
],
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map([
["assistant-1" as never, assistantTurnDiffSummary],
]),
@@ -510,6 +513,7 @@ describe("deriveMessagesTimelineRows", () => {
const collapsedRows = deriveMessagesTimelineRows({
timelineEntries,
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -533,6 +537,7 @@ describe("deriveMessagesTimelineRows", () => {
timelineEntries,
expandedTurnIds: new Set(["turn-1" as never]),
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -598,6 +603,7 @@ describe("deriveMessagesTimelineRows", () => {
const rows = deriveMessagesTimelineRows({
timelineEntries,
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -692,6 +698,7 @@ describe("deriveMessagesTimelineRows", () => {
completedAt: null,
},
isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:00:14Z",
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -728,6 +735,7 @@ describe("deriveMessagesTimelineRows", () => {
completedAt: "2026-01-01T00:00:47Z",
},
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -795,6 +803,7 @@ describe("deriveMessagesTimelineRows", () => {
completedAt: "2026-01-01T00:00:22Z",
},
isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:01:00Z",
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -803,6 +812,7 @@ describe("deriveMessagesTimelineRows", () => {
"turn-fold:turn-1",
"assistant-final-entry",
"user-followup-entry",
+ "working-indicator-row",
]);
const finalRow = rows.find((row) => row.id === "assistant-final-entry");
expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
@@ -845,12 +855,14 @@ describe("deriveMessagesTimelineRows", () => {
completedAt: null,
},
isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
expect(rows.map((row) => row.id)).toEqual([
+ "working-indicator-row",
"assistant-thought-entry",
"work-live:work-entry-1",
]);
@@ -912,11 +924,12 @@ describe("deriveMessagesTimelineRows", () => {
completedAt: null,
},
isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
- expect(rows.map((row) => row.kind)).toEqual(["work-live"]);
+ expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
entry: { id: "running-command" },
groupedEntries: [
@@ -982,11 +995,12 @@ describe("deriveMessagesTimelineRows", () => {
completedAt: null,
},
isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
- expect(rows.map((row) => row.kind)).toEqual(["work-toggle", "message", "work-live"]);
+ expect(rows.map((row) => row.kind)).toEqual(["working", "work-toggle", "message", "work-live"]);
expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
hiddenCount: 1,
summary: "Ran 1 command",
@@ -1048,11 +1062,12 @@ describe("deriveMessagesTimelineRows", () => {
completedAt: null,
},
isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
- expect(rows.map((row) => row.kind)).toEqual(["work-live", "message", "work-live"]);
+ expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]);
expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([
"first-running",
"second-running",
@@ -1094,6 +1109,7 @@ describe("deriveMessagesTimelineRows", () => {
],
latestTurn: null,
isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:01:00Z",
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -1154,6 +1170,7 @@ describe("deriveMessagesTimelineRows", () => {
completedAt: null,
},
isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -1189,11 +1206,12 @@ describe("deriveMessagesTimelineRows", () => {
completedAt: null,
},
isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
- expect(rows.map((row) => row.kind)).toEqual(["work-live"]);
+ expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
entry: { id: "latest-command" },
groupedEntries: [{ id: "latest-command" }],
@@ -1250,6 +1268,7 @@ describe("deriveMessagesTimelineRows", () => {
},
runningTurnId: "turn-2" as never,
isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:01:00Z",
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -1294,6 +1313,7 @@ describe("deriveMessagesTimelineRows", () => {
],
expandedTurnIds: new Set(["turn-1" as never]),
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -1331,6 +1351,7 @@ describe("deriveMessagesTimelineRows", () => {
completedAt: null,
},
isWorking: true,
+ activeTurnStartedAt: "2026-01-01T00:00:00Z",
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -1344,10 +1365,7 @@ describe("deriveMessagesTimelineRows", () => {
expect(assistantRow?.showAssistantCopyButton).toBe(false);
});
- it.each([
- ["tools", "tool", "Used 3 tools"],
- ["tools and status updates", "info", "Used 2 tools and received 1 update"],
- ] as const)("expands %s through the same activity group", (_, middleTone, summary) => {
+ it("models work log overflow expansion as inserted list rows", () => {
const timelineEntries = [
{
id: "work-entry-1",
@@ -1368,9 +1386,9 @@ describe("deriveMessagesTimelineRows", () => {
entry: {
id: "work-2",
createdAt: "2026-01-01T00:00:02Z",
- label: "Status updated",
+ label: "edit",
detail: "Editing MessagesTimeline.tsx",
- tone: middleTone,
+ tone: "tool" as const,
},
},
{
@@ -1390,6 +1408,7 @@ describe("deriveMessagesTimelineRows", () => {
const baseInput = {
timelineEntries,
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
};
@@ -1404,7 +1423,8 @@ describe("deriveMessagesTimelineRows", () => {
groupId: "work-group:work-entry-1",
hiddenCount: 3,
expanded: false,
- summary,
+ onlyToolEntries: true,
+ summary: "Used 3 tools",
});
expect(expandedRows.map((row) => row.id)).toEqual([
"work-toggle:work-entry-1",
@@ -1439,6 +1459,7 @@ describe("deriveMessagesTimelineRows", () => {
const rows = deriveMessagesTimelineRows({
timelineEntries,
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -1455,7 +1476,7 @@ describe("deriveMessagesTimelineRows", () => {
["an error-toned entry recovers", ["error", "info", "completed"], false],
["the final failure is hidden", ["completed", "failed", "info"], true],
["the final failure is visible", ["failed", "info", "failed"], true],
- ["the only failure is visible", ["completed", "info", "failed"], true],
+ ["the only failure is visible", ["completed", "info", "failed"], false],
] as const)(
"uses the final tool call for mixed work groups when %s",
(_, statuses, hasFailure) => {
@@ -1485,23 +1506,16 @@ describe("deriveMessagesTimelineRows", () => {
const rows = deriveMessagesTimelineRows({
timelineEntries,
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
- hiddenCount: statuses.some((status) => status === "error") ? 2 : 3,
- summary: statuses.some((status) => status === "error")
- ? "Received 1 update and used 1 tool"
- : "Used 2 tools and received 1 update",
+ hiddenCount: 2,
+ summary: null,
hasFailure,
});
- if (statuses.some((status) => status === "error")) {
- expect(rows[0]).toMatchObject({
- kind: "work",
- groupedEntries: [{ tone: "error", label: "Command failed" }],
- });
- }
},
);
});
@@ -1543,6 +1557,7 @@ describe("computeStableMessagesTimelineRows", () => {
},
],
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -1591,6 +1606,7 @@ describe("computeStableMessagesTimelineRows", () => {
},
],
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
@@ -1646,6 +1662,7 @@ describe("computeStableMessagesTimelineRows", () => {
},
],
isWorking: false,
+ activeTurnStartedAt: null,
turnDiffSummaryByAssistantMessageId: new Map(),
revertTurnCountByUserMessageId: new Map(),
});
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index 895141f2bef143f1bb30a61611a85c41a099b5d4..d398583430f55ecba7f81961d499f4222b991024 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -11,6 +11,7 @@ import {
import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
+export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
export const TIMELINE_MINIMAP_ITEM_SPACING = 8;
export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
@@ -201,8 +202,9 @@ export type MessagesTimelineRow =
groupId: string;
hiddenCount: number;
expanded: boolean;
- summary: string;
- summaryKind: ToolGroupSummaryKind;
+ onlyToolEntries: boolean;
+ summary: string | null;
+ summaryKind: ToolGroupSummaryKind | null;
hasFailure: boolean;
}
| {
@@ -236,6 +238,12 @@ export type MessagesTimelineRow =
id: string;
createdAt: string;
turnPlan: TurnPlanEntry;
+ }
+ | {
+ kind: "working";
+ id: string;
+ createdAt: string | null;
+ showThinking: boolean;
};
export interface StableMessagesTimelineRowsState {
@@ -266,7 +274,7 @@ export function normalizeCompactToolLabel(value: string): string {
return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
}
-type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update";
+type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other";
type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed";
export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {
@@ -296,7 +304,7 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
}
if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
if (entry.itemType === "web_search") return "search";
- return workLogEntryIsToolLike(entry) ? "other" : "update";
+ return "other";
}
function toolGroupActionCount(
@@ -331,8 +339,6 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
case "other":
return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
- case "update":
- return `Received ${count} ${count === 1 ? "update" : "updates"}`;
}
}
@@ -658,6 +664,7 @@ export function deriveMessagesTimelineRows(input: {
expandedTurnIds?: ReadonlySet;
expandedWorkGroupIds?: ReadonlySet;
isWorking: boolean;
+ activeTurnStartedAt: string | null;
turnDiffSummaryByAssistantMessageId: ReadonlyMap;
revertTurnCountByUserMessageId: ReadonlyMap;
}): MessagesTimelineRow[] {
@@ -707,6 +714,26 @@ export function deriveMessagesTimelineRows(input: {
unsettledTurnId !== null &&
entry.toolLifecycleStatus === "inProgress" &&
entry.turnId === unsettledTurnId;
+ const isVisibleActiveToolEntry = (entry: WorkLogEntry) =>
+ workLogEntryIsToolLike(entry) && workEntryIsVisibleInGroup(entry, true);
+ const activeEntries = input.isWorking
+ ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
+ : [];
+ const activeTurnHasVisibleContent = activeEntries.some((entry) => {
+ if (entry.kind === "message") {
+ return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0;
+ }
+ if (entry.kind === "work") {
+ return (
+ entry.entry.agentSpawn === undefined &&
+ workLogEntryIsToolLike(entry.entry) &&
+ entry.entry.toolLifecycleStatus === "inProgress"
+ );
+ }
+ if (entry.kind === "proposed-plan" || entry.kind === "turn-plan") return true;
+ return false;
+ });
+
const activeToolEntries: Array> = [];
for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {
const entry = input.timelineEntries[index]!;
@@ -714,7 +741,8 @@ export function deriveMessagesTimelineRows(input: {
!entryBelongsToActiveTurn(entry, index) ||
entry.kind !== "work" ||
entry.entry.agentSpawn !== undefined ||
- entry.entry.tone === "error"
+ entry.entry.tone === "error" ||
+ !workLogEntryIsToolLike(entry.entry)
) {
break;
}
@@ -722,7 +750,7 @@ export function deriveMessagesTimelineRows(input: {
}
const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
- activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),
+ activeToolEntries.filter((entry) => isVisibleActiveToolEntry(entry.entry)),
(entry) => entry.entry,
);
const activeWorkAnchor = activeToolEntries[0];
@@ -743,6 +771,14 @@ export function deriveMessagesTimelineRows(input: {
};
})()
: null;
+ const appendWorkingRow = () => {
+ nextRows.push({
+ kind: "working",
+ id: "working-indicator-row",
+ createdAt: input.activeTurnStartedAt,
+ showThinking: activeWorkRow === null && !activeTurnHasVisibleContent,
+ });
+ };
const appendActiveWorkRows = () => {
if (activeWorkRow === null) return;
nextRows.push(activeWorkRow);
@@ -765,6 +801,10 @@ export function deriveMessagesTimelineRows(input: {
continue;
}
+ if (input.isWorking && index === activeTurnHeaderIndex) {
+ appendWorkingRow();
+ }
+
if (timelineEntry.id === activeWorkPlacementEntryId) {
appendActiveWorkRows();
}
@@ -790,17 +830,6 @@ export function deriveMessagesTimelineRows(input: {
}
if (timelineEntry.kind === "work") {
- if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") {
- nextRows.push({
- kind: "work",
- id: timelineEntry.id,
- createdAt: timelineEntry.createdAt,
- groupedEntries: [timelineEntry.entry],
- isExpandedToolGroupEntry: false,
- isLastExpandedToolGroupEntry: false,
- });
- continue;
- }
const groupedEntries = [timelineEntry.entry];
let cursor = index + 1;
while (cursor < input.timelineEntries.length) {
@@ -808,8 +837,6 @@ export function deriveMessagesTimelineRows(input: {
if (
!nextEntry ||
nextEntry.kind !== "work" ||
- nextEntry.entry.agentSpawn !== undefined ||
- nextEntry.entry.tone === "error" ||
activeWorkEntryIds.has(nextEntry.id) ||
collapsedEntryIds.has(nextEntry.id) ||
foldsByAnchorEntryId.has(nextEntry.id)
@@ -826,8 +853,14 @@ export function deriveMessagesTimelineRows(input: {
(entry) => entry,
);
if (visibleGroupedEntries.length > 0) {
+ const onlyToolEntries = visibleGroupedEntries.every(
+ (entry) =>
+ workLogEntryIsToolLike(entry) &&
+ entry.agentSpawn === undefined &&
+ entry.tone !== "error",
+ );
const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);
- if (activeInProgressToolEntries.length > 0) {
+ if (onlyToolEntries && activeInProgressToolEntries.length > 0) {
const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;
@@ -852,11 +885,10 @@ export function deriveMessagesTimelineRows(input: {
});
}
}
- } else {
+ } else if (onlyToolEntries) {
const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
- const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
nextRows.push({
kind: "work-toggle",
id: `work-toggle:${timelineEntry.id}`,
@@ -864,15 +896,10 @@ export function deriveMessagesTimelineRows(input: {
groupId,
hiddenCount: visibleGroupedEntries.length,
expanded,
- summary:
- visibleGroupedEntries.length === 1 &&
- !workLogEntryIsToolLike(visibleGroupedEntries[0]!)
- ? visibleGroupedEntries[0]!.label
- : summarizeToolGroup(visibleGroupedEntries),
+ onlyToolEntries: true,
+ summary: summarizeToolGroup(visibleGroupedEntries),
summaryKind,
- hasFailure:
- latestToolEntry !== undefined &&
- workEntryDisplayIndicatesToolFailure(latestToolEntry),
+ hasFailure: workEntryDisplayIndicatesToolFailure(visibleGroupedEntries.at(-1)!),
});
if (expanded) {
for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
@@ -886,6 +913,64 @@ export function deriveMessagesTimelineRows(input: {
});
}
}
+ } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
+ nextRows.push({
+ kind: "work",
+ id: timelineEntry.id,
+ createdAt: timelineEntry.createdAt,
+ groupedEntries: visibleGroupedEntries,
+ isExpandedToolGroupEntry: false,
+ isLastExpandedToolGroupEntry: false,
+ });
+ } else {
+ const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
+ const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
+ // Agent-spawn CTA rows are always visible: a running fleet must
+ // never hide behind a "+N tool calls" toggle. Selection is by
+ // membership (spawn OR recent-tail), preserving the group's
+ // chronological order in both collapsed and expanded states
+ // (review finding: concatenating two filtered lists moved a
+ // mid-group spawn row above earlier tool rows).
+ const overflowCandidates = visibleGroupedEntries.filter(
+ (entry) => entry.agentSpawn === undefined,
+ );
+ const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);
+ const hiddenIds = new Set(hiddenEntries.map((entry) => entry.id));
+ const visibleEntries = visibleGroupedEntries.filter(
+ (entry) => entry.agentSpawn !== undefined || !hiddenIds.has(entry.id),
+ );
+ const renderedEntries = expanded ? visibleGroupedEntries : visibleEntries;
+
+ for (const workEntry of renderedEntries) {
+ nextRows.push({
+ kind: "work",
+ id: workEntry.id,
+ createdAt: workEntry.createdAt,
+ groupedEntries: [workEntry],
+ isExpandedToolGroupEntry: false,
+ isLastExpandedToolGroupEntry: false,
+ });
+ }
+
+ if (hiddenEntries.length > 0) {
+ const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
+
+ nextRows.push({
+ kind: "work-toggle",
+ id: `work-toggle:${timelineEntry.id}`,
+ createdAt: timelineEntry.createdAt,
+ groupId,
+ hiddenCount: hiddenEntries.length,
+ expanded,
+ onlyToolEntries: hiddenEntries.every(workLogEntryIsToolLike),
+ summary: null,
+ summaryKind: null,
+ hasFailure:
+ latestToolEntry !== undefined &&
+ workEntryDisplayIndicatesToolFailure(latestToolEntry) &&
+ hiddenEntries.some(workEntryDisplayIndicatesToolFailure),
+ });
+ }
}
}
index = cursor - 1;
@@ -948,6 +1033,10 @@ export function deriveMessagesTimelineRows(input: {
});
}
+ if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
+ appendWorkingRow();
+ }
+
return nextRows;
}
@@ -976,6 +1065,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
if (a.kind !== b.kind || a.id !== b.id) return false;
switch (a.kind) {
+ case "working":
+ return (
+ a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking
+ );
+
case "turn-fold": {
const bf = b as typeof a;
return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;
@@ -1018,6 +1112,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
a.groupId === bw.groupId &&
a.hiddenCount === bw.hiddenCount &&
a.expanded === bw.expanded &&
+ a.onlyToolEntries === bw.onlyToolEntries &&
a.summary === bw.summary &&
a.summaryKind === bw.summaryKind &&
a.hasFailure === bw.hasFailure
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx
index 67060a28d64795ce7e6fcf57e7ebb62f2539102f..4c7cf29969d9612f721542e59c9b720f07852635 100644
--- a/apps/web/src/components/chat/MessagesTimeline.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx
@@ -177,6 +177,7 @@ const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
function buildProps() {
return {
isWorking: false,
+ activeTurnStartedAt: null,
listRef: createRef(),
latestTurn: null,
runningTurnId: null,
@@ -674,6 +675,7 @@ describe("MessagesTimeline", () => {
{
);
expect(markup).toContain("Context compacted");
+ expect(markup).toContain("Work Log");
});
it("summarizes changed files in one line", () => {
@@ -1169,7 +1172,7 @@ describe("MessagesTimeline", () => {
/>,
);
- expect(markup).toContain("Ran 2 commands and received 1 update");
+ expect(markup).toContain("+2 previous log entries");
expect(markup).not.toContain('aria-label="Hidden work includes a failure"');
});
@@ -1179,6 +1182,7 @@ describe("MessagesTimeline", () => {
{
/>,
);
+ expect(markup).toContain("Working for");
expect(markup).toContain("Running pnpm");
expect(markup).toContain("live-activity-focus");
});
@@ -1217,6 +1222,7 @@ describe("MessagesTimeline", () => {
{
{
expect(markup).toContain("tool call failed");
});
+ it("aligns the iconless Thinking row with the working timer", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain("Working for");
+ expect(markup).toContain("Thinking");
+ expect(markup).toContain("gap-1.5 py-0.5 px-1");
+ });
+
it("renders review comment contexts as structured cards instead of raw tags", () => {
const markup = renderToStaticMarkup(
{
expect(markup).not.toContain('data-testid="file-diff"');
});
- it("keeps failed lifecycle entries discoverable in mixed activity summaries", () => {
+ it("renders a muted failure marker for failed tool lifecycle entries", () => {
const markup = renderToStaticMarkup(
{
/>,
);
- expect(markup).toContain('aria-label="Received 1 update and used 1 tool, tool call failed"');
+ expect(markup).toContain("lucide-x");
+ expect(markup).toContain('aria-label="Tool call failed"');
// Ordinary tool failures render muted, not red.
expect(markup).not.toContain("text-destructive");
});
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index d8c88c088648519be0192e90ec818c4a87b1bd27..dc0c03709e6ca0c3c349c27332551d640c4f1728 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -138,7 +138,7 @@ import {
// Context — shared state consumed by every row component via Context.
// Propagates through LegendList's memo boundaries for shared callbacks and
// non-row-scoped state. `nowIso` is intentionally excluded — self-ticking
-// components (LiveElapsed) handle it.
+// components (WorkingTimer, LiveElapsed) handle it.
// ---------------------------------------------------------------------------
interface TimelineRowSharedState {
@@ -164,6 +164,8 @@ interface TimelineRowActivityState {
isWorking: boolean;
isRevertingCheckpoint: boolean;
latestTurnId: TurnId | null;
+ /** Current plan step label for the working row, when the turn has a plan. */
+ workingStepLabel: string | null;
}
const TimelineRowCtx = createContext(null!);
@@ -216,6 +218,8 @@ interface MessagesTimelineProps {
agentPanelModel?: AgentPanelModel;
onOpenAgents?: () => void;
isWorking: boolean;
+ workingStepLabel?: string | null;
+ activeTurnStartedAt: string | null;
listRef: React.RefObject;
timelineEntries: ReturnType;
latestTurn: TimelineLatestTurn | null;
@@ -258,6 +262,8 @@ interface MessagesTimelineProps {
export const MessagesTimeline = memo(function MessagesTimeline({
isWorking,
+ workingStepLabel = null,
+ activeTurnStartedAt,
agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
onOpenAgents = NOOP_OPEN_AGENTS,
listRef,
@@ -420,6 +426,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
expandedTurnIds,
expandedWorkGroupIds,
isWorking,
+ activeTurnStartedAt,
turnDiffSummaryByAssistantMessageId,
revertTurnCountByUserMessageId,
}),
@@ -430,6 +437,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
expandedTurnIds,
expandedWorkGroupIds,
isWorking,
+ activeTurnStartedAt,
turnDiffSummaryByAssistantMessageId,
revertTurnCountByUserMessageId,
],
@@ -558,8 +566,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
isWorking,
isRevertingCheckpoint,
latestTurnId: latestTurn?.turnId ?? null,
+ workingStepLabel,
}),
- [isRevertingCheckpoint, isWorking, latestTurn?.turnId],
+ [isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel],
);
// Stable renderItem — no closure deps. Row components read shared state
@@ -937,7 +946,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;
const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
const isExpandedToolGroupHeader =
- (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);
+ (row.kind === "work-toggle" && row.summary !== null && row.onlyToolEntries && row.expanded) ||
+ (row.kind === "work-live" && row.expanded);
return (
: null}
{row.kind === "turn-plan" ? : null}
+ {row.kind === "working" ? : null}
);
});
@@ -1266,7 +1277,7 @@ function ProposedPlanTimelineRow({
/**
* Inline folded plan chip: one row per turn that produced plan/todo steps.
- * Collapsed by default, with a segment bar and the current step label,
+ * Collapsed by default — a segment bar plus the in-progress step label —
* and expands in place to the full step list. Replaces the old plan sidebar.
*/
const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
@@ -1288,19 +1299,15 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon;
return (
-
+
setExpanded((value) => !value)}
>
-
-
-
- {steps.length > 1 && steps.length <= 10 ? (
+
+ {steps.length > 1 ? (
{steps.map((step) => (
{expanded ? (
-
+
{steps.map((step) => (
}) {
+ const { workingStepLabel } = use(TimelineRowActivityCtx);
+ return (
+
+
+
+ {row.createdAt ? (
+ <>
+ Working for
+ >
+ ) : (
+ "Working..."
+ )}
+ {workingStepLabel ? (
+ · {workingStepLabel}
+ ) : null}
+
+
+ {row.showThinking ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Self-ticking labels — update their own text nodes so elapsed-time display
+// does not create a React commit every second while a response is streaming.
+// ---------------------------------------------------------------------------
+
+/** Live "Working for Xs" label. */
+function WorkingTimer({ createdAt }: { createdAt: string }) {
+ const textRef = useRef(null);
+ const initialText = formatWorkingTimerNow(createdAt);
+
+ useEffect(() => {
+ const updateText = () => {
+ if (textRef.current) {
+ textRef.current.textContent = formatWorkingTimerNow(createdAt);
+ }
+ };
+ updateText();
+ const id = setInterval(updateText, 1000);
+ return () => clearInterval(id);
+ }, [createdAt]);
+
+ return (
+
+ {initialText}
+
+ );
+}
+
// ---------------------------------------------------------------------------
// Extracted row sections — own their state / store subscriptions so changes
// re-render only the affected row, not the entire list.
@@ -1387,6 +1449,12 @@ const WorkGroupSection = memo(function WorkGroupSection({
groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),
[groupedEntries, isExpandedToolGroupEntry],
);
+ const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));
+ const groupLabel = onlyToolEntries
+ ? nonEmptyEntries.length === 1
+ ? "1 tool call"
+ : `${nonEmptyEntries.length} tool calls`
+ : "Work Log";
const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
if (nonEmptyEntries.length === 0) return null;
@@ -1394,8 +1462,11 @@ const WorkGroupSection = memo(function WorkGroupSection({
return (
+ {!onlyToolEntries && (
+ {groupLabel}
+ )}
{nonEmptyEntries.map((workEntry) => (
;
+}
+
function LiveActivityContent({
label,
iconName,
@@ -1524,8 +1599,8 @@ function toolGroupSummaryIconName(
return "bot";
case "tone-tool":
return "zap";
- case "update":
case "mixed":
+ case null:
return "hammer";
}
}
@@ -1536,21 +1611,61 @@ function WorkGroupToggleTimelineRow({
row: Extract
;
}) {
const ctx = use(TimelineRowCtx);
+ if (row.onlyToolEntries && row.summary) {
+ return (
+ ctx.onToggleWorkGroup(row.groupId, row.id)}
+ >
+
+
+
+ {row.summary}
+
+ );
+ }
+ const labelNoun = row.onlyToolEntries
+ ? row.hiddenCount === 1
+ ? "tool call"
+ : "tool calls"
+ : row.hiddenCount === 1
+ ? "log entry"
+ : "log entries";
return (
ctx.onToggleWorkGroup(row.groupId, row.id)}
>
-
- {row.summary}
+ {row.expanded ? (
+
+ Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"}
+
+ ) : (
+
+ +{row.hiddenCount} previous {labelNoun}
+
+ )}
);
}
@@ -2048,6 +2163,33 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {
// Pure helpers
// ---------------------------------------------------------------------------
+function formatWorkingTimer(startIso: string, endIso: string): string | null {
+ const startedAtMs = Date.parse(startIso);
+ const endedAtMs = Date.parse(endIso);
+ if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) {
+ return null;
+ }
+
+ const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000));
+ if (elapsedSeconds < 60) {
+ return `${elapsedSeconds}s`;
+ }
+
+ const hours = Math.floor(elapsedSeconds / 3600);
+ const minutes = Math.floor((elapsedSeconds % 3600) / 60);
+ const seconds = elapsedSeconds % 60;
+
+ if (hours > 0) {
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
+ }
+
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
+}
+
+function formatWorkingTimerNow(startIso: string): string {
+ return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
+}
+
type WorkEntryIconName =
| "bot"
| "check"
diff --git a/apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx b/apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..2a6a28b2becd50368c705e84ab73e834208b0181
--- /dev/null
+++ b/apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx
@@ -0,0 +1,22 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vite-plus/test";
+
+import { ThreadSyncStatusPill } from "./ThreadSyncStatusPill";
+
+describe("ThreadSyncStatusPill", () => {
+ it.each([
+ ["loading", "Loading messages..."],
+ ["syncing", "Syncing messages..."],
+ ] as const)("renders the %s message sync phase", (phase, label) => {
+ const markup = renderToStaticMarkup( );
+
+ expect(markup).toContain('role="status"');
+ expect(markup).toContain('data-thread-sync-drawer="true"');
+ expect(markup).toContain("chat-composer-drawer-surface");
+ expect(markup).toContain("chat-composer-drawer-attached");
+ expect(markup).toContain("chat-composer-drawer-slot");
+ expect(markup).toContain("pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)]");
+ expect(markup).toContain(label);
+ expect(markup).not.toContain("animate-");
+ });
+});
diff --git a/apps/web/src/components/chat/ThreadSyncStatusPill.tsx b/apps/web/src/components/chat/ThreadSyncStatusPill.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..31b5dc184191b399d9981cb80cc714b4c8f071dd
--- /dev/null
+++ b/apps/web/src/components/chat/ThreadSyncStatusPill.tsx
@@ -0,0 +1,19 @@
+import { LoaderCircleIcon } from "lucide-react";
+
+import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
+
+export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhase }) {
+ const label = threadSyncLabel(phase);
+
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 7653ce9bd9efc066f9f3333b9603fecb2ea5e04f..d43475f901cc32ba13f6f8778ef782cdeface90b 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -763,6 +763,472 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
outline: none;
}
+ .chat-composer-shoulder-tab {
+ border-color: var(
+ --chat-composer-outline,
+ color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
+ );
+ background: color-mix(
+ in srgb,
+ var(--chat-composer-glass-surface, var(--card)) var(--glass-opacity),
+ transparent
+ );
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ box-shadow: 0 -8px 24px -18px rgb(0 0 0 / 45%);
+
+ @variant dark {
+ box-shadow: 0 -10px 28px -18px rgb(0 0 0 / 80%);
+ }
+
+ @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
+ background: var(--chat-composer-glass-surface, var(--card));
+ }
+ }
+
+ .chat-composer-banner-stack-cap {
+ --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
+ --chat-composer-attached-outline: var(
+ --chat-composer-outline,
+ color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
+ );
+ background: color-mix(
+ in srgb,
+ var(--chat-composer-attached-surface) var(--glass-opacity),
+ transparent
+ );
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+
+ @variant dark {
+ --chat-composer-attached-surface: var(
+ --chat-composer-glass-surface,
+ color-mix(in srgb, var(--background) 96%, var(--color-white))
+ );
+ --chat-composer-attached-outline: var(
+ --chat-composer-outline,
+ color-mix(in srgb, var(--color-white) 5%, transparent)
+ );
+ }
+
+ @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
+ background: var(--chat-composer-attached-surface);
+ }
+ }
+
+ /* Mirror the run-context strip above the composer. These surfaces deliberately
+ own no card fill or border themselves: a masked pseudo-element draws the
+ same inset glass, 16px outer corners, quiet outline, and open attachment
+ seam as the strip below. */
+ .chat-composer-drawer-surface,
+ .chat-composer-top-drawer {
+ --chat-composer-attachment-overlap: calc(1rem + 1px);
+ --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
+ --chat-composer-attached-outline: var(
+ --chat-composer-outline,
+ color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
+ );
+ --chat-composer-attached-tint: transparent;
+ position: relative;
+ isolation: isolate;
+ border: 0 !important;
+ background: transparent !important;
+ box-shadow: none;
+
+ @variant dark {
+ --chat-composer-attached-surface: var(
+ --chat-composer-glass-surface,
+ color-mix(in srgb, var(--background) 96%, var(--color-white))
+ );
+ --chat-composer-attached-outline: var(
+ --chat-composer-outline,
+ color-mix(in srgb, var(--color-white) 5%, transparent)
+ );
+
+ &::before {
+ background:
+ linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
+ linear-gradient(
+ to top,
+ transparent 0 var(--chat-composer-attachment-overlap),
+ rgb(0 0 0 / 18%) var(--chat-composer-attachment-overlap),
+ transparent calc(var(--chat-composer-attachment-overlap) + 10px)
+ ),
+ color-mix(
+ in srgb,
+ var(--chat-composer-attached-surface) var(--glass-opacity),
+ transparent
+ );
+ box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
+ }
+ }
+ }
+
+ .chat-composer-drawer-attached,
+ .chat-composer-top-drawer {
+ padding-bottom: var(--chat-composer-attachment-overlap);
+ }
+
+ [data-chat-composer-form="true"],
+ .chat-composer-drawer-slot,
+ .chat-composer-top-drawer {
+ --chat-composer-drawer-inset: 1.375rem;
+ }
+
+ .chat-composer-drawer-slot,
+ .chat-composer-top-drawer {
+ --chat-composer-attachment-overlap: calc(1rem + 1px);
+ width: calc(100% - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
+ max-width: calc(48rem - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
+ margin-inline: auto;
+ margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap));
+ }
+
+ :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)::before {
+ pointer-events: none;
+ position: absolute;
+ z-index: -1;
+ inset: 0;
+ border: 1px solid var(--chat-composer-attached-outline);
+ border-radius: 16px 16px 0 0;
+ background:
+ linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
+ color-mix(in srgb, var(--chat-composer-attached-surface) var(--glass-opacity), transparent);
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ -webkit-mask-image: linear-gradient(
+ to top,
+ transparent 0 var(--chat-composer-attachment-overlap),
+ black var(--chat-composer-attachment-overlap)
+ );
+ mask-image: linear-gradient(
+ to top,
+ transparent 0 var(--chat-composer-attachment-overlap),
+ black var(--chat-composer-attachment-overlap)
+ );
+ box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
+ content: "";
+
+ @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
+ background:
+ linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
+ var(--chat-composer-attached-surface) !important;
+ }
+ }
+
+ .chat-composer-drawer-slot + .chat-composer-drawer-slot::before,
+ .chat-composer-drawer-slot + :has(.chat-composer-top-drawer) .chat-composer-top-drawer::before {
+ border-top: 0;
+ border-radius: 0;
+ }
+
+ .chat-composer-top-drawer {
+ z-index: 0;
+ }
+
+ :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="error"] {
+ --chat-composer-attached-outline: color-mix(in srgb, var(--error) 32%, transparent);
+ --chat-composer-attached-tint: color-mix(in srgb, var(--error) 8%, transparent);
+ }
+
+ :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="info"] {
+ --chat-composer-attached-outline: color-mix(in srgb, var(--info) 32%, transparent);
+ --chat-composer-attached-tint: color-mix(in srgb, var(--info) 4%, transparent);
+ }
+
+ :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="success"] {
+ --chat-composer-attached-outline: color-mix(in srgb, var(--success) 32%, transparent);
+ --chat-composer-attached-tint: color-mix(in srgb, var(--success) 4%, transparent);
+ }
+
+ :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="warning"] {
+ --chat-composer-attached-outline: color-mix(in srgb, var(--warning) 28%, transparent);
+ --chat-composer-attached-tint: color-mix(in srgb, var(--warning) 8%, transparent);
+ }
+
+ .chat-composer-glass-shell {
+ --chat-composer-attachment-overlap: calc(1rem + 1px);
+ --chat-composer-glass-surface: var(--card);
+ --chat-composer-outline: rgb(0 0 0 / 8%);
+
+ isolation: isolate;
+
+ @variant dark {
+ --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white));
+ --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent);
+ --chat-composer-highlight: rgb(255 255 255 / 3%);
+ }
+ }
+
+ .chat-composer-glass-shell::before {
+ pointer-events: none;
+ position: absolute;
+ z-index: 0;
+ inset: 0;
+ border-radius: 22px;
+ background: color-mix(
+ in srgb,
+ var(--chat-composer-glass-surface) var(--glass-opacity),
+ transparent
+ );
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ content: "";
+ }
+
+ .chat-composer-glass-shell-with-context {
+ --chat-composer-context-extension: 2.25rem;
+ }
+
+ .chat-composer-glass-shell-with-context::before {
+ border-radius: 0;
+ /*
+ * One continuous glass layer: a 22px composer joined to a 16px strip. The
+ * strip is inset 1.375rem per side, so the step-in positions and their
+ * curve controls stay in rem to keep tracking it at non-default interface
+ * font sizes; the composer's 22px top radius and the strip's 16px bottom
+ * radius are px by design.
+ */
+ clip-path: shape(
+ from 0 22px,
+ curve to 22px 0 with 0 9.85px / 9.85px 0,
+ line to calc(100% - 22px) 0,
+ curve to 100% 22px with calc(100% - 9.85px) 0 / 100% 9.85px,
+ line to 100% calc(100% - var(--chat-composer-context-extension) - 1.375rem),
+ curve to calc(100% - 1.375rem) calc(100% - var(--chat-composer-context-extension)) with 100%
+ calc(100% - var(--chat-composer-context-extension) - 0.6156rem) / calc(100% - 0.6156rem)
+ calc(100% - var(--chat-composer-context-extension)),
+ line to calc(100% - 1.375rem) calc(100% - 16px),
+ curve to calc(100% - 1.375rem - 16px) 100% with calc(100% - 1.375rem) calc(100% - 7.16px) /
+ calc(100% - 1.375rem - 7.16px) 100%,
+ line to calc(1.375rem + 16px) 100%,
+ curve to 1.375rem calc(100% - 16px) with calc(1.375rem + 7.16px) 100% / 1.375rem
+ calc(100% - 7.16px),
+ line to 1.375rem calc(100% - var(--chat-composer-context-extension)),
+ curve to 0 calc(100% - var(--chat-composer-context-extension) - 1.375rem) with 0.6156rem
+ calc(100% - var(--chat-composer-context-extension)) / 0
+ calc(100% - var(--chat-composer-context-extension) - 0.6156rem),
+ line to 0 22px,
+ close
+ );
+ }
+
+ @media (min-width: 40rem) {
+ .chat-composer-glass-shell-with-context {
+ /* The xs toolbar controls shrink by 4px at Tailwind's sm breakpoint. */
+ --chat-composer-context-extension: 2rem;
+ }
+ }
+
+ .chat-composer-glass-host {
+ box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
+
+ @variant dark {
+ box-shadow: none;
+
+ &::after {
+ box-shadow: inset 0 1px var(--chat-composer-highlight);
+ }
+ }
+ }
+
+ .chat-composer-glass-host::after {
+ pointer-events: none;
+ position: absolute;
+ z-index: 1;
+ inset: 0;
+ border: 1px solid var(--chat-composer-outline);
+ border-radius: inherit;
+ content: "";
+ }
+
+ /* A top attachment cannot share the shell's single clipped backdrop: it
+ needs the composer surface and outline to paint in front of its overlap.
+ Split the glass into exact surfaces while a drawer or tab is present; the
+ default composer keeps the cheaper continuous layer. */
+ .chat-composer-glass-shell:is(
+ .chat-composer-glass-shell-attached,
+ :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
+ )::before,
+ .chat-composer-glass-shell:is(
+ .chat-composer-glass-shell-attached,
+ :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
+ )
+ .chat-composer-glass-host::after {
+ display: none;
+ }
+
+ .chat-composer-glass-shell:is(
+ .chat-composer-glass-shell-attached,
+ :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
+ )
+ .chat-composer-glass-host {
+ box-shadow: none;
+ }
+
+ .chat-composer-glass-shell:is(
+ .chat-composer-glass-shell-attached,
+ :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
+ )
+ .chat-composer-glass-host
+ [data-chat-composer-main-surface="true"] {
+ background: color-mix(
+ in srgb,
+ var(--chat-composer-glass-surface) var(--glass-opacity),
+ transparent
+ );
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
+
+ &::after {
+ pointer-events: none;
+ position: absolute;
+ z-index: 20;
+ inset: 0;
+ border: 1px solid var(--chat-composer-outline);
+ border-radius: inherit;
+ content: "";
+ }
+
+ @variant dark {
+ box-shadow: none;
+
+ &::after {
+ box-shadow: inset 0 1px var(--chat-composer-highlight);
+ }
+ }
+
+ @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
+ background: var(--chat-composer-glass-surface);
+ }
+ }
+
+ .chat-composer-glass-shell:is(
+ .chat-composer-glass-shell-attached,
+ :has(.chat-composer-top-drawer)
+ )
+ .chat-composer-glass-host
+ [data-chat-composer-mobile-collapsed="true"] {
+ min-height: var(--chat-composer-attachment-overlap);
+ }
+
+ .chat-composer-glass-shell-with-context .chat-composer-glass-host::after,
+ .chat-composer-glass-shell-with-context:is(
+ .chat-composer-glass-shell-attached,
+ :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
+ )
+ [data-chat-composer-main-surface="true"]::after {
+ /* Keep the attachment seam open; the strip continues the outer outline below. */
+ clip-path: polygon(
+ 0 0,
+ 100% 0,
+ 100% 100%,
+ calc(100% - 22px) 100%,
+ calc(100% - 22px) calc(100% - 2px),
+ 22px calc(100% - 2px),
+ 22px 100%,
+ 0 100%
+ );
+ }
+
+ .chat-composer-context-strip {
+ position: relative;
+ isolation: isolate;
+
+ @variant dark {
+ &::before {
+ border-color: rgb(255 255 255 / 7%);
+ background:
+ linear-gradient(
+ to bottom,
+ transparent 0 1rem,
+ rgb(0 0 0 / 18%) 1rem,
+ transparent calc(1rem + 10px)
+ ),
+ rgb(255 255 255 / 2%);
+ box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
+ }
+ }
+ }
+
+ .chat-composer-context-strip::before {
+ position: absolute;
+ z-index: -1;
+ inset: 0;
+ border: 1px solid var(--chat-composer-outline);
+ border-radius: 0 0 16px 16px;
+ /* Start the strip outline exactly where the composer's 22px curve reaches its tangent. */
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem);
+ mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem);
+ box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
+ content: "";
+ }
+
+ .chat-composer-glass-shell:is(
+ .chat-composer-glass-shell-attached,
+ :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
+ )
+ .chat-composer-context-strip {
+ &::before {
+ background: color-mix(
+ in srgb,
+ var(--chat-composer-glass-surface) var(--glass-opacity),
+ transparent
+ );
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ }
+
+ @variant dark {
+ &::before {
+ background:
+ linear-gradient(
+ to bottom,
+ transparent 0 1rem,
+ rgb(0 0 0 / 18%) 1rem,
+ transparent calc(1rem + 10px)
+ ),
+ linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)),
+ color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent);
+ }
+ }
+ }
+
+ @supports not (clip-path: shape(from 0 0, line to 1px 1px)) {
+ .chat-composer-glass-shell-with-context::before {
+ inset-block-end: var(--chat-composer-context-extension);
+ border-radius: 22px;
+ clip-path: none;
+ }
+
+ .chat-composer-context-strip::before {
+ background: color-mix(
+ in srgb,
+ var(--chat-composer-glass-surface) var(--glass-opacity),
+ transparent
+ );
+ -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
+ }
+
+ .chat-composer-context-strip {
+ @variant dark {
+ &::before {
+ background:
+ linear-gradient(
+ to bottom,
+ transparent 0 1rem,
+ rgb(0 0 0 / 18%) 1rem,
+ transparent calc(1rem + 10px)
+ ),
+ linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)),
+ color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent);
+ }
+ }
+ }
+ }
+
.settings-slider {
--settings-slider-progress: 0%;
--settings-slider-fill-offset: 0.5rem;
@@ -877,6 +1343,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
all: revert;
}
}
+
+ @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
+ .chat-composer-glass-shell::before {
+ background: var(--chat-composer-glass-surface);
+ }
+ }
}
/* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering.
@@ -1143,6 +1615,58 @@ html[data-theme-id]:not([data-theme-id=""]) {
--terminal-selection-background: var(--app-theme-terminal-selection-background);
}
+/* T3 Chat's composer is a translucent lift over --chat-background. Route its
+ measured flattened color through the raised-surface role instead of deriving
+ another tint from the canvas, which made the dark composer too red. */
+html[data-theme-id] .chat-composer-glass-shell {
+ --chat-composer-glass-surface: var(--app-theme-surface-raised);
+ --chat-composer-outline: var(--app-theme-toolbar-border);
+
+ @variant dark {
+ --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background));
+ --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent);
+ }
+}
+
+html[data-theme-id]
+ :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer) {
+ --chat-composer-attached-surface: var(--app-theme-surface-raised);
+}
+
+html[data-theme-id]
+ :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
+ :not([data-variant]),
+ [data-variant="default"]
+ ) {
+ --chat-composer-attached-outline: var(--chat-composer-outline, var(--app-theme-toolbar-border));
+
+ @variant dark {
+ --chat-composer-attached-outline: var(
+ --chat-composer-outline,
+ color-mix(in srgb, var(--app-theme-input) 30%, var(--background))
+ );
+ }
+}
+
+html[data-theme-id="t3-chat"] .chat-composer-glass-shell {
+ /* T3 Chat's visible composer edge is a dark plum, not the stock translucent
+ white outline. Its highlight is derived from --chat-input-gradient. */
+ @variant dark {
+ --chat-composer-outline: #241e28;
+ --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent);
+ }
+}
+
+html[data-theme-id="t3-chat"]
+ :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
+ :not([data-variant]),
+ [data-variant="default"]
+ ) {
+ @variant dark {
+ --chat-composer-attached-outline: #241e28;
+ }
+}
+
/* Theme-token dependency probes are restored synchronously, before paint. Keep
transitions from observing the temporary sentinel color in between. */
html[data-theme-token-probe],
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
index 5a2b998704f376c7d8e9e9acbf226cd8a1d67e71..432c8aa801b7ea89a1496a2847a774569a48cb57 100644
--- a/apps/web/src/session-logic.test.ts
+++ b/apps/web/src/session-logic.test.ts
@@ -915,34 +915,6 @@ describe("workEntryIndicatesToolFailure", () => {
});
describe("deriveWorkLogEntries", () => {
- it("keeps the latest task progress without emitting plan-update log entries", () => {
- const activities = [
- makeActivity({ id: "before", kind: "tool.completed", summary: "Read files", sequence: 0 }),
- makeActivity({
- id: "plan-1",
- kind: "turn.plan.updated",
- summary: "Plan updated",
- turnId: "turn-1",
- sequence: 1,
- payload: { plan: [{ step: "Verify the composer", status: "inProgress" }] },
- }),
- makeActivity({
- id: "plan-2",
- kind: "turn.plan.updated",
- summary: "Plan updated",
- turnId: "turn-1",
- sequence: 2,
- payload: { plan: [{ step: "Verify the composer", status: "completed" }] },
- }),
- makeActivity({ id: "after", kind: "tool.completed", summary: "Ran tests", sequence: 3 }),
- ];
- expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual(["before", "after"]);
- expect(deriveTurnPlans(activities)).toHaveLength(1);
- expect(deriveTurnPlans(activities)[0]?.plan.steps).toMatchObject([
- { step: "Verify the composer", status: "completed" },
- ]);
- });
-
it("omits tool started entries and keeps completed entries", () => {
const activities: OrchestrationThreadActivity[] = [
makeActivity({
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index 5796cda979b88c9389934774b10edf9fb03781fe..4b35a6e24cc08698ebd7a9dc6c8befdb71278a2a 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -880,7 +880,6 @@ export function deriveWorkLogEntries(
if (activity.kind === "task.updated") continue;
if (activity.kind === "tool.progress") continue;
if (activity.kind === "context-window.updated") continue;
- if (activity.kind === "turn.plan.updated") continue;
if (activity.summary === "Checkpoint captured") continue;
if (isNoContentRuntimeWarning(activity)) continue;
if (isPlanBoundaryToolActivity(activity)) continue;
diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts
index 82a5563a86c3191df21f06b619d4839debc05ad2..708ebc69269ac2987f66b3d48eec2b28b93c0323 100644
--- a/apps/web/src/versionSkew.test.ts
+++ b/apps/web/src/versionSkew.test.ts
@@ -1,5 +1,4 @@
import { EnvironmentId } from "@t3tools/contracts";
-import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
// Pinned so the direction cases below read as fixed versions instead of
@@ -11,9 +10,7 @@ import { APP_VERSION } from "./branding";
import {
appendVersionMismatchHint,
buildVersionMismatchDismissalKey,
- dismissServerUpdateFailure,
dismissVersionMismatch,
- isServerUpdateFailureDismissed,
isVersionMismatchDismissed,
resolveServerConfigVersionMismatch,
resolveServerSelfUpdateCapability,
@@ -29,39 +26,6 @@ describe("versionSkew", () => {
branding.APP_VERSION = "0.0.34";
});
- it("dismisses only the current failed attempt without clearing its retry state", () => {
- const failure = {
- status: "failed",
- stage: "downloading",
- fromVersion: "0.0.33",
- targetVersion: "0.0.34",
- message: "Download failed.",
- } as const satisfies ServerUpdateState;
- const retryFailure = { ...failure };
- const otherEnvironmentFailure = { ...failure };
-
- dismissServerUpdateFailure(failure);
-
- expect(isServerUpdateFailureDismissed(failure)).toBe(true);
- expect(failure.status).toBe("failed");
- expect(failure.message).toBe("Download failed.");
- expect(isServerUpdateFailureDismissed(retryFailure)).toBe(false);
- expect(isServerUpdateFailureDismissed(otherEnvironmentFailure)).toBe(false);
- });
-
- it("does not dismiss an update that is still running", () => {
- const running = {
- status: "running",
- stage: "resuming",
- fromVersion: "0.0.33",
- targetVersion: "0.0.34",
- } as const satisfies ServerUpdateState;
-
- dismissServerUpdateFailure(running);
-
- expect(isServerUpdateFailureDismissed(running)).toBe(false);
- });
-
it("does not warn when versions match", () => {
expect(resolveVersionMismatch(APP_VERSION)).toBeNull();
});
diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts
index 38905cc090f5cba46809c074273f063511956716..594bff08168b37af3ed40af0d961b8e526aec1ba 100644
--- a/apps/web/src/versionSkew.ts
+++ b/apps/web/src/versionSkew.ts
@@ -1,5 +1,4 @@
import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts";
-import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
import * as Schema from "effect/Schema";
@@ -14,18 +13,6 @@ export interface VersionMismatch {
export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1";
-// Runtime failures retain their identity until the next attempt. Dismiss only
-// that attempt, across chat remounts, without clearing the error in Settings.
-const dismissedServerUpdateFailures = new WeakSet();
-
-export function isServerUpdateFailureDismissed(state: ServerUpdateState): boolean {
- return state.status === "failed" && dismissedServerUpdateFailures.has(state);
-}
-
-export function dismissServerUpdateFailure(state: ServerUpdateState): void {
- if (state.status === "failed") dismissedServerUpdateFailures.add(state);
-}
-
const VersionMismatchDismissalsSchema = Schema.Struct({
keys: Schema.Array(Schema.String),
});