authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-30 20:24:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-30 20:24:47-07:00
log30175a8af04c0daa359652b5e8dc8230b40b462a
tree60e5c1b5a81f9b5071f0fdbcf5bd6a28039f066c
parentf9137a0c89f6190d27813bb46dbb6d1bb796f080
signature Signed by PGP key B5690EEEBB952194

fix(web): restore unified activity logs and composer banners (#8734)

Co-authored-by: Julius Marminge <julius0216@outlook.com> Co-authored-by: Julius Marminge <jmarminge@gmail.com>

33 files changed, 1407 insertions(+), 2232 deletions(-)

apps/web/src/components/BranchToolbar.tsx+3-3
......@@ -40,6 +40,7 @@ import {
4040 MenuTrigger,
4141} from "./ui/menu";
4242import { Separator } from "./ui/separator";
43import { ComposerSurface } from "./chat/ComposerSurface";
4344
4445interface BranchToolbarProps {
4546 environmentId: EnvironmentId;
......@@ -466,10 +467,9 @@ export const BranchToolbar = memo(function BranchToolbar({
466467 if (!hasActiveThread || !activeProject) return null;
467468
468469 return (
469 <div
470 <ComposerSurface.ContextStrip
470471 ref={setStripElement}
471472 data-compact={labelsOverflow ? "" : undefined}
472 className="chat-composer-context-strip group/composer-context -mt-4 mx-auto flex w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)] items-center gap-2 overflow-x-clip overflow-y-visible ps-1 pe-2 pt-5 pb-1"
473473 >
474474 {isMobile && showGitControls ? (
475475 <MobileRunContextSelector
......@@ -534,6 +534,6 @@ export const BranchToolbar = memo(function BranchToolbar({
534534 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
535535 />
536536 ) : null}
537 </div>
537 </ComposerSurface.ContextStrip>
538538 );
539539});
apps/web/src/components/ChatView.logic.test.ts-19
......@@ -35,7 +35,6 @@ import {
3535 resolveSendEnvMode,
3636 resolveDraftHeroState,
3737 scheduleEnvironmentReconnectWarning,
38 shoulderTabReserve,
3938 startNewThreadForProject,
4039 codexArtifactTemplatePromptToAppend,
4140 shouldDockDraftHeroForSubmission,
......@@ -89,24 +88,6 @@ describe("artifact template composer insertion", () => {
8988 });
9089});
9190
92describe("shoulderTabReserve", () => {
93 it("ignores the top drawer when measuring the shoulder tab band", () => {
94 const elementAt = (top: number) => ({ getBoundingClientRect: () => ({ top }) }) as HTMLElement;
95 const elements = new Map<string, HTMLElement>([
96 ['[data-chat-composer-form="true"]', elementAt(20)],
97 [".chat-composer-shoulder-tab", elementAt(100)],
98 ['[data-chat-composer-main-surface="true"]', elementAt(128)],
99 ]);
100 const overlay = {
101 querySelector: (selector: string) => elements.get(selector) ?? null,
102 } as HTMLElement;
103
104 expect(shoulderTabReserve(overlay)).toBe(28);
105 elements.set(".chat-composer-tasks-tab", elementAt(100));
106 expect(shoulderTabReserve(overlay)).toBe(0);
107 });
108});
109
11091describe("draft hero submission transition", () => {
11192 it("does not dock the composer before a background submission", () => {
11293 expect(
apps/web/src/components/ChatView.logic.ts-10
......@@ -52,16 +52,6 @@ export function codexArtifactTemplatePromptToAppend(
5252 ? null
5353 : codexArtifactTemplateUsePrompt(template);
5454}
55export function shoulderTabReserve(overlay: HTMLElement): number {
56 if (overlay.querySelector(".chat-composer-tasks-tab")) return 0;
57 const tab = overlay.querySelector<HTMLElement>(".chat-composer-shoulder-tab");
58 const surface = overlay.querySelector<HTMLElement>('[data-chat-composer-main-surface="true"]');
59 if (!tab || !surface) return 0;
60 return Math.max(
61 0,
62 Math.round(surface.getBoundingClientRect().top - tab.getBoundingClientRect().top),
63 );
64}
6555
6656export function shouldDockDraftHeroForSubmission(input: {
6757 isDraftHeroState: boolean;
apps/web/src/components/ChatView.tsx+62-81
......@@ -102,7 +102,6 @@ import {
102102 deriveTimelineEntries,
103103 deriveActiveWorkStartedAt,
104104 deriveActivePlanState,
105 deriveTurnPlans,
106105 findLatestProposedPlan,
107106 deriveWorkLogEntries,
108107 hasActionableProposedPlan,
......@@ -189,6 +188,7 @@ import {
189188 CheckCircle2Icon,
190189 ChevronDownIcon,
191190 GitBranchIcon,
191 InfoIcon,
192192 Minimize2Icon,
193193 PaperclipIcon,
194194 WifiOffIcon,
......@@ -322,14 +322,14 @@ import {
322322 threadChangeRequestSnapshotsAtom,
323323 useLinkedThreadPullRequest,
324324} from "./ThreadStatusIndicators";
325import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
325import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
326import { ComposerSurface } from "./chat/ComposerSurface";
326327import {
327328 hasAvailableClaudeCompactionProvider,
328329 hasDismissedResumeCompaction,
329330 shouldOfferResumeCompaction,
330331} from "./chat/ContextWindowMeter.logic";
331332import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
332import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
333333import {
334334 DRAFT_HERO_TRANSITION_ANIMATION_ID,
335335 DRAFT_HERO_TRANSITION_DURATION_MS,
......@@ -356,7 +356,6 @@ import {
356356 shouldDockDraftHeroForSubmission,
357357 shouldReleaseTimelineAnchorForToolActivity,
358358 shouldShowBranchMismatchBanner,
359 shoulderTabReserve,
360359 shouldShowPlanFollowUpPrompt,
361360 getStartedThreadModelChangeBlockReason,
362361 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
......@@ -410,10 +409,13 @@ import {
410409 AlertDialogTitle,
411410} from "./ui/alert-dialog";
412411import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
413import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction";
412import { ServerUpdateAction } from "./ServerUpdateAction";
413import { ComposerServerUpdateStatus } from "./chat/ComposerServerUpdateStatus";
414414import {
415415 buildVersionMismatchDismissalKey,
416 dismissServerUpdateFailure,
416417 dismissVersionMismatch,
418 isServerUpdateFailureDismissed,
417419 isVersionMismatchDismissed,
418420 resolveServerConfigVersionMismatch,
419421 resolveServerSelfUpdateCapability,
......@@ -2180,6 +2182,12 @@ function ChatViewContent(props: ChatViewProps) {
21802182 const serverUpdateState = useAtomValue(
21812183 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
21822184 );
2185 const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState<
2186 typeof serverUpdateState | null
2187 >(null);
2188 const serverUpdateFailureDismissed =
2189 serverUpdateState === dismissedServerUpdateState ||
2190 isServerUpdateFailureDismissed(serverUpdateState);
21832191 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
21842192 const items: ComposerBannerStackItem[] = [];
21852193 const updateRunning = serverUpdateState.status === "running";
......@@ -2207,8 +2215,8 @@ function ChatViewContent(props: ChatViewProps) {
22072215 items.push({
22082216 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
22092217 variant: "default",
2210 // Live connection status: calm styling, but it must front the stack.
2211 urgent: true,
2218 // Prioritize live connection progress among the notices.
2219 priority: "urgent",
22122220 icon: (
22132221 <span
22142222 className="size-1.5 animate-status-pulse rounded-full bg-foreground"
......@@ -2255,29 +2263,24 @@ function ChatViewContent(props: ChatViewProps) {
22552263 if (
22562264 serverUpdateEnvironmentId &&
22572265 !reconnectingThroughVersionSkew &&
2258 (serverUpdateState.status !== "idle" ||
2259 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))
2266 (serverUpdateState.status === "idle"
2267 ? showVersionMismatchBanner
2268 : !serverUpdateFailureDismissed)
22602269 ) {
22612270 const updateInProgress = serverUpdateState.status === "running";
22622271 const updateFailed = serverUpdateState.status === "failed";
22632272 items.push({
22642273 id: `server-version:${serverUpdateEnvironmentId}`,
22652274 variant: updateFailed ? "error" : "default",
2266 // A running update is live progress the user is waiting on; only the
2267 // idle "update available" offer is calm enough to stack behind.
2268 urgent: updateInProgress,
2269 // In-flight and failed states carry their own status dot inside
2270 // ServerUpdateProgress; only the idle offer needs an icon.
2271 icon:
2272 updateInProgress || updateFailed ? null : (
2273 <span
2274 className="size-1.5 rounded-full border border-muted-foreground/40"
2275 aria-hidden="true"
2276 />
2277 ),
2275 // Prioritize update progress over passive notices, but keep activity attached.
2276 priority: updateInProgress ? "urgent" : "notice",
2277 icon: <InfoIcon aria-hidden />,
22782278 title:
22792279 updateInProgress || updateFailed ? (
2280 `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
2280 <ComposerServerUpdateStatus
2281 state={serverUpdateState}
2282 serverLabel={versionMismatchServerLabel}
2283 />
22812284 ) : versionMismatch ? (
22822285 <Tooltip>
22832286 <TooltipTrigger
......@@ -2296,11 +2299,9 @@ function ChatViewContent(props: ChatViewProps) {
22962299 "Server update available"
22972300 ),
22982301 description:
2299 updateInProgress || updateFailed ? (
2300 <ServerUpdateProgress state={serverUpdateState} />
2301 ) : versionMismatchSelfUpdate === "desktop-managed" ? (
2302 serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2303 ) : null,
2302 !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed"
2303 ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2304 : undefined,
23042305 // The desktop-managed guidance is already the description; the action
23052306 // slot would only repeat it.
23062307 actions:
......@@ -2315,11 +2316,15 @@ function ChatViewContent(props: ChatViewProps) {
23152316 label={updateFailed ? "Retry" : "Update"}
23162317 />
23172318 ),
2318 ...(updateInProgress || updateFailed || !versionMismatchDismissKey
2319 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)
23192320 ? {}
23202321 : {
23212322 dismissLabel: "Dismiss update notice",
23222323 onDismiss: () => {
2324 if (updateFailed) {
2325 dismissServerUpdateFailure(serverUpdateState);
2326 setDismissedServerUpdateState(serverUpdateState);
2327 }
23232328 dismissVersionMismatch(versionMismatchDismissKey);
23242329 setDismissedVersionMismatchKey(versionMismatchDismissKey);
23252330 },
......@@ -2334,6 +2339,7 @@ function ChatViewContent(props: ChatViewProps) {
23342339 navigate,
23352340 setDismissedVersionMismatchKey,
23362341 showVersionMismatchBanner,
2342 serverUpdateFailureDismissed,
23372343 serverUpdateState,
23382344 versionMismatch,
23392345 versionMismatchDismissKey,
......@@ -2361,7 +2367,6 @@ function ChatViewContent(props: ChatViewProps) {
23612367 [threadActivities],
23622368 );
23632369 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
2364 const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]);
23652370 // Native subagent fold: memoized by activity-list identity, shared by the
23662371 // Agents surface, live strip, and workflow cards. v2Projection is null
23672372 // until orchestration-v2 lands (source precedence lives in the derive).
......@@ -2431,7 +2436,7 @@ function ChatViewContent(props: ChatViewProps) {
24312436 // Current step for the in-chat working row: only for the running turn's own
24322437 // plan (deriveActivePlanState falls back to older turns' plans, which must
24332438 // not label fresh work). Falls back to the first pending step so an
2434 // all-pending freshly written plan labels the row, matching the chip and
2439 // all-pending freshly written plan labels the row, matching the composer and
24352440 // the server's planProgress.
24362441 const workingStepLabel = useMemo(() => {
24372442 if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) {
......@@ -2828,13 +2833,8 @@ function ChatViewContent(props: ChatViewProps) {
28282833 ]);
28292834 const timelineEntries = useMemo(
28302835 () =>
2831 deriveTimelineEntries(
2832 timelineMessages,
2833 activeThread?.proposedPlans ?? [],
2834 workLogEntries,
2835 turnPlans,
2836 ),
2837 [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries],
2836 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
2837 [activeThread?.proposedPlans, timelineMessages, workLogEntries],
28382838 );
28392839 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
28402840 const draftHeroDockRequested =
......@@ -4553,10 +4553,20 @@ function ChatViewContent(props: ChatViewProps) {
45534553 // partition (same shell, same capability gate, same PR auto-settle input)
45544554 // so the banner and the sidebar row never disagree.
45554555 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
4556 const activeComposerTasksProgress =
4557 activeLatestTurn !== null && !latestTurnSettled
4558 ? (activeThreadShell?.planProgress ?? null)
4559 : null;
4556 const activeComposerTasksProgress = useMemo(() => {
4557 if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) {
4558 return null;
4559 }
4560 const currentStep =
4561 activePlan.steps.find((step) => step.status === "inProgress") ??
4562 activePlan.steps.find((step) => step.status === "pending");
4563 if (!currentStep) return null;
4564 return {
4565 step: currentStep.step,
4566 completedSteps: activePlan.steps.filter((step) => step.status === "completed").length,
4567 totalSteps: activePlan.steps.length,
4568 };
4569 }, [activeLatestTurn, activePlan, latestTurnSettled]);
45604570 const activeComposerTaskSteps =
45614571 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
45624572 ? activePlan.steps
......@@ -4571,9 +4581,8 @@ function ChatViewContent(props: ChatViewProps) {
45714581 setComposerOverlayHeight((currentHeight) =>
45724582 currentHeight === nextHeight ? currentHeight : nextHeight,
45734583 );
4574 const nextClearance = Math.max(0, nextHeight - shoulderTabReserve(composerOverlayElement));
45754584 setScrollToEndClearance((currentClearance) =>
4576 currentClearance === nextClearance ? currentClearance : nextClearance,
4585 currentClearance === nextHeight ? currentClearance : nextHeight,
45774586 );
45784587 };
45794588
......@@ -4582,11 +4591,8 @@ function ChatViewContent(props: ChatViewProps) {
45824591
45834592 const resizeObserver = new ResizeObserver(updateHeight);
45844593 resizeObserver.observe(composerOverlayElement);
4585 const tabObserver = new MutationObserver(updateHeight);
4586 tabObserver.observe(composerOverlayElement, { childList: true, subtree: true });
45874594 return () => {
45884595 resizeObserver.disconnect();
4589 tabObserver.disconnect();
45904596 };
45914597 }, [composerOverlayElement]);
45924598
......@@ -4914,6 +4920,7 @@ function ChatViewContent(props: ChatViewProps) {
49144920 return {
49154921 id: `background-liveness:${activeThread.id}`,
49164922 variant: "default",
4923 priority: "activity",
49174924 icon: (
49184925 <span
49194926 className={cn("size-1.5 rounded-full bg-foreground", working && "animate-status-pulse")}
......@@ -4960,13 +4967,6 @@ function ChatViewContent(props: ChatViewProps) {
49604967 onDismiss: acknowledgeActiveThreadWoke,
49614968 };
49624969 }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);
4963 // The stack renders items[0] front-most and tucks the rest behind hover, so
4964 // ordering is priority: urgent system banners (error/warning variants plus
4965 // calm-styled live states flagged `urgent`, like update progress), then
4966 // background liveness — its Stop button is the only stop affordance for
4967 // settled turns, so a passive "update available" notice must not cover it —
4968 // then calm system banners, the woke and branch-mismatch notices, and the
4969 // informational parked-thread banner last — it must never cover another.
49704970 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
49714971 if (!activeThreadSnoozed && !activeThreadSettled) {
49724972 return null;
......@@ -5117,10 +5117,6 @@ function ChatViewContent(props: ChatViewProps) {
51175117 void handleSwitchCheckoutToThread();
51185118 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
51195119 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
5120 const isUrgentSystemItem = (item: ComposerBannerStackItem) =>
5121 item.urgent === true || item.variant === "error" || item.variant === "warning";
5122 const urgentSystemItems = systemComposerBannerItems.filter(isUrgentSystemItem);
5123 const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item));
51245120 const backgroundLivenessItems =
51255121 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
51265122 const resumeCompactionItems =
......@@ -5129,18 +5125,16 @@ function ChatViewContent(props: ChatViewProps) {
51295125 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
51305126 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
51315127 return [
5132 ...urgentSystemItems,
5128 ...systemComposerBannerItems,
51335129 ...backgroundLivenessItems,
5134 ...calmSystemItems,
51355130 ...resumeCompactionItems,
51365131 ...wokeThreadItems,
51375132 ...parkedThreadItems,
51385133 ];
51395134 }
51405135 return [
5141 ...urgentSystemItems,
5136 ...systemComposerBannerItems,
51425137 ...backgroundLivenessItems,
5143 ...calmSystemItems,
51445138 ...resumeCompactionItems,
51455139 ...wokeThreadItems,
51465140 {
......@@ -7147,8 +7141,6 @@ function ChatViewContent(props: ChatViewProps) {
71477141 setDragActive: setIsWorkspaceFileDragActive,
71487142 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
71497143 });
7150 const externalComposerDrawerAttached =
7151 composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable);
71527144
71537145 return (
71547146 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
......@@ -7317,7 +7309,7 @@ function ChatViewContent(props: ChatViewProps) {
73177309 {isDraftHeroState ? (
73187310 <div className="absolute inset-x-0 bottom-full z-0">
73197311 <div
7320 className="pb-8 group-has-[.chat-composer-shoulder-tab]/composer-stack:pb-4"
7312 className="pb-8 group-has-data-[composer-shoulder-tab]/composer-stack:pb-4"
73217313 style={
73227314 forceExpandedMobileComposer
73237315 ? {
......@@ -7331,13 +7323,7 @@ function ChatViewContent(props: ChatViewProps) {
73317323 activeProjectTitle={activeProject?.title ?? null}
73327324 />
73337325 </div>
7334 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
73357326 </div>
7336 ) : (
7337 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
7338 )}
7339 {threadSyncPhase && !activeEnvironmentUnavailable ? (
7340 <ThreadSyncStatusPill phase={threadSyncPhase} />
73417327 ) : null}
73427328 <div
73437329 className="relative"
......@@ -7347,14 +7333,8 @@ function ChatViewContent(props: ChatViewProps) {
73477333 : undefined
73487334 }
73497335 >
7350 <div
7351 className={cn(
7352 "chat-composer-glass-shell relative mx-auto w-full max-w-3xl",
7353 externalComposerDrawerAttached && "chat-composer-glass-shell-attached",
7354 showComposerContextStrip && "chat-composer-glass-shell-with-context",
7355 )}
7356 >
7357 <div className="chat-composer-glass-host relative z-10 w-full rounded-[22px]">
7336 <ComposerSurface.Shell contextStrip={showComposerContextStrip}>
7337 <ComposerSurface.Host>
73587338 <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">
73597339 <ChatComposer
73607340 composerRef={composerRef}
......@@ -7384,7 +7364,7 @@ function ChatViewContent(props: ChatViewProps) {
73847364 : null
73857365 }
73867366 isPreparingWorktree={isPreparingWorktree}
7387 externalDrawerAttached={externalComposerDrawerAttached}
7367 bannerItems={composerBannerItems}
73887368 environmentUnavailable={activeEnvironmentUnavailableState}
73897369 activePendingApproval={activePendingApproval}
73907370 pendingApprovals={pendingApprovals}
......@@ -7399,6 +7379,7 @@ function ChatViewContent(props: ChatViewProps) {
73997379 activeProposedPlan={activeProposedPlan}
74007380 activeTasksProgress={activeComposerTasksProgress}
74017381 activeTaskSteps={activeComposerTaskSteps}
7382 threadSyncPhase={activeEnvironmentUnavailable ? null : threadSyncPhase}
74027383 runtimeMode={runtimeMode}
74037384 interactionMode={interactionMode}
74047385 lockedProvider={lockedProvider}
......@@ -7447,7 +7428,7 @@ function ChatViewContent(props: ChatViewProps) {
74477428 openingVideoAttachmentId={openingVideoAttachmentId}
74487429 />
74497430 </div>
7450 </div>
7431 </ComposerSurface.Host>
74517432 <div className="min-h-0">
74527433 <div
74537434 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}
......@@ -7485,7 +7466,7 @@ function ChatViewContent(props: ChatViewProps) {
74857466 )}
74867467 </div>
74877468 </div>
7488 </div>
7469 </ComposerSurface.Shell>
74897470 <div
74907471 aria-hidden
74917472 className="h-[calc(env(safe-area-inset-bottom)+1rem)] sm:h-[calc(env(safe-area-inset-bottom)+1.25rem)]"
apps/web/src/components/chat/ChatComposer.tsx+213-204
......@@ -11,7 +11,6 @@ import type {
1111 ScopedThreadRef,
1212 ServerProvider,
1313 ThreadId,
14 TurnId,
1514} from "@t3tools/contracts";
1615import {
1716 ProviderDriverKind,
......@@ -75,10 +74,20 @@ import { ComposerStashBadge } from "./ComposerStashBadge";
7574import { ComposerStashMenu } from "./ComposerStashMenu";
7675import {
7776 ComposerTasksBadge,
77 ComposerTasksContent,
7878 ComposerTasksDrawer,
7979 type ComposerTaskStep,
8080 type ComposerTasksProgress,
8181} from "./ComposerTasksBadge";
82import { ComposerActivityRow } from "./ComposerActivityStatus";
83import type { ThreadSyncPhase } from "../../threadSync";
84import { ComposerBanner } from "./ComposerBanner";
85import { ComposerSurface } from "./ComposerSurface";
86import {
87 ComposerBannerStack,
88 type ComposerBannerStackContent,
89 type ComposerBannerStackItem,
90} from "./ComposerBannerStack";
8291import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
8392import {
8493 fileAttachmentTooLargeMessage,
......@@ -628,7 +637,7 @@ export interface ChatComposerProps {
628637 isSendBusy: boolean;
629638 sendDisabledReason: string | null;
630639 isPreparingWorktree: boolean;
631 externalDrawerAttached: boolean;
640 bannerItems: readonly ComposerBannerStackItem[];
632641 environmentUnavailable: {
633642 readonly label: string;
634643 readonly connection: EnvironmentConnectionPresentation;
......@@ -656,6 +665,7 @@ export interface ChatComposerProps {
656665 activeProposedPlan: Thread["proposedPlans"][number] | null;
657666 activeTasksProgress: ComposerTasksProgress | null;
658667 activeTaskSteps: readonly ComposerTaskStep[] | null;
668 threadSyncPhase: ThreadSyncPhase | null;
659669
660670 // Mode
661671 runtimeMode: RuntimeMode;
......@@ -758,8 +768,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
758768 respondingRequestIds,
759769 showPlanFollowUpPrompt,
760770 activeProposedPlan,
761 activeTasksProgress,
762 activeTaskSteps,
763771 runtimeMode,
764772 interactionMode,
765773 lockedProvider,
......@@ -800,6 +808,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
800808 onFileOpen,
801809 openingVideoAttachmentId,
802810 } = props;
811 const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;
812 const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;
803813 // ------------------------------------------------------------------
804814 // Store subscriptions (prompt / images / terminal contexts)
805815 // ------------------------------------------------------------------
......@@ -1207,7 +1217,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
12071217 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
12081218 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
12091219 const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);
1210 const [dismissedTasksTurnId, setDismissedTasksTurnId] = useState<TurnId | null>(null);
12111220 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
12121221 key: 0,
12131222 active: false,
......@@ -2611,6 +2620,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26112620 const deleteStashEntry = useCallback(
26122621 (entry: PromptStashEntry) => {
26132622 const { entry: removed, durable } = takeStashEntry(entry.id);
2623 if (!stashQueue.some((candidate) => candidate.id !== entry.id)) {
2624 setIsStashMenuOpen(false);
2625 }
26142626 if (durable && removed) {
26152627 for (const file of removed.files ?? []) {
26162628 releasePersistedAttachmentUpload({
......@@ -2630,7 +2642,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26302642 });
26312643 }
26322644 },
2633 [takeStashEntry],
2645 [stashQueue, takeStashEntry],
26342646 );
26352647
26362648 const stashCurrentPrompt = useCallback(async () => {
......@@ -2830,81 +2842,70 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28302842 ]);
28312843
28322844 const toggleStashMenu = useCallback(() => {
2833 setIsStashMenuOpen((open) => !open);
2834 }, []);
2835 const toggleInlineStashMenu = useCallback(() => {
28362845 if (isComposerCollapsedMobile) {
28372846 expandMobileComposer();
28382847 setIsStashMenuOpen(true);
28392848 return;
28402849 }
2841 toggleStashMenu();
2842 }, [expandMobileComposer, isComposerCollapsedMobile, toggleStashMenu]);
2850 setIsStashMenuOpen((open) => !open);
2851 }, [expandMobileComposer, isComposerCollapsedMobile]);
28432852 const toggleTasksDrawer = useCallback(() => {
28442853 setIsTasksDrawerOpen((open) => !open);
28452854 }, []);
2846 const activeTasksTurnId = activeThread?.latestTurn?.turnId ?? null;
2847 const tasksDismissedForActiveTurn =
2848 activeTasksTurnId !== null && dismissedTasksTurnId === activeTasksTurnId;
2849 const visibleTasksProgress = tasksDismissedForActiveTurn ? null : activeTasksProgress;
2850 const visibleTaskSteps = tasksDismissedForActiveTurn ? null : activeTaskSteps;
2855 const hasBannerItems = props.bannerItems.length > 0;
28512856 const hasBlockingComposerTopDrawer =
28522857 activePendingApproval !== null || pendingUserInputs.length > 0;
2853 const dismissTasks = useCallback(() => {
2854 if (activeTasksTurnId !== null) {
2855 setDismissedTasksTurnId(activeTasksTurnId);
2856 }
2857 setIsTasksDrawerOpen(false);
2858 }, [activeTasksTurnId]);
2859 const showInlineStashBadge =
2860 stashQueue.length > 0 &&
2861 !isComposerApprovalState &&
2862 (props.externalDrawerAttached ||
2863 showComposerTopDrawer ||
2864 isTasksDrawerOpen ||
2865 isComposerCollapsedMobile);
2866 const inlineStashBadge = showInlineStashBadge ? (
2867 <ComposerStashBadge
2868 count={stashQueue.length}
2869 menuOpen={isStashMenuOpen}
2870 placement="inline"
2871 pulseKey={stashPulse.key}
2872 pulsing={stashPulse.active}
2873 onToggleMenu={toggleInlineStashMenu}
2874 />
2875 ) : null;
28762858 const showInlineTasksBadge =
2877 visibleTasksProgress !== null &&
2878 visibleTaskSteps !== null &&
2859 activeTasksProgress !== null &&
2860 activeTaskSteps !== null &&
28792861 !isTasksDrawerOpen &&
28802862 !hasBlockingComposerTopDrawer &&
2881 (props.externalDrawerAttached || showComposerTopDrawer || isComposerCollapsedMobile);
2863 (hasBannerItems || showComposerTopDrawer || isComposerCollapsedMobile);
28822864 const inlineTasksBadge = showInlineTasksBadge ? (
28832865 <ComposerTasksBadge
28842866 expanded={false}
2885 onDismiss={dismissTasks}
28862867 onToggle={toggleTasksDrawer}
28872868 placement="inline"
2888 progress={visibleTasksProgress}
2889 steps={visibleTaskSteps}
2869 progress={activeTasksProgress}
2870 steps={activeTaskSteps}
28902871 />
28912872 ) : null;
2892 const showShoulderTabs =
2893 !props.externalDrawerAttached &&
2873 const showTasksTab =
2874 !hasBannerItems &&
28942875 !showComposerTopDrawer &&
28952876 !isTasksDrawerOpen &&
2896 !isComposerCollapsedMobile;
2897 const hasShoulderTab =
2898 showShoulderTabs &&
2899 (stashQueue.length > 0 ||
2900 (visibleTasksProgress !== null &&
2901 visibleTaskSteps !== null &&
2902 visibleTasksProgress.totalSteps > 0));
2877 !isComposerCollapsedMobile &&
2878 activeTasksProgress !== null &&
2879 activeTaskSteps !== null &&
2880 activeTasksProgress.totalSteps > 0;
2881 const activityStackContent = hasBannerItems ? (
2882 props.threadSyncPhase ? (
2883 <ComposerActivityRow phase={props.threadSyncPhase} />
2884 ) : !hasBlockingComposerTopDrawer && activeTasksProgress && activeTaskSteps ? (
2885 <ComposerTasksContent
2886 expanded={isTasksDrawerOpen}
2887 onToggle={toggleTasksDrawer}
2888 progress={activeTasksProgress}
2889 steps={activeTaskSteps}
2890 />
2891 ) : null
2892 ) : null;
2893 const activityStackItem: ComposerBannerStackContent | null = activityStackContent
2894 ? {
2895 id: "composer-activity",
2896 variant: "default",
2897 priority: "activity",
2898 content: activityStackContent,
2899 }
2900 : null;
2901 const bannerStackItems = activityStackItem
2902 ? [...props.bannerItems, activityStackItem]
2903 : props.bannerItems;
29032904 useEffect(() => {
2904 if (visibleTasksProgress === null || visibleTaskSteps === null) {
2905 if (activeTasksProgress === null || activeTaskSteps === null) {
29052906 setIsTasksDrawerOpen(false);
29062907 }
2907 }, [visibleTaskSteps, visibleTasksProgress]);
2908 }, [activeTaskSteps, activeTasksProgress]);
29082909
29092910 useEffect(() => {
29102911 if (hasBlockingComposerTopDrawer) {
......@@ -3427,145 +3428,163 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34273428 onDragOverCapture={composerMentionDragHandlers.onDragOver}
34283429 onDragLeaveCapture={onComposerMentionDragLeaveCapture}
34293430 onDropCapture={composerMentionDragHandlers.onDrop}
3430 className={cn("mx-auto w-full min-w-0 max-w-3xl", hasShoulderTab && "pt-7")}
3431 className="mx-auto w-full min-w-0 max-w-3xl"
34313432 data-chat-composer-form="true"
34323433 >
3433 {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
3434 <div
3435 className="chat-composer-top-drawer"
3436 data-chat-composer-top-drawer="true"
3437 data-variant={activePendingApproval ? "warning" : "info"}
3438 >
3439 {!isComposerCollapsedMobile && activePendingApproval ? (
3440 <div className="flex min-w-0 flex-wrap items-center gap-1 px-3 py-1.5 sm:px-4">
3441 <ComposerPendingApprovalPanel
3442 approval={activePendingApproval}
3443 pendingCount={pendingApprovals.length}
3444 />
3445 <div className="flex min-w-0 flex-wrap items-center gap-0.5">
3446 <ComposerPendingApprovalActions
3447 requestId={activePendingApproval.requestId}
3448 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3449 options={activePendingApproval.options}
3450 onRespondToApproval={onRespondToApproval}
3451 />
3452 </div>
3453 </div>
3454 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3455 <ComposerPendingUserInputPanel
3456 pendingUserInputs={pendingUserInputs}
3457 respondingRequestIds={respondingRequestIds}
3458 answers={activePendingDraftAnswers}
3459 questionIndex={activePendingQuestionIndex}
3460 onToggleOption={onSelectActivePendingUserInputOption}
3461 onAdvance={onAdvanceActivePendingUserInput}
3462 />
3463 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (
3464 <ComposerPlanFollowUpBanner
3465 key={activeProposedPlan.id}
3466 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
3467 />
3468 ) : isComposerCollapsedMobile && activePendingApproval ? (
3469 <div data-chat-composer-collapsed-controls="true">
3470 <ComposerPendingApprovalPanel
3471 approval={activePendingApproval}
3472 pendingCount={pendingApprovals.length}
3473 className="px-3 pt-2 sm:px-4"
3474 />
3475 <div className="flex flex-wrap items-center justify-end gap-1 px-3 pt-2 pb-3 sm:px-4">
3476 <ComposerPendingApprovalActions
3477 requestId={activePendingApproval.requestId}
3478 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3479 options={activePendingApproval.options}
3480 onRespondToApproval={onRespondToApproval}
3481 />
3482 </div>
3483 </div>
3484 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3485 <div data-chat-composer-collapsed-controls="true">
3486 <ComposerPendingUserInputPanel
3487 pendingUserInputs={pendingUserInputs}
3488 respondingRequestIds={respondingRequestIds}
3489 answers={activePendingDraftAnswers}
3490 questionIndex={activePendingQuestionIndex}
3491 onToggleOption={onSelectActivePendingUserInputOption}
3492 onAdvance={onAdvanceActivePendingUserInput}
3493 />
3494 <div className="px-3 pb-3 sm:px-4">
3495 <div
3496 data-chat-composer-mobile-pending-compact="true"
3497 className={cn(
3498 "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80",
3499 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
3500 )}
3501 >
3502 <button
3503 type="button"
3504 className={cn(
3505 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
3506 activePendingProgress?.customAnswer ? "text-foreground" : "text-placeholder",
3507 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
3508 )}
3509 onPointerDown={(event) => event.preventDefault()}
3510 onClick={expandMobileComposer}
3511 aria-label="Write custom answer"
3434 <ComposerBanner.Dock>
3435 <ComposerBanner.Column>
3436 <ComposerBannerStack
3437 key={activeThreadId}
3438 className="relative z-0"
3439 items={bannerStackItems}
3440 />
3441 {!activityStackItem && (props.threadSyncPhase || inlineTasksBadge) ? (
3442 <ComposerBanner.Attachment>
3443 <ComposerBanner.Root
3444 width={
3445 props.threadSyncPhase && !showComposerTopDrawer && !isComposerCollapsedMobile
3446 ? "content"
3447 : "fill"
3448 }
3449 data-chat-composer-activity-strip="true"
3450 >
3451 {props.threadSyncPhase ? (
3452 <ComposerActivityRow phase={props.threadSyncPhase} />
3453 ) : (
3454 inlineTasksBadge
3455 )}
3456 </ComposerBanner.Root>
3457 </ComposerBanner.Attachment>
3458 ) : null}
3459 {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
3460 <ComposerBanner.Attachment>
3461 <ComposerBanner.Root
3462 data-chat-composer-top-drawer="true"
3463 variant={activePendingApproval ? "warning" : "info"}
3464 >
3465 {activePendingApproval ? (
3466 <ComposerBanner.Row
3467 layout="wrap-actions"
3468 data-chat-composer-collapsed-controls="true"
35123469 >
3513 {activePendingProgress?.customAnswer || "Write custom answer"}
3514 </button>
3515 {inlineTasksBadge}
3516 {inlineStashBadge}
3517 {activePendingProgress?.activeQuestion?.multiSelect ? (
3518 <ComposerPrimaryActions
3519 compact
3520 pendingAction={pendingPrimaryAction}
3521 isRunning={false}
3522 showPlanFollowUpPrompt={false}
3523 promptHasText={false}
3524 isSendBusy={isSendBusy}
3525 sendDisabledReason={sendDisabledReason}
3526 isConnecting={isConnecting}
3527 isEnvironmentUnavailable={
3528 environmentUnavailable !== null ||
3529 noProviderAvailable ||
3530 projectSelectionRequired
3531 }
3532 isPreparingWorktree={false}
3533 hasSendableContent={false}
3534 preserveComposerFocusOnPointerDown
3535 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3536 onInterrupt={handleInterruptPrimaryAction}
3537 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3470 <ComposerBanner.Icon />
3471 <ComposerBanner.Content>
3472 <ComposerPendingApprovalPanel
3473 approval={activePendingApproval}
3474 pendingCount={pendingApprovals.length}
3475 />
3476 </ComposerBanner.Content>
3477 <ComposerBanner.Actions>
3478 <ComposerPendingApprovalActions
3479 requestId={activePendingApproval.requestId}
3480 isResponding={respondingRequestIds.includes(
3481 activePendingApproval.requestId,
3482 )}
3483 options={activePendingApproval.options}
3484 onRespondToApproval={onRespondToApproval}
3485 />
3486 </ComposerBanner.Actions>
3487 </ComposerBanner.Row>
3488 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3489 <ComposerPendingUserInputPanel
3490 pendingUserInputs={pendingUserInputs}
3491 respondingRequestIds={respondingRequestIds}
3492 answers={activePendingDraftAnswers}
3493 questionIndex={activePendingQuestionIndex}
3494 onToggleOption={onSelectActivePendingUserInputOption}
3495 onAdvance={onAdvanceActivePendingUserInput}
3496 />
3497 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (
3498 <ComposerPlanFollowUpBanner
3499 key={activeProposedPlan.id}
3500 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
3501 />
3502 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3503 <div data-chat-composer-collapsed-controls="true">
3504 <ComposerPendingUserInputPanel
3505 pendingUserInputs={pendingUserInputs}
3506 respondingRequestIds={respondingRequestIds}
3507 answers={activePendingDraftAnswers}
3508 questionIndex={activePendingQuestionIndex}
3509 onToggleOption={onSelectActivePendingUserInputOption}
3510 onAdvance={onAdvanceActivePendingUserInput}
35383511 />
3539 ) : null}
3540 </div>
3541 </div>
3542 </div>
3512 <ComposerBanner.Body>
3513 <div
3514 data-chat-composer-mobile-pending-compact="true"
3515 className={cn(
3516 "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80",
3517 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
3518 )}
3519 >
3520 <button
3521 type="button"
3522 className={cn(
3523 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
3524 activePendingProgress?.customAnswer
3525 ? "text-foreground"
3526 : "text-placeholder",
3527 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
3528 )}
3529 onPointerDown={(event) => event.preventDefault()}
3530 onClick={expandMobileComposer}
3531 aria-label="Write custom answer"
3532 >
3533 {activePendingProgress?.customAnswer || "Write custom answer"}
3534 </button>
3535 {activePendingProgress?.activeQuestion?.multiSelect ? (
3536 <ComposerPrimaryActions
3537 compact
3538 pendingAction={pendingPrimaryAction}
3539 isRunning={false}
3540 showPlanFollowUpPrompt={false}
3541 promptHasText={false}
3542 isSendBusy={isSendBusy}
3543 sendDisabledReason={sendDisabledReason}
3544 isConnecting={isConnecting}
3545 isEnvironmentUnavailable={
3546 environmentUnavailable !== null ||
3547 noProviderAvailable ||
3548 projectSelectionRequired
3549 }
3550 isPreparingWorktree={false}
3551 hasSendableContent={false}
3552 preserveComposerFocusOnPointerDown
3553 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3554 onInterrupt={handleInterruptPrimaryAction}
3555 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3556 />
3557 ) : null}
3558 </div>
3559 </ComposerBanner.Body>
3560 </div>
3561 ) : null}
3562 </ComposerBanner.Root>
3563 </ComposerBanner.Attachment>
35433564 ) : null}
3544 </div>
3545 ) : null}
3546 {isTasksDrawerOpen &&
3547 !hasBlockingComposerTopDrawer &&
3548 visibleTasksProgress &&
3549 visibleTaskSteps ? (
3550 <ComposerTasksDrawer
3551 onDismiss={dismissTasks}
3552 onCollapse={toggleTasksDrawer}
3553 progress={visibleTasksProgress}
3554 steps={visibleTaskSteps}
3555 />
3556 ) : null}
3557 <div className="relative">
3558 {showShoulderTabs && visibleTasksProgress && visibleTaskSteps ? (
3559 <ComposerTasksBadge
3560 expanded={false}
3561 hasTrailingShoulder={stashQueue.length > 0}
3562 onDismiss={dismissTasks}
3563 onToggle={toggleTasksDrawer}
3564 progress={visibleTasksProgress}
3565 steps={visibleTaskSteps}
3566 />
3567 ) : null}
3568 {showShoulderTabs ? (
3565 {!activityStackItem &&
3566 isTasksDrawerOpen &&
3567 !hasBlockingComposerTopDrawer &&
3568 activeTasksProgress &&
3569 activeTaskSteps ? (
3570 <ComposerTasksDrawer
3571 onCollapse={toggleTasksDrawer}
3572 progress={activeTasksProgress}
3573 steps={activeTaskSteps}
3574 />
3575 ) : null}
3576 {showTasksTab ? (
3577 <ComposerBanner.Attachment>
3578 <ComposerTasksBadge
3579 expanded={false}
3580 onToggle={toggleTasksDrawer}
3581 progress={activeTasksProgress}
3582 steps={activeTaskSteps}
3583 />
3584 </ComposerBanner.Attachment>
3585 ) : null}
3586 </ComposerBanner.Column>
3587 {!isComposerApprovalState ? (
35693588 <ComposerStashBadge
35703589 count={stashQueue.length}
35713590 menuOpen={isStashMenuOpen}
......@@ -3574,13 +3593,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35743593 onToggleMenu={toggleStashMenu}
35753594 />
35763595 ) : null}
3577 <div
3578 data-chat-composer-main-surface="true"
3579 className={cn(
3580 "group relative z-10 rounded-[22px] p-px transition-colors duration-200",
3581 composerProviderState.composerFrameClassName,
3582 )}
3583 >
3596 </ComposerBanner.Dock>
3597 <div className="relative">
3598 <ComposerSurface.Main className={composerProviderState.composerFrameClassName}>
35843599 <div
35853600 ref={composerSurfaceRef}
35863601 data-chat-composer-surface="true"
......@@ -3612,8 +3627,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36123627 : prompt.trim() ||
36133628 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
36143629 </button>
3615 {inlineTasksBadge}
3616 {inlineStashBadge}
36173630 <button
36183631 type="button"
36193632 className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
......@@ -4061,8 +4074,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
40614074 data-chat-composer-mobile-pending-actions="true"
40624075 className="absolute bottom-0 right-0 flex items-center justify-end gap-1"
40634076 >
4064 {inlineTasksBadge}
4065 {inlineStashBadge}
40664077 <ComposerPrimaryActions
40674078 compact
40684079 pendingAction={pendingPrimaryAction}
......@@ -4219,8 +4230,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42194230 </Tooltip>
42204231 </>
42214232 ) : null}
4222 {showMobilePendingAnswerActions ? null : inlineTasksBadge}
4223 {showMobilePendingAnswerActions ? null : inlineStashBadge}
42244233 <ComposerFooterPrimaryActions
42254234 compact={isComposerPrimaryActionsCompact}
42264235 activeContextWindow={activeContextWindow}
......@@ -4258,7 +4267,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
42584267 </div>
42594268 )}
42604269 </div>
4261 </div>
4270 </ComposerSurface.Main>
42624271 </div>
42634272 </form>
42644273 );
apps/web/src/components/chat/ComposerActivityStatus.tsx created+22
......@@ -0,0 +1,22 @@
1import { LoaderCircleIcon } from "lucide-react";
2import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
3import { ComposerBanner } from "./ComposerBanner";
4
5export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase }) {
6 return (
7 <ComposerBanner.Row>
8 <ComposerBanner.Icon>
9 <LoaderCircleIcon className="motion-safe:animate-spin" />
10 </ComposerBanner.Icon>
11 <ComposerBanner.Content>
12 <span
13 className="shrink-0 whitespace-nowrap text-muted-foreground"
14 data-composer-sync-status={phase}
15 role="status"
16 >
17 {threadSyncLabel(phase)}
18 </span>
19 </ComposerBanner.Content>
20 </ComposerBanner.Row>
21 );
22}
apps/web/src/components/chat/ComposerBanner.tsx created+348
......@@ -0,0 +1,348 @@
1import { mergeProps } from "@base-ui/react/merge-props";
2import { useRender } from "@base-ui/react/use-render";
3import { ChevronDownIcon, XIcon } from "lucide-react";
4import type { ComponentProps } from "react";
5
6import { cn } from "~/lib/utils";
7import { Button, buttonVariants } from "../ui/button";
8import { ScrollArea } from "../ui/scroll-area";
9
10export type ComposerBannerVariant = "default" | "error" | "info" | "success" | "warning";
11
12const surfaceColors = cn(
13 "[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--card))]",
14 "dark:[--chat-composer-attached-surface:var(--chat-composer-glass-surface,color-mix(in_srgb,var(--background)_96%,var(--color-white)))]",
15 "[html[data-theme-id]_&]:[--chat-composer-attached-surface:var(--app-theme-surface-raised)]",
16);
17
18const neutralOutline = cn(
19 "[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--contrast-foreground)_8%,transparent))]",
20 "dark:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--color-white)_5%,transparent))]",
21 "[html[data-theme-id]_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,var(--app-theme-toolbar-border))]",
22 "dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--app-theme-input)_30%,var(--background)))]",
23 "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-attached-outline:#241e28]",
24);
25
26const variantColors: Record<ComposerBannerVariant, string> = {
27 default: neutralOutline,
28 error:
29 "[--chat-composer-attached-outline:color-mix(in_srgb,var(--error)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--error)_8%,transparent)]",
30 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)]",
31 success:
32 "[--chat-composer-attached-outline:color-mix(in_srgb,var(--success)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--success)_4%,transparent)]",
33 warning:
34 "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]",
35};
36
37/** Shared glass and attachment seam, also used by the command menu without banner row padding. */
38function Surface({
39 placement = "attached",
40 variant = "default",
41 className,
42 ...props
43}: ComponentProps<"div"> & {
44 placement?: "attached" | "floating";
45 variant?: ComposerBannerVariant;
46}) {
47 return (
48 <div
49 data-composer-banner-surface={placement}
50 data-variant={variant}
51 className={cn(
52 surfaceColors,
53 "relative isolate border-0 bg-transparent shadow-none [--chat-composer-attached-tint:transparent]",
54 variantColors[variant],
55 placement === "attached"
56 ? "[--chat-composer-attachment-overlap:calc(1rem+1px)] before:rounded-t-[16px]"
57 : "[--chat-composer-attachment-overlap:0px] before:rounded-[1rem]",
58 "before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:border before:border-(--chat-composer-attached-outline)",
59 "before:bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint))] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)",
60 "before:mask-[linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),black_var(--chat-composer-attachment-overlap))] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]",
61 "dark:supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-[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))]",
62 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-(--chat-composer-attached-surface)",
63 className,
64 )}
65 {...props}
66 />
67 );
68}
69
70// A peeking notice uses the first hidden notice's severity, never the attached row's.
71const peekBorder: Record<ComposerBannerVariant, string> = {
72 default: "border-(--chat-composer-attached-outline)",
73 error: "border-destructive/24",
74 info: "border-info/24",
75 success: "border-success/24",
76 warning: "border-warning/24",
77};
78
79function Peek({
80 className,
81 variant = "default",
82 ...props
83}: ComponentProps<"button"> & { variant?: ComposerBannerVariant }) {
84 return (
85 <button
86 type="button"
87 data-slot="composer-banner-peek"
88 className={cn(
89 surfaceColors,
90 neutralOutline,
91 "absolute inset-x-0 bottom-0 z-0 mx-auto h-3 w-[96%] cursor-pointer rounded-t-2xl border border-b-0 shadow-[0_6px_18px_rgb(0_0_0/6%)]",
92 "bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] backdrop-blur-(--glass-blur) backdrop-saturate-(--glass-saturation)",
93 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:bg-(--chat-composer-attached-surface)",
94 "transition-opacity duration-150 ease-out focus-visible:outline-2 focus-visible:outline-ring",
95 peekBorder[variant],
96 className,
97 )}
98 {...props}
99 />
100 );
101}
102
103function Attachment({ className, ...props }: ComponentProps<"div">) {
104 return (
105 <div
106 data-slot="composer-banner-attachment"
107 className={cn(
108 "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2.75rem)] max-w-[45.25rem]",
109 // Adjacent attachments share their outline, including notices outside the form.
110 "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0",
111 "[&+:has([data-chat-composer-form])_[data-chat-composer-form]>[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",
112 className,
113 )}
114 {...props}
115 />
116 );
117}
118
119function Dock({ className, ...props }: ComponentProps<"div">) {
120 return (
121 <Attachment
122 className={cn(
123 "flex items-end gap-1 not-has-data-[composer-banner-surface=attached]:hidden",
124 className,
125 )}
126 {...props}
127 />
128 );
129}
130
131/** Attachments share a column while neighboring tabs keep their own surface. */
132function Column({ className, ...props }: ComponentProps<"div">) {
133 return (
134 <div
135 className={cn(
136 "flex min-w-0 flex-1 flex-col empty:hidden",
137 "[&>[data-slot=composer-banner-attachment]]:w-full [&>[data-slot=composer-banner-attachment]:last-child]:mb-0",
138 className,
139 )}
140 {...props}
141 />
142 );
143}
144
145function Root({
146 className,
147 placement = "attached",
148 variant = "default",
149 width = "fill",
150 ...props
151}: ComponentProps<"div"> & {
152 placement?: "attached" | "floating";
153 variant?: ComposerBannerVariant;
154 width?: "fill" | "content";
155}) {
156 return (
157 <Surface
158 className={cn(
159 "min-w-0 p-1 pb-[calc(var(--chat-composer-attachment-overlap)+(--spacing(1)))] text-xs/4 [--composer-banner-icon-column:--spacing(7)] sm:[--composer-banner-icon-column:--spacing(6)]",
160 width === "content" ? "w-fit max-w-full flex-none" : "@container",
161 className,
162 )}
163 data-slot="composer-banner"
164 placement={placement}
165 data-composer-banner-width={width}
166 variant={variant}
167 {...props}
168 />
169 );
170}
171
172/** The same row can be a status, a list item, or an entire disclosure button. */
173function Row({
174 className,
175 render,
176 layout = "inline",
177 ...props
178}: useRender.ComponentProps<"div"> & {
179 layout?: "inline" | "wrap-actions";
180}) {
181 const rowProps = {
182 className: cn(
183 "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",
184 "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]",
185 "[&: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",
186 layout === "wrap-actions" &&
187 "@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]",
188 className,
189 ),
190 "data-composer-banner-row": "true",
191 "data-composer-banner-layout": layout,
192 };
193 return useRender({
194 defaultTagName: "div",
195 render,
196 props: mergeProps<"div">(rowProps, props),
197 });
198}
199
200function Icon({ className, ...props }: ComponentProps<"span">) {
201 return (
202 <span
203 aria-hidden
204 data-slot="composer-banner-icon"
205 className={cn(
206 "col-start-1 row-start-1 flex w-(--composer-banner-icon-column) min-w-0 flex-none items-center justify-center text-muted-foreground [&>svg]:size-3",
207 className,
208 )}
209 {...props}
210 />
211 );
212}
213
214function Content({ className, ...props }: ComponentProps<"span">) {
215 return (
216 <span
217 data-slot="composer-banner-content"
218 className={cn(
219 "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0",
220 "group-not-has-[>[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",
221 "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",
222 className,
223 )}
224 {...props}
225 />
226 );
227}
228
229function Separator() {
230 return (
231 <span
232 aria-hidden
233 data-slot="composer-banner-separator"
234 className="mx-1 inline-block flex-none text-muted-foreground/40"
235 >
236 ·
237 </span>
238 );
239}
240
241function Actions({ className, ...props }: ComponentProps<"span">) {
242 return (
243 <span
244 data-slot="composer-banner-actions"
245 className={cn(
246 "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1",
247 className,
248 )}
249 {...props}
250 />
251 );
252}
253
254/** Child rows keep their parent's columns and begin immediately after its header. */
255function Children({ className, render, ...props }: useRender.ComponentProps<"div">) {
256 return useRender({
257 defaultTagName: "div",
258 render,
259 props: mergeProps<"div">(
260 { className: cn("grid gap-px [&_[data-composer-banner-row]]:min-h-5", className) },
261 props,
262 ),
263 });
264}
265
266/** Bounded banner content uses the app's scroll area and fades only overflowing edges. */
267function Scroll({ className, ...props }: ComponentProps<typeof ScrollArea>) {
268 return (
269 <ScrollArea
270 scrollFade
271 className={cn(
272 "h-auto max-h-[min(24rem,40dvh)] rounded-none [&>[data-slot=scroll-area-viewport][data-has-overflow-y]]:pe-2",
273 className,
274 )}
275 {...props}
276 />
277 );
278}
279
280function Count({ className, ...props }: ComponentProps<"span">) {
281 return (
282 <span
283 className={cn(
284 "inline-flex min-w-(--composer-banner-icon-column,1em) flex-none justify-center font-medium text-muted-foreground tabular-nums",
285 className,
286 )}
287 {...props}
288 />
289 );
290}
291
292function Body({ className, ...props }: ComponentProps<"div">) {
293 return (
294 <div
295 className={cn(
296 "min-w-0 ps-[calc(var(--composer-banner-icon-column)+(--spacing(1)))]",
297 className,
298 )}
299 {...props}
300 />
301 );
302}
303
304function Dot({ className, ...props }: ComponentProps<"span">) {
305 return (
306 <span className={cn("size-1.5 flex-none rounded-full bg-current", className)} {...props} />
307 );
308}
309
310function ToggleIcon({ expanded, className }: { expanded: boolean; className?: string }) {
311 return (
312 <span
313 aria-hidden
314 className={cn(buttonVariants({ size: "icon-xs", variant: "ghost" }), className)}
315 >
316 <ChevronDownIcon className={cn("size-3.5", !expanded && "rotate-180")} />
317 </span>
318 );
319}
320
321function Dismiss({ className, children, ...props }: ComponentProps<typeof Button>) {
322 return (
323 <Button size="icon-xs" variant="ghost" className={className} {...props}>
324 {children ?? <XIcon className="size-3.5" />}
325 </Button>
326 );
327}
328
329export const ComposerBanner = {
330 Surface,
331 Peek,
332 Attachment,
333 Dock,
334 Column,
335 Root,
336 Row,
337 Icon,
338 Content,
339 Separator,
340 Actions,
341 Children,
342 Scroll,
343 Count,
344 Body,
345 Dot,
346 ToggleIcon,
347 Dismiss,
348};
apps/web/src/components/chat/ComposerBannerStack.test.tsx deleted-108
......@@ -1,108 +0,0 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
5
6const banner = (
7 id: string,
8 variant: ComposerBannerStackItem["variant"] = "warning",
9): ComposerBannerStackItem => ({
10 id,
11 variant,
12 icon: <span aria-hidden="true">!</span>,
13 title: `${id} warning`,
14});
15
16describe("ComposerBannerStack", () => {
17 it("keeps expanded banners in layout flow so surrounding content moves out of their way", () => {
18 const markup = renderToStaticMarkup(
19 <ComposerBannerStack items={[banner("front"), banner("stacked")]} />,
20 );
21
22 const expandedItems = markup.match(
23 /<div data-composer-banner-stack-expanded-items="true" class="([^"]+)">/,
24 );
25
26 expect(expandedItems?.[1]).toContain("grid-rows-[0fr]");
27 expect(expandedItems?.[1]).toContain("group-hover/banner-stack:grid-rows-[1fr]");
28 expect(expandedItems?.[1]).toContain("z-20");
29 expect(expandedItems?.[1]).not.toContain("absolute");
30 expect(markup.indexOf("front warning")).toBeLessThan(markup.indexOf("stacked warning"));
31 expect(markup).toContain("invisible pointer-events-none");
32 expect(markup).toContain("group-focus-within/banner-stack:visible");
33 });
34
35 it("colors the collapsed stack cap by the hidden banner's variant, not a fixed warning", () => {
36 const neutralBehind = renderToStaticMarkup(
37 <ComposerBannerStack items={[banner("front", "default"), banner("stacked", "default")]} />,
38 );
39 expect(neutralBehind).toContain("chat-composer-banner-stack-cap");
40 expect(neutralBehind).toContain("border-[var(--chat-composer-attached-outline)]");
41 expect(neutralBehind).not.toContain("border-border");
42 expect(neutralBehind).not.toContain("border-warning/24");
43
44 const warningBehind = renderToStaticMarkup(
45 <ComposerBannerStack items={[banner("front", "default"), banner("stacked", "warning")]} />,
46 );
47 expect(warningBehind).toContain("border-warning/24");
48 });
49
50 it("does not render an expandable region for a single banner", () => {
51 const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />);
52
53 expect(markup).not.toContain("data-composer-banner-stack-expanded-items");
54 expect(markup).toContain("chat-composer-drawer-surface");
55 expect(markup).toContain("chat-composer-drawer-attached");
56 expect(markup).not.toContain("before:mask-none");
57 expect(markup).toContain("text-xs");
58 expect(markup).toContain('data-composer-banner-drawer="true"');
59 expect(markup).toContain('data-variant="warning"');
60 expect(markup).toContain("transform:none");
61 expect(markup).not.toContain("will-change:transform");
62 });
63 it("applies item-specific surface and action layout classes", () => {
64 const markup = renderToStaticMarkup(
65 <ComposerBannerStack
66 items={[
67 {
68 ...banner("branch"),
69 className: "branch-surface",
70 actionClassName: "branch-actions",
71 actions: <button type="button">Repair</button>,
72 },
73 ]}
74 />,
75 );
76
77 expect(markup).toContain("branch-surface");
78 expect(markup).toContain("branch-actions");
79 });
80
81 it("renders a disabled compaction action on the shared accessible banner surface", () => {
82 const markup = renderToStaticMarkup(
83 <ComposerBannerStack
84 items={[
85 {
86 id: "resume-compaction",
87 variant: "info",
88 icon: <span aria-hidden="true">!</span>,
89 title: "Resume with less context",
90 description: "250k tokens from an older session",
91 actions: (
92 <button type="button" disabled>
93 Compact
94 </button>
95 ),
96 dismissLabel: "Keep full history",
97 onDismiss: () => {},
98 },
99 ]}
100 />,
101 );
102
103 expect(markup).toContain('role="alert"');
104 expect(markup).toContain("chat-composer-drawer-attached");
105 expect(markup).toContain('disabled=""');
106 expect(markup).toContain('aria-label="Keep full history"');
107 });
108});
apps/web/src/components/chat/ComposerBannerStack.tsx+173-133
......@@ -1,60 +1,52 @@
1import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2import { XIcon } from "lucide-react";
1import { useEffect, useId, useRef, useState, type ReactNode } from "react";
32
43import { cn } from "~/lib/utils";
5import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert";
6import { Button } from "../ui/button";
4import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
75
6// Match the duration-220 exit transition before removing a dismissed notice.
87const DISMISS_TRANSITION_MS = 220;
9const frontExitStyle = {
10 opacity: 0,
11 transform: "translate3d(0, 4rem, 0)",
12} satisfies CSSProperties;
13const stackedExitStyle = {
14 opacity: 0,
15 transform: "translate3d(0, 7rem, 0)",
16} satisfies CSSProperties;
17const restingStyle = {
18 opacity: 1,
19 transform: "none",
20} satisfies CSSProperties;
21const exitTransitionStyle = {
22 transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`,
23} satisfies CSSProperties;
24
25// The collapsed cap peeking above the front banner is the only hint that more
26// banners are stacked behind it, so its border must match the severity of the
27// first hidden banner — a neutral banner must not masquerade as a warning.
28const stackCapBorderClass: Record<ComposerBannerStackItem["variant"], string> = {
29 default: "border-[var(--chat-composer-attached-outline)]",
30 error: "border-destructive/24",
31 info: "border-info/24",
32 success: "border-success/24",
33 warning: "border-warning/24",
34};
358
369export interface ComposerBannerStackItem {
3710 readonly id: string;
38 readonly variant: "default" | "error" | "info" | "success" | "warning";
39 // Ordering hint for stack assemblers: front this banner even though its
40 // variant is calm (e.g. live update progress). The stack itself ignores it.
41 readonly urgent?: boolean;
11 readonly variant: ComposerBannerVariant;
12 readonly priority?: "urgent" | "activity" | "notice";
4213 readonly icon: ReactNode;
4314 readonly title: ReactNode;
4415 readonly description?: ReactNode;
16 readonly children?: ReactNode;
4517 readonly actions?: ReactNode;
4618 readonly className?: string;
47 readonly actionClassName?: string;
4819 readonly dismissLabel?: string;
4920 readonly onDismiss?: () => void;
5021}
5122
23export type ComposerBannerStackContent = Pick<
24 ComposerBannerStackItem,
25 "id" | "variant" | "priority" | "className"
26> & { readonly content: ReactNode };
27
28type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent;
29
30function bannerPriority(item: ComposerBannerStackEntry) {
31 if (item.priority === "activity") {
32 return 0;
33 }
34 if (item.priority === "urgent" || item.variant === "error" || item.variant === "warning") {
35 return 1;
36 }
37 return 2;
38}
39
5240interface ComposerBannerStackProps {
5341 readonly className?: string;
54 readonly items: ReadonlyArray<ComposerBannerStackItem>;
42 readonly items: ReadonlyArray<ComposerBannerStackEntry>;
5543}
5644
5745export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) {
46 const [stackExpanded, setStackExpanded] = useState(false);
47 const noticesRef = useRef<HTMLDivElement>(null);
48 const peekRef = useRef<HTMLButtonElement>(null);
49 const expandedItemsId = useId();
5850 const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null);
5951 const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
6052 const exitingItemId =
......@@ -70,21 +62,27 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
7062 };
7163 }, []);
7264
65 useEffect(() => {
66 if (items.length < 2) setStackExpanded(false);
67 }, [items.length]);
68
7369 if (items.length === 0) {
7470 return null;
7571 }
7672
77 const frontItem = items[0];
73 // Activity stays attached. Urgency and severity only order the notices behind it.
74 const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b));
75 const frontItem = orderedItems[0];
7876 if (!frontItem) {
7977 return null;
8078 }
81 const stackedItems = items.slice(1);
79 const stackedItems = orderedItems.slice(1);
8280 const hasStack = stackedItems.length > 0;
8381 const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;
8482 const firstStackedItem = stackedItems[0];
8583
86 const requestDismiss = (item: ComposerBannerStackItem) => {
87 if (!item.onDismiss || exitingItemId) {
84 const requestDismiss = (item: ComposerBannerStackEntry) => {
85 if (!("onDismiss" in item) || !item.onDismiss || exitingItemId) {
8886 return;
8987 }
9088 setExitingItemId(item.id);
......@@ -98,37 +96,28 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
9896 };
9997
10098 return (
101 <div
102 className={cn("group/banner-stack chat-composer-drawer-slot", className)}
99 <ComposerBanner.Attachment
100 className={className}
103101 data-composer-banner-drawer="true"
102 data-chat-composer-collapsed-controls="true"
104103 >
105 <div
106 className={cn(
107 "relative flex flex-col-reverse",
108 hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null,
109 )}
110 >
111 {showCollapsedStackCap && firstStackedItem ? (
112 <div
113 className={cn(
114 "pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-2xl",
115 "chat-composer-banner-stack-cap border border-b-0 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",
116 stackCapBorderClass[firstStackedItem.variant],
117 "transition-opacity duration-150 ease-out",
118 "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0",
119 )}
120 style={{ width: "96%" }}
121 aria-hidden="true"
122 />
123 ) : null}
104 <div className={cn("relative flex flex-col-reverse", hasStack && stackExpanded && "z-50")}>
124105 <div
125106 className={cn(
126 "relative z-10",
127 exitingItemId === frontItem.id ? "pointer-events-none" : null,
107 "relative z-10 transition-[translate,opacity] duration-220 ease-in",
108 exitingItemId === frontItem.id
109 ? "pointer-events-none translate-y-16 opacity-0"
110 : "opacity-100",
128111 )}
129 style={{
130 ...exitTransitionStyle,
131 ...(exitingItemId === frontItem.id ? frontExitStyle : restingStyle),
112 onPointerDownCapture={() => {
113 setStackExpanded(false);
114 const activeElement = document.activeElement;
115 if (
116 activeElement instanceof HTMLElement &&
117 noticesRef.current?.contains(activeElement)
118 ) {
119 activeElement.blur();
120 }
132121 }}
133122 >
134123 <ComposerBannerStackAlert
......@@ -140,44 +129,86 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
140129 </div>
141130 {hasStack ? (
142131 <div
143 data-composer-banner-stack-expanded-items="true"
144 className={cn(
145 "relative z-20 grid grid-rows-[0fr] transition-[grid-template-rows] duration-150 ease-out",
146 "group-hover/banner-stack:grid-rows-[1fr] group-focus-within/banner-stack:grid-rows-[1fr]",
147 )}
132 ref={noticesRef}
133 className="relative z-20"
134 onPointerEnter={(event) => {
135 if (event.pointerType !== "touch") setStackExpanded(true);
136 }}
137 onPointerLeave={(event) => {
138 if (!event.currentTarget.contains(document.activeElement)) setStackExpanded(false);
139 }}
140 onFocusCapture={() => setStackExpanded(true)}
141 onBlurCapture={(event) => {
142 if (
143 !event.currentTarget.contains(event.relatedTarget) &&
144 !event.currentTarget.matches(":hover")
145 ) {
146 setStackExpanded(false);
147 }
148 }}
149 onKeyDown={(event) => {
150 if (event.key !== "Escape") return;
151 event.stopPropagation();
152 peekRef.current?.focus({ preventScroll: true });
153 setStackExpanded(false);
154 }}
148155 >
149 <div className="min-h-0 overflow-hidden">
150 <div
151 className={cn(
152 "invisible pointer-events-none space-y-2 pb-2 opacity-0",
153 "translate-y-1 transform-gpu transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]",
154 "group-hover/banner-stack:visible group-hover/banner-stack:pointer-events-auto group-hover/banner-stack:translate-y-0 group-hover/banner-stack:opacity-100",
155 "group-focus-within/banner-stack:visible group-focus-within/banner-stack:pointer-events-auto group-focus-within/banner-stack:translate-y-0 group-focus-within/banner-stack:opacity-100",
156 )}
157 >
158 {stackedItems.map((item) => (
159 <div
160 key={item.id}
161 className={cn(exitingItemId === item.id ? "pointer-events-none" : null)}
162 style={{
163 ...exitTransitionStyle,
164 ...(exitingItemId === item.id ? stackedExitStyle : restingStyle),
165 }}
166 >
167 <ComposerBannerStackAlert
168 item={item}
169 attached={false}
170 exiting={exitingItemId === item.id}
171 onDismissRequest={() => requestDismiss(item)}
172 />
173 </div>
174 ))}
156 {showCollapsedStackCap && firstStackedItem ? (
157 <ComposerBanner.Peek
158 ref={peekRef}
159 variant={firstStackedItem.variant}
160 aria-label="Show other notices"
161 aria-expanded={stackExpanded}
162 aria-controls={expandedItemsId}
163 onClick={(event) => {
164 event.currentTarget.focus({ preventScroll: true });
165 setStackExpanded(true);
166 }}
167 className={cn(stackExpanded && "opacity-0")}
168 />
169 ) : null}
170 <div
171 id={expandedItemsId}
172 data-composer-banner-stack-expanded-items="true"
173 className={cn(
174 "grid transition-[grid-template-rows] duration-150 ease-out",
175 stackExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
176 )}
177 >
178 <div className="min-h-0 overflow-hidden">
179 <div
180 className={cn(
181 "transform-gpu space-y-2 pb-2 transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]",
182 stackExpanded
183 ? "pointer-events-auto visible translate-y-0 opacity-100"
184 : "pointer-events-none invisible translate-y-1 opacity-0",
185 )}
186 >
187 {stackedItems.map((item) => (
188 <div
189 key={item.id}
190 className={cn(
191 "transition-[translate,opacity] duration-220 ease-in",
192 exitingItemId === item.id
193 ? "pointer-events-none translate-y-28 opacity-0"
194 : "opacity-100",
195 )}
196 >
197 <ComposerBannerStackAlert
198 item={item}
199 attached={false}
200 exiting={exitingItemId === item.id}
201 onDismissRequest={() => requestDismiss(item)}
202 />
203 </div>
204 ))}
205 </div>
175206 </div>
176207 </div>
177208 </div>
178209 ) : null}
179210 </div>
180 </div>
211 </ComposerBanner.Attachment>
181212 );
182213}
183214
......@@ -187,50 +218,59 @@ function ComposerBannerStackAlert({
187218 exiting,
188219 onDismissRequest,
189220}: {
190 readonly item: ComposerBannerStackItem;
221 readonly item: ComposerBannerStackEntry;
191222 readonly attached: boolean;
192223 readonly exiting: boolean;
193224 readonly onDismissRequest: () => void;
194225}) {
195 const dismissOnly = item.onDismiss && !item.actions;
226 if ("content" in item) {
227 return (
228 <ComposerBanner.Root
229 placement={attached ? "attached" : "floating"}
230 variant={item.variant}
231 className={item.className}
232 >
233 {item.content}
234 </ComposerBanner.Root>
235 );
236 }
196237
197238 return (
198 <Alert
239 <ComposerBanner.Root
240 role="alert"
241 placement={attached ? "attached" : "floating"}
199242 variant={item.variant}
200 className={cn(
201 attached
202 ? "chat-composer-drawer-surface chat-composer-drawer-attached px-3 pt-2 pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)] text-xs sm:px-4"
203 : "alert-glass rounded-[22px]",
204 item.className,
205 )}
206 data-variant={item.variant}
243 className={item.className}
207244 >
208 {item.icon}
209 <AlertTitle>{item.title}</AlertTitle>
210 {item.description ? <AlertDescription>{item.description}</AlertDescription> : null}
211 {item.actions || item.onDismiss ? (
212 <AlertAction
213 className={cn(
214 item.actionClassName,
215 dismissOnly
216 ? "max-sm:col-start-3 max-sm:row-start-1 max-sm:mt-0 max-sm:self-start"
217 : undefined,
218 )}
219 >
220 {item.actions}
221 {item.onDismiss ? (
222 <Button
223 size="icon-xs"
224 variant="ghost"
225 aria-label={item.dismissLabel ?? "Dismiss warning"}
226 disabled={exiting}
227 onClick={onDismissRequest}
228 >
229 <XIcon className="size-3.5" />
230 </Button>
245 <ComposerBanner.Row layout="wrap-actions">
246 <ComposerBanner.Icon>{item.icon}</ComposerBanner.Icon>
247 <ComposerBanner.Content className="font-medium">{item.title}</ComposerBanner.Content>
248 {item.actions || item.onDismiss ? (
249 <ComposerBanner.Actions>
250 {item.actions}
251 {item.onDismiss ? (
252 <ComposerBanner.Dismiss
253 aria-label={item.dismissLabel ?? "Dismiss warning"}
254 disabled={exiting}
255 onClick={onDismissRequest}
256 />
257 ) : null}
258 </ComposerBanner.Actions>
259 ) : null}
260 </ComposerBanner.Row>
261 {item.description || item.children ? (
262 <ComposerBanner.Children>
263 {item.description ? (
264 <ComposerBanner.Row>
265 <ComposerBanner.Icon />
266 <ComposerBanner.Content className="text-muted-foreground">
267 {item.description}
268 </ComposerBanner.Content>
269 </ComposerBanner.Row>
231270 ) : null}
232 </AlertAction>
271 {item.children}
272 </ComposerBanner.Children>
233273 ) : null}
234 </Alert>
274 </ComposerBanner.Root>
235275 );
236276}
apps/web/src/components/chat/ComposerCommandMenu.test.tsx-2
......@@ -19,8 +19,6 @@ describe("ComposerCommandMenu", () => {
1919 );
2020
2121 expect(markup).toContain('data-composer-command-drawer="true"');
22 expect(markup).toContain("chat-composer-drawer-surface");
23 expect(markup).toContain("chat-composer-drawer-attached");
2422 expect(markup).not.toContain("dropdown-glass");
2523 });
2624
apps/web/src/components/chat/ComposerCommandMenu.tsx+4-3
......@@ -24,6 +24,7 @@ import { cn } from "~/lib/utils";
2424import { Badge } from "../ui/badge";
2525import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
2626import { PierreEntryIcon } from "./PierreEntryIcon";
27import { ComposerBanner } from "./ComposerBanner";
2728
2829export type ComposerCommandItem =
2930 | {
......@@ -88,9 +89,9 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
8889 );
8990 }}
9091 >
91 <div
92 <ComposerBanner.Surface
9293 ref={listRef}
93 className="chat-composer-drawer-surface chat-composer-drawer-attached relative w-full overflow-hidden **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"
94 className="w-full overflow-hidden pb-(--chat-composer-attachment-overlap) **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"
9495 data-composer-command-drawer="true"
9596 >
9697 {props.items.length > 0 ? (
......@@ -125,7 +126,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
125126 </p>
126127 </div>
127128 )}
128 </div>
129 </ComposerBanner.Surface>
129130 </Command>
130131 );
131132});
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+2-2
......@@ -31,7 +31,7 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
3131 : "File change";
3232
3333 return (
34 <div
34 <span
3535 aria-label={fallbackLabel}
3636 className={cn("flex min-w-0 flex-1 items-center gap-2", className)}
3737 role="group"
......@@ -54,6 +54,6 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
5454 1/{pendingCount}
5555 </span>
5656 ) : null}
57 </div>
57 </span>
5858 );
5959});
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+25-42
......@@ -5,9 +5,10 @@ import {
55 derivePendingUserInputProgress,
66 type PendingUserInputDraftAnswer,
77} from "../../pendingUserInput";
8import { CheckIcon, ChevronDownIcon } from "lucide-react";
8import { CheckIcon } from "lucide-react";
99import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
1010import { cn } from "~/lib/utils";
11import { ComposerBanner } from "./ComposerBanner";
1112
1213interface PendingUserInputPanelProps {
1314 pendingUserInputs: PendingUserInput[];
......@@ -167,58 +168,40 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
167168
168169 return (
169170 <Collapsible
170 className="py-2"
171171 open={!isCollapsed}
172172 onOpenChange={(open) => {
173173 setCollapsedQuestionId(open ? null : activeQuestion.id);
174174 }}
175175 >
176 {/* The trigger's wrapper is inset less than the card's text column, and
177 the trigger pays the difference back as padding: the hover background
178 and focus ring bleed 10px past that column on both sides, while the
179 header label and the chevron still line up with the left and right
180 edges of the question text below. The negative block margin keeps the
181 taller hit area from pushing the panel down. */}
182 <div className="flex items-center gap-1 px-1 sm:px-2">
183 <CollapsibleTrigger
184 title={
185 isCollapsed ? "Show the question and its options" : "Hide the question and its options"
186 }
187 data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}
188 className="group -my-1 flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors duration-150 hover:bg-muted/35 focus-visible:ring-1 focus-visible:ring-primary/25"
189 >
190 <span className="text-xs font-medium text-muted-foreground group-hover:text-foreground/85">
176 <CollapsibleTrigger
177 render={<ComposerBanner.Row render={<button type="button" />} />}
178 title={
179 isCollapsed ? "Show the question and its options" : "Hide the question and its options"
180 }
181 data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}
182 >
183 <ComposerBanner.Icon />
184 <ComposerBanner.Content>
185 <span className="shrink-0 font-medium text-muted-foreground">
191186 {activeQuestion.header}
192187 </span>
188 {isCollapsed ? (
189 <span className="min-w-0 flex-1 truncate text-secondary-label">
190 {activeQuestion.question}
191 </span>
192 ) : null}
193 </ComposerBanner.Content>
194 <ComposerBanner.Actions>
193195 {prompt.questions.length > 1 ? (
194196 <span className="text-[10px] font-medium text-muted-foreground tabular-nums">
195197 {questionIndex + 1}/{prompt.questions.length}
196198 </span>
197199 ) : null}
198 {/* Collapsed, the header is otherwise just a section label and a
199 counter, so the question itself is echoed here as a one-line
200 reminder of what is being asked. */}
201 {isCollapsed ? (
202 <span className="min-w-0 flex-1 truncate text-secondary-label text-xs">
203 {activeQuestion.question}
204 </span>
205 ) : null}
206 {/* The chevron points at the body: down while it is open below the
207 header, up while it is collapsed into it. */}
208 <ChevronDownIcon
209 aria-hidden="true"
210 className={cn(
211 "ml-auto size-3.5 shrink-0 text-secondary-label transition-transform duration-150 group-hover:text-foreground",
212 isCollapsed && "rotate-180",
213 )}
214 />
215 </CollapsibleTrigger>
216 </div>
217 {/* The panel carries the horizontal padding itself: it clips its content
218 while the height animates, so the option buttons have to sit inside
219 that padding or their focus rings get shaved off at the edges. */}
220 <CollapsiblePanel className="px-3 sm:px-4">
221 <div className="pt-2 pb-0.5">
200 <ComposerBanner.ToggleIcon expanded={!isCollapsed} />
201 </ComposerBanner.Actions>
202 </CollapsibleTrigger>
203 <CollapsiblePanel>
204 <ComposerBanner.Body>
222205 <p className="text-sm text-foreground/85">{activeQuestion.question}</p>
223206 {activeQuestion.multiSelect ? (
224207 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
......@@ -276,7 +259,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
276259 );
277260 })}
278261 </div>
279 </div>
262 </ComposerBanner.Body>
280263 </CollapsiblePanel>
281264 </Collapsible>
282265 );
apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx+8-9
......@@ -1,4 +1,5 @@
11import { memo } from "react";
2import { ComposerBanner } from "./ComposerBanner";
23
34export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({
45 planTitle,
......@@ -6,16 +7,14 @@ export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBann
67 planTitle: string | null;
78}) {
89 return (
9 <div className="px-3 py-2 sm:px-4">
10 <div className="flex flex-wrap items-center gap-2">
11 <span className="text-xs font-medium text-muted-foreground">Plan ready</span>
10 <ComposerBanner.Row>
11 <ComposerBanner.Icon />
12 <ComposerBanner.Content>
13 <span className="shrink-0 font-medium text-muted-foreground">Plan ready</span>
1214 {planTitle ? (
13 <span className="min-w-0 flex-1 truncate text-xs text-foreground/85">{planTitle}</span>
15 <span className="min-w-0 flex-1 truncate text-foreground/85">{planTitle}</span>
1416 ) : null}
15 </div>
16 {/* <div className="mt-2 text-xs text-muted-foreground">
17 Review the plan
18 </div> */}
19 </div>
17 </ComposerBanner.Content>
18 </ComposerBanner.Row>
2019 );
2120});
apps/web/src/components/chat/ComposerServerUpdateStatus.tsx created+49
......@@ -0,0 +1,49 @@
1import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
2import { useId, useState } from "react";
3
4import { serverUpdateStageLabel } from "../ServerUpdateAction";
5import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6import { ComposerBanner } from "./ComposerBanner";
7
8/** One text line, clipped at the end so the error detail never squeezes its title. */
9export function ComposerServerUpdateStatus({
10 state,
11 serverLabel = "server",
12}: {
13 readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
14 readonly serverLabel?: string;
15}) {
16 const [detailsOpen, setDetailsOpen] = useState(false);
17 const triggerId = useId();
18 const title = `${state.status === "failed" ? "Could not update" : "Updating"} ${serverLabel}`;
19 const detail = state.status === "failed" ? state.message : serverUpdateStageLabel(state.stage);
20 return (
21 <span
22 role={state.status === "failed" ? "alert" : "status"}
23 className="min-w-0"
24 data-composer-server-update-status={state.status}
25 >
26 <Tooltip open={detailsOpen} onOpenChange={setDetailsOpen} triggerId={triggerId}>
27 <TooltipTrigger
28 id={triggerId}
29 closeOnClick={false}
30 render={
31 <button
32 type="button"
33 aria-label={`${title}: ${detail}`}
34 className="block max-w-full cursor-help truncate rounded-sm text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
35 onClick={() => setDetailsOpen(true)}
36 >
37 {title}
38 <ComposerBanner.Separator />
39 <span className="font-normal text-muted-foreground">{detail}</span>
40 </button>
41 }
42 />
43 <TooltipPopup side="top" className="max-w-80">
44 {title}: {detail}
45 </TooltipPopup>
46 </Tooltip>
47 </span>
48 );
49}
apps/web/src/components/chat/ComposerStashBadge.test.tsx deleted-68
......@@ -1,68 +0,0 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerStashBadge } from "./ComposerStashBadge";
5
6describe("ComposerStashBadge", () => {
7 it("renders as an attached composer tab instead of a floating pill", () => {
8 const markup = renderToStaticMarkup(
9 <ComposerStashBadge
10 count={3}
11 menuOpen={false}
12 pulseKey={0}
13 pulsing={false}
14 onToggleMenu={() => {}}
15 />,
16 );
17
18 expect(markup).toContain("chat-composer-stash-tab");
19 expect(markup).toContain("rounded-t-xl");
20 expect(markup).toContain("border-b-0");
21 expect(markup).toContain("items-center");
22 expect(markup).not.toContain("items-start");
23 expect(markup).not.toContain("rounded-full");
24 expect(markup).not.toContain("opacity-70");
25 expect(markup).not.toContain("border-border");
26 expect(markup).toContain('aria-expanded="false"');
27 });
28
29 it("reports when the stash drawer is open", () => {
30 const markup = renderToStaticMarkup(
31 <ComposerStashBadge
32 count={3}
33 menuOpen
34 pulseKey={0}
35 pulsing={false}
36 onToggleMenu={() => {}}
37 />,
38 );
39
40 expect(markup).toContain('aria-expanded="true"');
41 expect(markup).toContain("text-foreground");
42 expect(markup).toContain("pointer-events-none");
43 expect(markup).not.toContain("invisible");
44 });
45
46 it("keeps a compact inline entry point when a drawer occupies the tab", () => {
47 const markup = renderToStaticMarkup(
48 <ComposerStashBadge
49 count={3}
50 menuOpen
51 placement="inline"
52 pulseKey={0}
53 pulsing={false}
54 onToggleMenu={() => {}}
55 />,
56 );
57
58 expect(markup).toContain('data-slot="button"');
59 expect(markup).toContain("rounded-sm");
60 expect(markup).toContain("focus-visible:ring-2");
61 expect(markup).toContain("pointer-coarse:after:min-h-11");
62 expect(markup).toContain("Stashed prompts: 3. Open stash.");
63 expect(markup).toContain('aria-expanded="true"');
64 expect(markup).not.toContain("chat-composer-stash-tab");
65 expect(markup).not.toContain("rounded-t-xl");
66 expect(markup).not.toContain(" pointer-events-none ");
67 });
68});
apps/web/src/components/chat/ComposerStashBadge.tsx+21-47
......@@ -2,12 +2,11 @@ import { BookmarkIcon } from "lucide-react";
22import { memo } from "react";
33
44import { cn } from "~/lib/utils";
5import { Button } from "../ui/button";
5import { ComposerBanner } from "./ComposerBanner";
66
77/**
8 * Bookmark control that shows the stash count and opens the stash menu. It
9 * sits behind the composer's shoulder unless another drawer needs that space,
10 * then moves into the composer's existing controls.
8 * Bookmark tab that shows the stash count beside the composer's other attachments
9 * and opens the stash menu.
1110 *
1211 * On save the badge gives one quiet acknowledgement: it lifts to full
1312 * opacity and the count ticks over. `pulseKey` changes per stash, remounting
......@@ -16,72 +15,47 @@ import { Button } from "../ui/button";
1615export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
1716 count: number;
1817 menuOpen: boolean;
19 placement?: "inline" | "tab";
2018 pulseKey: number;
2119 pulsing: boolean;
2220 onToggleMenu: () => void;
2321}) {
2422 if (props.count === 0) return null;
25 const inline = props.placement === "inline";
2623 const count = (
27 <span
24 <ComposerBanner.Count
2825 key={props.pulseKey}
2926 className={cn(
30 "text-[10px] font-medium leading-none tabular-nums",
3127 props.pulsing
3228 ? "animate-[prompt-stash-count-enter_180ms_ease-out_both] text-primary motion-reduce:animate-none"
3329 : "text-muted-foreground",
3430 )}
3531 >
3632 {props.count}
37 </span>
33 </ComposerBanner.Count>
3834 );
3935
40 if (inline) {
41 return (
42 <Button
43 size="micro"
44 variant="ghost-muted"
36 return (
37 <ComposerBanner.Root width="content" data-composer-shoulder-tab className="ml-auto">
38 <ComposerBanner.Row
39 render={<button type="button" />}
4540 data-prompt-stash-badge="true"
4641 aria-label={`Stashed prompts: ${props.count}. Open stash.`}
4742 aria-expanded={props.menuOpen}
4843 className={cn(
49 "shrink-0 gap-1 px-1.5",
50 (props.menuOpen || props.pulsing) &&
51 "[--control-icon-color:currentColor] text-foreground",
44 "transition-colors duration-200",
45 props.menuOpen && "pointer-events-none",
46 props.menuOpen || props.pulsing
47 ? "text-foreground"
48 : "text-muted-foreground hover:text-foreground",
5249 )}
5350 onPointerDown={(event) => event.preventDefault()}
5451 onClick={props.onToggleMenu}
5552 >
56 <BookmarkIcon className="size-3 shrink-0" aria-hidden="true" />
57 {count}
58 </Button>
59 );
60 }
61
62 return (
63 <button
64 type="button"
65 data-prompt-stash-badge="true"
66 aria-label={`Stashed prompts: ${props.count}. Open stash.`}
67 aria-expanded={props.menuOpen}
68 className={cn(
69 "chat-composer-shoulder-tab chat-composer-stash-tab absolute -top-7 right-5.5 z-0 inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-t-xl border border-b-0 px-3 pb-1 text-xs leading-none",
70 "transition-[color,border-color] duration-200",
71 props.menuOpen && "pointer-events-none",
72 props.menuOpen || props.pulsing
73 ? "text-foreground"
74 : "text-muted-foreground hover:text-foreground",
75 )}
76 onPointerDown={(event) => {
77 // Keep composer focus so Escape/typing flows stay intact.
78 event.preventDefault();
79 }}
80 onClick={props.onToggleMenu}
81 >
82 <BookmarkIcon className="size-3 shrink-0" aria-hidden="true" />
83 Stash
84 {count}
85 </button>
53 <ComposerBanner.Icon>
54 <BookmarkIcon />
55 </ComposerBanner.Icon>
56 <ComposerBanner.Content>Stash</ComposerBanner.Content>
57 <ComposerBanner.Actions>{count}</ComposerBanner.Actions>
58 </ComposerBanner.Row>
59 </ComposerBanner.Root>
8660 );
8761});
apps/web/src/components/chat/ComposerStashMenu.test.tsx-44
......@@ -5,41 +5,6 @@ import { describe, expect, it } from "vite-plus/test";
55import { ComposerStashMenu } from "./ComposerStashMenu";
66
77describe("ComposerStashMenu", () => {
8 it("renders saved prompts as an attached composer drawer", () => {
9 const markup = renderToStaticMarkup(
10 <ComposerStashMenu
11 entries={[]}
12 stashShortcutLabel="Ctrl+S"
13 onRestore={() => {}}
14 onDelete={() => {}}
15 onClose={() => {}}
16 />,
17 );
18
19 expect(markup).toContain('data-composer-stash-drawer="true"');
20 expect(markup).toContain("chat-composer-drawer-surface");
21 expect(markup).toContain("chat-composer-drawer-attached");
22 expect(markup).toContain('aria-label="Close stash"');
23 expect(markup).not.toContain("dropdown-glass");
24 expect(markup).not.toContain("Stashed prompts");
25 expect(markup).toContain("Press Ctrl+S with a prompt in the composer to stash it.");
26 });
27
28 it("does not advertise a shortcut when stash is unbound", () => {
29 const markup = renderToStaticMarkup(
30 <ComposerStashMenu
31 entries={[]}
32 stashShortcutLabel={null}
33 onRestore={() => {}}
34 onDelete={() => {}}
35 onClose={() => {}}
36 />,
37 );
38
39 expect(markup).toContain("Nothing stashed yet.");
40 expect(markup).not.toContain("Press");
41 });
42
438 it("shows saved image thumbnails and incomplete image states", () => {
449 const markup = renderToStaticMarkup(
4510 <ComposerStashMenu
......@@ -81,14 +46,6 @@ describe("ComposerStashMenu", () => {
8146 expect(markup).toContain('src="data:image/png;base64,AA=="');
8247 expect(markup).toContain("1 image dropped");
8348 expect(markup).toContain("saving 1 image");
84 expect(markup).not.toContain("absolute top-1/2 right-2");
85 expect(markup).toContain("pointer-events-none");
86 expect(markup).toContain("pointer-coarse:pointer-events-auto");
87 expect(markup).toContain("pointer-coarse:opacity-100");
88 expect(markup).not.toContain("bg-popover!");
89 expect(markup).toContain("[--control-icon-color:currentColor]");
90 expect(markup).toContain("size-3.5 stroke-2");
91 expect(markup).not.toContain("bg-background/90");
9249 });
9350
9451 it("labels mixed file and image stashes without treating images as files", () => {
......@@ -129,7 +86,6 @@ describe("ComposerStashMenu", () => {
12986 );
13087
13188 expect(markup).toContain("(2 attachments)");
132 expect(markup).toContain("size-3.5 text-secondary-label");
13389 expect(markup).not.toContain("(2 files)");
13490 });
13591});
apps/web/src/components/chat/ComposerStashMenu.tsx+88-80
......@@ -1,11 +1,10 @@
1import { FileIcon, XIcon } from "lucide-react";
1import { BookmarkIcon, FileIcon, FileTextIcon } from "lucide-react";
22import { memo, useEffect, useRef, useState } from "react";
33
44import { formatRelativeTimeLabel } from "../../timestampFormat";
55import { cn } from "~/lib/utils";
66import { type PromptStashEntry } from "../../promptStashStore";
7import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
8import { Button } from "../ui/button";
7import { ComposerBanner } from "./ComposerBanner";
98
109const SNIPPET_MAX_CHARS = 90;
1110
......@@ -30,7 +29,7 @@ function stashEntrySnippet(entry: PromptStashEntry): string {
3029}
3130
3231/**
33 * Popover listing the stashed prompts. Keyboard-first: opened by ⌘S on an
32 * Attached banner listing the stashed prompts. Keyboard-first: opened by ⌘S on an
3433 * empty composer, navigated with arrows, restored with Enter, dismissed
3534 * with Escape. The listener runs capture-phase on window so it wins over
3635 * the Lexical editor's handlers while the menu is open.
......@@ -64,13 +63,6 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
6463 return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true);
6564 }, [onClose]);
6665
67 useEffect(() => {
68 if (entries.length === 0) return;
69 if (!entries.some((entry) => entry.id === highlightedId)) {
70 setHighlightedId(entries[0]?.id ?? null);
71 }
72 }, [entries, highlightedId]);
73
7466 useEffect(() => {
7567 const handler = (event: KeyboardEvent) => {
7668 if (event.key === "Escape") {
......@@ -83,11 +75,17 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
8375 if (entries.length === 0) return;
8476 event.preventDefault();
8577 event.stopPropagation();
86 const currentIndex = entries.findIndex((entry) => entry.id === highlightedId);
78 const currentIndex = entries.findIndex((entry) => entry.id === highlightedEntry?.id);
8779 const offset = event.key === "ArrowDown" ? 1 : -1;
8880 const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0;
8981 const nextIndex = (normalizedIndex + offset + entries.length) % entries.length;
9082 setHighlightedId(entries[nextIndex]?.id ?? null);
83 const nextButton =
84 drawerRef.current?.querySelectorAll<HTMLButtonElement>("[data-stash-restore]")[nextIndex];
85 nextButton?.scrollIntoView({ block: "nearest" });
86 if (drawerRef.current?.contains(document.activeElement)) {
87 nextButton?.focus({ preventScroll: true });
88 }
9189 return;
9290 }
9391 if (event.key === "Enter") {
......@@ -111,64 +109,77 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
111109 };
112110 window.addEventListener("keydown", handler, true);
113111 return () => window.removeEventListener("keydown", handler, true);
114 }, [entries, highlightedEntry, highlightedId, onClose, onDelete, onRestore]);
112 }, [entries, highlightedEntry, onClose, onDelete, onRestore]);
115113
116114 return (
117 <Command autoHighlight={false} mode="none">
118 <div
119 ref={drawerRef}
120 className="chat-composer-drawer-surface chat-composer-drawer-attached relative w-full overflow-hidden"
121 data-composer-stash-drawer="true"
115 <ComposerBanner.Root ref={drawerRef} data-composer-stash-drawer="true">
116 <ComposerBanner.Row
117 render={<button type="button" />}
118 aria-label="Close stash"
119 aria-expanded="true"
120 onPointerDown={(event) => event.preventDefault()}
121 onClick={onClose}
122122 >
123 <div className="flex h-7 items-center justify-end px-2 pt-1">
124 <Button
125 variant="ghost-muted"
126 size="icon-micro"
127 aria-label="Close stash"
128 onPointerDown={(event) => event.preventDefault()}
129 onClick={onClose}
130 >
131 <XIcon className="size-3" />
132 </Button>
133 </div>
134 <CommandList className="max-h-64 scroll-pb-6">
135 <CommandGroup>
136 {entries.length === 0 ? (
137 <p className="px-3 py-1.5 text-secondary-label text-xs">
123 <ComposerBanner.Icon>
124 <BookmarkIcon />
125 </ComposerBanner.Icon>
126 <ComposerBanner.Content className="text-muted-foreground">Stash</ComposerBanner.Content>
127 <ComposerBanner.Actions>
128 <ComposerBanner.Count>{entries.length}</ComposerBanner.Count>
129 <ComposerBanner.ToggleIcon expanded />
130 </ComposerBanner.Actions>
131 </ComposerBanner.Row>
132 <ComposerBanner.Scroll>
133 <ComposerBanner.Children render={<ul />} aria-label="Stashed prompts">
134 {entries.length === 0 ? (
135 <ComposerBanner.Row render={<li />}>
136 <ComposerBanner.Icon />
137 <ComposerBanner.Content className="text-muted-foreground">
138138 Nothing stashed yet.
139139 {stashShortcutLabel
140140 ? ` Press ${stashShortcutLabel} with a prompt in the composer to stash it.`
141141 : null}
142 </p>
143 ) : (
144 entries.map((entry) => (
145 <CommandItem
146 key={entry.id}
147 value={entry.id}
148 className={cn(
149 "group/stash relative cursor-pointer select-none gap-3 rounded-lg px-3 py-1.5! hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit",
150 highlightedId === entry.id && "bg-accent! text-accent-foreground!",
151 )}
152 onMouseMove={() => {
153 if (highlightedId !== entry.id) setHighlightedId(entry.id);
154 }}
155 onMouseDown={(event) => {
156 event.preventDefault();
157 }}
158 onClick={() => {
159 onRestore(entry);
160 }}
161 >
162 <span className="min-w-0 flex-1 truncate text-xs">
142 </ComposerBanner.Content>
143 </ComposerBanner.Row>
144 ) : (
145 entries.map((entry) => (
146 <ComposerBanner.Row
147 render={<li />}
148 key={entry.id}
149 data-stash-entry={entry.id}
150 data-highlighted={highlightedEntry?.id === entry.id || undefined}
151 className={cn(
152 "relative rounded-sm",
153 highlightedEntry?.id === entry.id && "bg-accent text-accent-foreground",
154 )}
155 onMouseMove={() => {
156 if (highlightedId !== entry.id) setHighlightedId(entry.id);
157 }}
158 onFocus={() => setHighlightedId(entry.id)}
159 >
160 <ComposerBanner.Icon>
161 <FileTextIcon />
162 </ComposerBanner.Icon>
163 <ComposerBanner.Content>
164 <button
165 type="button"
166 className="min-w-0 flex-1 cursor-pointer truncate text-left text-foreground/80 outline-none before:absolute before:inset-0 before:rounded-sm focus-visible:before:ring-2 focus-visible:before:ring-ring"
167 data-stash-restore={entry.id}
168 aria-label={`Restore stashed prompt: ${stashEntrySnippet(entry)}`}
169 onPointerDown={(event) => event.preventDefault()}
170 onClick={() => onRestore(entry)}
171 >
163172 {stashEntrySnippet(entry)}
164 </span>
173 </button>
174 </ComposerBanner.Content>
175 <ComposerBanner.Actions>
165176 {entry.pendingImageCount ? (
166 <span className="shrink-0 text-[10px] text-secondary-label">
177 <span className="shrink-0 text-muted-foreground">
167178 saving {entry.pendingImageCount} image
168179 {entry.pendingImageCount === 1 ? "" : "s"}…
169180 </span>
170181 ) : missingImageCount(entry) > 0 ? (
171 <span className="shrink-0 text-[10px] text-warning-foreground">
182 <span className="shrink-0 text-warning-foreground">
172183 {missingImageCount(entry)} image
173184 {missingImageCount(entry) === 1 ? "" : "s"} dropped
174185 </span>
......@@ -181,38 +192,35 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
181192 src={attachment.dataUrl}
182193 alt=""
183194 aria-hidden="true"
184 className="size-5 rounded border border-border/70 object-cover"
195 className="size-4 rounded border border-border/70 object-cover"
185196 />
186197 ))}
187198 </span>
188199 ) : null}
189200 {(entry.files?.length ?? 0) > 0 ? (
190 <span className="flex shrink-0 items-center gap-1 text-secondary-label text-xs">
191 <FileIcon className="size-3.5 text-secondary-label" />
201 <span className="flex shrink-0 items-center gap-1 text-muted-foreground">
202 <FileIcon className="size-3" aria-hidden />
192203 {entry.files!.length}
193204 </span>
194205 ) : null}
195 <span className="shrink-0 text-secondary-label text-xs max-sm:hidden">
206 <time
207 dateTime={entry.createdAt}
208 className="shrink-0 text-muted-foreground tabular-nums max-sm:hidden"
209 >
196210 {formatRelativeTimeLabel(entry.createdAt)}
197 </span>
198 <Button
199 variant="ghost"
200 size="icon-xs"
201 className="pointer-events-none shrink-0 [--control-icon-color:currentColor] opacity-0 shadow-none! transition-opacity pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 group-hover/stash:pointer-events-auto group-hover/stash:opacity-100 group-focus-within/stash:pointer-events-auto group-focus-within/stash:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
211 </time>
212 <ComposerBanner.Dismiss
213 className="z-10"
202214 aria-label="Delete stashed prompt"
203 onClick={(event) => {
204 event.stopPropagation();
205 onDelete(entry);
206 }}
207 >
208 <XIcon className="size-3.5 stroke-2" />
209 </Button>
210 </CommandItem>
211 ))
212 )}
213 </CommandGroup>
214 </CommandList>
215 </div>
216 </Command>
215 onPointerDown={(event) => event.preventDefault()}
216 onClick={() => onDelete(entry)}
217 />
218 </ComposerBanner.Actions>
219 </ComposerBanner.Row>
220 ))
221 )}
222 </ComposerBanner.Children>
223 </ComposerBanner.Scroll>
224 </ComposerBanner.Root>
217225 );
218226});
apps/web/src/components/chat/ComposerSurface.tsx created+99
......@@ -0,0 +1,99 @@
1import type { ComponentProps } from "react";
2
3import { cn } from "~/lib/utils";
4
5/** One glass backdrop until a top attachment needs the composer to cover its overlap. */
6function Shell({
7 contextStrip = false,
8 className,
9 ...props
10}: ComponentProps<"div"> & { contextStrip?: boolean }) {
11 return (
12 <div
13 data-slot="composer-shell"
14 data-with-context={contextStrip || undefined}
15 className={cn(
16 "group/composer-surface relative isolate mx-auto w-full max-w-3xl",
17 "[--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]",
18 "dark:[--chat-composer-glass-surface:color-mix(in_srgb,var(--background)_96%,var(--color-white))] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]",
19 "[html[data-theme-id]_&]:[--chat-composer-glass-surface:var(--app-theme-surface-raised)] [html[data-theme-id]_&]:[--chat-composer-outline:var(--app-theme-toolbar-border)]",
20 "dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-highlight:color-mix(in_srgb,var(--app-theme-input)_12%,transparent)] dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-outline:color-mix(in_srgb,var(--app-theme-input)_30%,var(--background))]",
21 "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-highlight:color-mix(in_srgb,#432d48_12%,transparent)] dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-outline:#241e28]",
22 "before:pointer-events-none before:absolute before:inset-0 before:z-0 before:rounded-[22px] before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)",
23 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-(--chat-composer-glass-surface)",
24 "has-data-[composer-banner-surface=attached]:before:hidden",
25 contextStrip && [
26 "[--chat-composer-context-extension:2.25rem] sm:[--chat-composer-context-extension:2rem]",
27 // Keep one continuous backdrop around the fixed-pixel corners and rem-sized strip inset.
28 "supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:rounded-none",
29 "before:[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)]",
30 "not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:bottom-(--chat-composer-context-extension)",
31 ],
32 className,
33 )}
34 {...props}
35 />
36 );
37}
38
39const outlineClasses =
40 "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)]";
41
42// The bottom strip continues the outline, so leave the seam between its corners open.
43const contextSeamClasses =
44 "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%)]";
45
46function Host({ className, ...props }: ComponentProps<"div">) {
47 return (
48 <div
49 data-slot="composer-host"
50 className={cn(
51 "relative z-10 w-full rounded-[22px] shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] after:z-1 dark:shadow-none",
52 outlineClasses,
53 contextSeamClasses,
54 "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none group-has-data-[composer-banner-surface=attached]/composer-surface:after:hidden",
55 className,
56 )}
57 {...props}
58 />
59 );
60}
61
62function Main({ className, ...props }: ComponentProps<"div">) {
63 return (
64 <div
65 data-chat-composer-main-surface="true"
66 className={cn(
67 "group relative z-10 rounded-[22px] p-px transition-colors duration-200",
68 outlineClasses,
69 contextSeamClasses,
70 "after:z-20 after:hidden group-has-data-[composer-banner-surface=attached]/composer-surface:after:block",
71 "group-has-data-[composer-banner-surface=attached]/composer-surface:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] group-has-data-[composer-banner-surface=attached]/composer-surface:backdrop-blur-(--glass-blur) group-has-data-[composer-banner-surface=attached]/composer-surface:backdrop-saturate-(--glass-saturation)",
72 "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none",
73 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:group-has-data-[composer-banner-surface=attached]/composer-surface:bg-(--chat-composer-glass-surface)",
74 "group-has-data-[composer-banner-surface=attached]/composer-surface:**:data-[chat-composer-mobile-collapsed=true]:min-h-[calc(1rem+1px)]",
75 className,
76 )}
77 {...props}
78 />
79 );
80}
81
82function ContextStrip({ className, ...props }: ComponentProps<"div">) {
83 return (
84 <div
85 data-slot="composer-context-strip"
86 className={cn(
87 "group/composer-context relative isolate mx-auto -mt-4 flex w-[calc(100%-2.75rem)] max-w-181 items-center gap-2 overflow-x-clip overflow-y-visible ps-1 pe-2 pt-5 pb-1",
88 "before:absolute before:inset-0 before:-z-1 before:rounded-b-[16px] before:border before:border-(--chat-composer-outline) before:mask-[linear-gradient(to_bottom,transparent_0_1rem,black_1rem)] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)]",
89 "dark:before:border-white/7 dark:before:bg-[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%))] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]",
90 "group-has-data-[composer-banner-surface=attached]/composer-surface:before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] group-has-data-[composer-banner-surface=attached]/composer-surface:before:backdrop-blur-(--glass-blur) group-has-data-[composer-banner-surface=attached]/composer-surface:before:backdrop-saturate-(--glass-saturation)",
91 "not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:backdrop-blur-(--glass-blur) not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:backdrop-saturate-(--glass-saturation)",
92 className,
93 )}
94 {...props}
95 />
96 );
97}
98
99export const ComposerSurface = { Shell, Host, Main, ContextStrip };
apps/web/src/components/chat/ComposerTasksBadge.test.tsx deleted-190
......@@ -1,190 +0,0 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerTasksBadge, ComposerTasksDrawer } from "./ComposerTasksBadge";
5
6const progress = {
7 step: "Attach task progress",
8 completedSteps: 1,
9 totalSteps: 3,
10};
11const steps = [
12 { durationMs: 4_000, step: "Inspect the composer", status: "completed" as const },
13 { step: "Attach task progress", status: "inProgress" as const },
14 { step: "Verify the result", status: "pending" as const },
15];
16
17describe("ComposerTasksBadge", () => {
18 it("renders active progress as an attached composer tab", () => {
19 const markup = renderToStaticMarkup(
20 <ComposerTasksBadge
21 expanded={false}
22 onDismiss={() => undefined}
23 onToggle={() => undefined}
24 progress={progress}
25 steps={steps}
26 />,
27 );
28
29 expect(markup).toContain('data-composer-tasks-badge="true"');
30 expect(markup).toContain('aria-expanded="false"');
31 expect(markup).toContain("chat-composer-shoulder-tab");
32 expect(markup).toContain("chat-composer-tasks-tab");
33 expect(markup).toContain("rounded-t-xl");
34 expect(markup).toContain("border-b-0");
35 expect(markup).toContain("left-5.5");
36 expect(markup).toContain("right-5.5");
37 expect(markup).toContain('data-composer-task-current="true"');
38 expect(markup).toContain("min-w-0 flex-1 truncate");
39 expect(markup).toContain("w-20");
40 expect(markup).toContain("Tasks");
41 expect(markup).toContain("Attach task progress");
42 expect(markup).not.toContain("·");
43 expect(markup).toContain("1/3");
44 expect(markup).toContain("Current task: Attach task progress");
45 expect(markup).toContain("lucide-list-todo");
46 expect(markup).toContain('aria-label="Dismiss tasks for this turn"');
47 expect(markup).toContain("lucide-x");
48 expect(markup).not.toContain("lucide-chevron");
49 expect(markup).toContain("bg-success");
50 expect(markup).toContain("bg-primary");
51 expect(markup).toContain("bg-muted-foreground/25");
52 });
53
54 it("leaves room for the stash tab when both shoulders are present", () => {
55 const markup = renderToStaticMarkup(
56 <ComposerTasksBadge
57 expanded={false}
58 hasTrailingShoulder
59 onDismiss={() => undefined}
60 onToggle={() => undefined}
61 progress={progress}
62 steps={steps}
63 />,
64 );
65
66 expect(markup).toContain("right-30");
67 expect(markup).not.toContain("right-5.5");
68 });
69
70 it("has a compact inline fallback for occupied composer shoulders", () => {
71 const markup = renderToStaticMarkup(
72 <ComposerTasksBadge
73 expanded={false}
74 onDismiss={() => undefined}
75 onToggle={() => undefined}
76 placement="inline"
77 progress={progress}
78 steps={steps}
79 />,
80 );
81
82 expect(markup).toContain("rounded-sm");
83 expect(markup).toContain("1/3");
84 expect(markup).not.toContain("chat-composer-shoulder-tab");
85 expect(markup).not.toContain("rounded-t-xl");
86 });
87
88 it("expands into a read-only attached task list", () => {
89 const markup = renderToStaticMarkup(
90 <ComposerTasksDrawer
91 onCollapse={() => undefined}
92 onDismiss={() => undefined}
93 progress={progress}
94 steps={steps}
95 />,
96 );
97
98 expect(markup).toContain('data-chat-composer-tasks-drawer="true"');
99 expect(markup).toContain('data-chat-composer-collapsed-controls="true"');
100 expect(markup).not.toContain("data-variant");
101 expect(markup).toContain('aria-expanded="true"');
102 expect(markup).toContain('aria-label="Collapse tasks. 1 of 3 complete."');
103 expect(markup).toContain('aria-label="Task list. 1 of 3 complete."');
104 expect(markup).toContain('role="list"');
105 expect(markup).toContain('data-composer-tasks-list="true"');
106 expect(markup).toContain('tabindex="0"');
107 expect(markup).toContain("max-h-[min(24rem,40dvh)]");
108 expect(markup).toContain("overflow-y-auto");
109 expect(markup).toContain("overscroll-contain");
110 expect(markup).toContain("focus-visible:ring-2");
111 expect(markup).toContain("focus-visible:ring-inset");
112 expect(markup).toContain("Inspect the composer");
113 expect(markup).toContain('data-composer-task-duration="true"');
114 expect(markup).toContain("ml-auto w-10");
115 expect(markup).toContain("4.0s");
116 expect(markup).toContain("now");
117 expect(markup).toContain("Attach task progress");
118 expect(markup).toContain("Verify the result");
119 expect(markup).toContain("lucide-list-todo");
120 expect(markup).toContain("lucide-chevron-down");
121 expect(markup).toContain('aria-label="Dismiss tasks for this turn"');
122 expect(markup).not.toContain("bg-success");
123 expect(markup).not.toContain("bg-primary");
124 expect(markup).not.toContain("bg-muted-foreground/25");
125 });
126
127 it("drops the step segments when they would render as a blank gap", () => {
128 const manySteps = Array.from({ length: 24 }, (_, index) => ({
129 step: `Step ${index + 1}`,
130 status: "pending" as const,
131 }));
132 const tab = renderToStaticMarkup(
133 <ComposerTasksBadge
134 expanded={false}
135 onDismiss={() => undefined}
136 onToggle={() => undefined}
137 progress={{ ...progress, totalSteps: manySteps.length }}
138 steps={manySteps}
139 />,
140 );
141 const inline = renderToStaticMarkup(
142 <ComposerTasksBadge
143 expanded={false}
144 onDismiss={() => undefined}
145 onToggle={() => undefined}
146 placement="inline"
147 progress={{ ...progress, totalSteps: manySteps.length }}
148 steps={manySteps}
149 />,
150 );
151
152 expect(tab).not.toContain("w-20");
153 expect(tab).toContain("1/24");
154 expect(inline).not.toContain("w-10");
155 expect(inline).toContain("1/24");
156 });
157
158 it("keeps every long-list task inside the bounded scroll region", () => {
159 const longSteps = Array.from({ length: 20 }, (_, index) => ({
160 step: `Task ${index + 1}`,
161 status: index === 0 ? ("inProgress" as const) : ("pending" as const),
162 }));
163 const markup = renderToStaticMarkup(
164 <ComposerTasksDrawer
165 onCollapse={() => undefined}
166 onDismiss={() => undefined}
167 progress={{ step: "Task 1", completedSteps: 0, totalSteps: longSteps.length }}
168 steps={longSteps}
169 />,
170 );
171
172 const listStart = markup.indexOf('data-composer-tasks-list="true"');
173 expect(listStart).toBeGreaterThan(markup.indexOf('aria-label="Collapse tasks.'));
174 expect(markup.slice(listStart)).toContain("Task 20");
175 });
176
177 it("does not render an empty task count", () => {
178 const markup = renderToStaticMarkup(
179 <ComposerTasksBadge
180 expanded={false}
181 onDismiss={() => undefined}
182 onToggle={() => undefined}
183 progress={{ ...progress, totalSteps: 0 }}
184 steps={steps}
185 />,
186 );
187
188 expect(markup).toBe("");
189 });
190});
apps/web/src/components/chat/ComposerTasksBadge.tsx+131-172
......@@ -1,9 +1,9 @@
1import { ChevronDownIcon, ListTodoIcon, XIcon } from "lucide-react";
2import { memo } from "react";
1import { ListTodoIcon } from "lucide-react";
2import { memo, type ComponentProps } from "react";
33
44import { formatDuration } from "../../session-logic";
55import { cn } from "~/lib/utils";
6import { Button } from "../ui/button";
6import { ComposerBanner } from "./ComposerBanner";
77
88export interface ComposerTasksProgress {
99 readonly step: string;
......@@ -56,205 +56,164 @@ function TaskSegments({
5656 );
5757}
5858
59export const ComposerTasksBadge = memo(function ComposerTasksBadge({
59function TaskSummary({
6060 expanded,
61 hasTrailingShoulder = false,
62 onDismiss,
63 onToggle,
64 placement = "tab",
6561 progress,
6662 steps,
6763}: {
6864 readonly expanded: boolean;
69 readonly hasTrailingShoulder?: boolean;
70 readonly onDismiss: () => void;
71 readonly onToggle: () => void;
72 readonly placement?: "inline" | "tab";
7365 readonly progress: ComposerTasksProgress;
7466 readonly steps: readonly ComposerTaskStep[];
7567}) {
76 if (progress.totalSteps <= 0) return null;
77
78 const allDone = progress.completedSteps >= progress.totalSteps;
79 const label = `Tasks: ${progress.completedSteps} of ${progress.totalSteps} complete. Current task: ${progress.step}`;
80 if (placement === "inline") {
81 return (
82 <span className="inline-flex shrink-0 items-center gap-0.5" data-composer-tasks-badge="true">
83 <Button
84 size="micro"
85 variant="ghost-muted"
86 aria-expanded={expanded}
87 aria-label={label}
88 className="shrink-0 gap-1 px-1.5"
89 onClick={onToggle}
90 onPointerDown={(event) => event.preventDefault()}
91 >
92 <ListTodoIcon aria-hidden className="size-3 shrink-0" />
93 <span>Tasks</span>
94 <TaskSegments steps={steps} />
95 <span
96 className={cn(
97 "font-medium tabular-nums",
98 allDone ? "text-success" : "text-muted-foreground",
99 )}
100 >
101 {progress.completedSteps}/{progress.totalSteps}
102 </span>
103 </Button>
104 <Button
105 size="icon-micro"
106 variant="ghost-muted"
107 aria-label="Dismiss tasks for this turn"
108 className="shrink-0"
109 onClick={onDismiss}
110 onPointerDown={(event) => event.preventDefault()}
111 >
112 <XIcon aria-hidden className="size-2.5" />
113 </Button>
114 </span>
115 );
116 }
117
11868 return (
119 <div
120 className={cn(
121 "chat-composer-shoulder-tab chat-composer-tasks-tab absolute -top-7 left-5.5 z-0 flex h-8 items-center gap-1 rounded-t-xl border border-b-0 px-2 pb-1 text-xs leading-none text-muted-foreground",
122 hasTrailingShoulder ? "right-30" : "right-5.5",
123 allDone && "text-foreground",
124 )}
125 data-composer-tasks-badge="true"
126 >
127 <button
128 type="button"
129 aria-expanded={expanded}
130 aria-label={label}
131 className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 self-stretch text-muted-foreground hover:text-foreground"
132 onClick={onToggle}
133 onPointerDown={(event) => event.preventDefault()}
134 >
135 <ListTodoIcon aria-hidden className="size-3.5 shrink-0" />
136 <span className="shrink-0">Tasks</span>
69 <>
70 <ComposerBanner.Icon>
71 <ListTodoIcon />
72 </ComposerBanner.Icon>
73 <ComposerBanner.Content>
74 <span className="shrink-0 text-muted-foreground">Tasks</span>
13775 <span
13876 className="min-w-0 flex-1 truncate text-left font-medium text-foreground/80"
13977 data-composer-task-current="true"
14078 >
14179 {progress.step}
14280 </span>
143 <span
144 className={cn(
145 "shrink-0 font-medium tabular-nums",
146 allDone ? "text-success" : "text-muted-foreground",
147 )}
81 </ComposerBanner.Content>
82 <ComposerBanner.Actions>
83 <ComposerBanner.Count
84 className={progress.completedSteps >= progress.totalSteps ? "text-success" : undefined}
85 data-composer-task-progress="true"
14886 >
14987 {progress.completedSteps}/{progress.totalSteps}
150 </span>
151 <TaskSegments className="w-20" steps={steps} />
152 </button>
153 <Button
154 size="icon-micro"
155 variant="ghost-muted"
156 aria-label="Dismiss tasks for this turn"
157 className="shrink-0"
158 onClick={onDismiss}
159 onPointerDown={(event) => event.preventDefault()}
160 >
161 <XIcon aria-hidden className="size-3" />
162 </Button>
163 </div>
88 </ComposerBanner.Count>
89 <TaskSegments className="hidden w-20 sm:flex" steps={steps} />
90 <ComposerBanner.ToggleIcon expanded={expanded} />
91 </ComposerBanner.Actions>
92 </>
93 );
94}
95
96export const ComposerTasksBadge = memo(function ComposerTasksBadge({
97 expanded,
98 onToggle,
99 placement = "tab",
100 progress,
101 steps,
102}: {
103 readonly expanded: boolean;
104 readonly onToggle: () => void;
105 readonly placement?: "inline" | "tab";
106 readonly progress: ComposerTasksProgress;
107 readonly steps: readonly ComposerTaskStep[];
108}) {
109 if (progress.totalSteps <= 0) return null;
110
111 const row = (
112 <ComposerBanner.Row
113 render={<button type="button" />}
114 aria-expanded={expanded}
115 aria-label={`${expanded ? "Collapse tasks" : "Tasks"}: ${progress.completedSteps} of ${progress.totalSteps} complete. Current task: ${progress.step}`}
116 data-composer-tasks-badge="true"
117 onClick={onToggle}
118 onPointerDown={(event) => event.preventDefault()}
119 >
120 <TaskSummary expanded={expanded} progress={progress} steps={steps} />
121 </ComposerBanner.Row>
122 );
123 return placement === "inline" ? (
124 row
125 ) : (
126 <ComposerBanner.Root data-composer-shoulder-tab>{row}</ComposerBanner.Root>
164127 );
165128});
166129
167export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
168 onDismiss,
169 onCollapse,
130export const ComposerTasksContent = memo(function ComposerTasksContent({
131 expanded,
132 onToggle,
170133 progress,
171134 steps,
172135}: {
173 readonly onDismiss: () => void;
174 readonly onCollapse: () => void;
136 readonly expanded: boolean;
137 readonly onToggle: () => void;
175138 readonly progress: ComposerTasksProgress;
176139 readonly steps: readonly ComposerTaskStep[];
177140}) {
178141 return (
179142 <div
180 className="chat-composer-top-drawer"
181143 data-chat-composer-collapsed-controls="true"
182 data-chat-composer-tasks-drawer="true"
144 data-chat-composer-tasks-drawer={expanded ? "true" : undefined}
183145 >
184 <div className="flex items-center gap-1 px-3 py-1.5 sm:px-4">
185 <button
186 type="button"
187 aria-expanded="true"
188 aria-label={`Collapse tasks. ${progress.completedSteps} of ${progress.totalSteps} complete.`}
189 className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 self-stretch text-left text-xs text-muted-foreground hover:text-foreground"
190 onClick={onCollapse}
191 onPointerDown={(event) => event.preventDefault()}
192 >
193 <ListTodoIcon aria-hidden className="size-3.5 shrink-0" />
194 <span className="font-medium text-foreground">Tasks</span>
195 <span className="tabular-nums">
196 {progress.completedSteps}/{progress.totalSteps}
197 </span>
198 <ChevronDownIcon aria-hidden className="ml-auto size-3.5 shrink-0" />
199 </button>
200 <Button
201 size="icon-micro"
202 variant="ghost-muted"
203 aria-label="Dismiss tasks for this turn"
204 className="shrink-0"
205 onClick={onDismiss}
206 onPointerDown={(event) => event.preventDefault()}
207 >
208 <XIcon aria-hidden className="size-3" />
209 </Button>
210 </div>
211 <div
212 aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`}
213 className="max-h-[min(24rem,40dvh)] space-y-px overflow-y-auto overscroll-contain px-3 pb-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 sm:px-4"
214 data-composer-tasks-list="true"
215 role="list"
216 tabIndex={0}
217 >
218 {keyedTaskSteps(steps).map(({ key, step }) => (
219 <div key={key} className="flex items-baseline gap-2 text-xs leading-5" role="listitem">
220 <span
221 aria-hidden
222 className={cn(
223 "w-3 shrink-0 text-center font-mono text-[10px]",
224 step.status === "completed"
225 ? "text-success"
226 : step.status === "inProgress"
227 ? "text-primary"
228 : "text-muted-foreground/40",
229 )}
230 >
231 {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}
232 </span>
233 <span
234 className={cn(
235 "min-w-0 flex-1",
236 step.status === "completed"
237 ? "text-muted-foreground/55"
238 : step.status === "inProgress"
239 ? "text-foreground/90"
240 : "text-muted-foreground/70",
241 )}
242 >
243 {step.step}
244 </span>
245 <span
246 className="ml-auto w-10 shrink-0 text-right text-[10px] text-muted-foreground/45 tabular-nums"
247 data-composer-task-duration="true"
248 >
249 {step.durationMs !== undefined
250 ? formatDuration(step.durationMs)
251 : step.status === "inProgress"
252 ? "now"
253 : null}
254 </span>
255 </div>
256 ))}
257 </div>
146 <ComposerTasksBadge
147 expanded={expanded}
148 onToggle={onToggle}
149 placement="inline"
150 progress={progress}
151 steps={steps}
152 />
153 {expanded ? (
154 <ComposerBanner.Scroll data-composer-tasks-scroll="true">
155 <ComposerBanner.Children
156 render={<ul />}
157 aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`}
158 data-composer-tasks-list="true"
159 >
160 {keyedTaskSteps(steps).map(({ key, step }) => (
161 <ComposerBanner.Row key={key} render={<li />}>
162 <ComposerBanner.Icon
163 className={cn(
164 "font-mono text-[10px]",
165 step.status === "completed"
166 ? "text-success"
167 : step.status === "inProgress"
168 ? "text-primary"
169 : "text-muted-foreground/40",
170 )}
171 >
172 {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}
173 </ComposerBanner.Icon>
174 <ComposerBanner.Content
175 className={cn(
176 step.status === "completed"
177 ? "text-muted-foreground/55"
178 : step.status === "inProgress"
179 ? "text-foreground/90"
180 : "text-muted-foreground/70",
181 )}
182 >
183 {step.step}
184 </ComposerBanner.Content>
185 <ComposerBanner.Actions>
186 <span
187 className="w-10 text-right text-[10px] text-muted-foreground/45 tabular-nums"
188 data-composer-task-duration="true"
189 >
190 {step.durationMs !== undefined
191 ? formatDuration(step.durationMs)
192 : step.status === "inProgress"
193 ? "now"
194 : null}
195 </span>
196 </ComposerBanner.Actions>
197 </ComposerBanner.Row>
198 ))}
199 </ComposerBanner.Children>
200 </ComposerBanner.Scroll>
201 ) : null}
258202 </div>
259203 );
260204});
205
206export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
207 onCollapse,
208 ...props
209}: Omit<ComponentProps<typeof ComposerTasksContent>, "expanded" | "onToggle"> & {
210 readonly onCollapse: () => void;
211}) {
212 return (
213 <ComposerBanner.Attachment>
214 <ComposerBanner.Root>
215 <ComposerTasksContent {...props} expanded onToggle={onCollapse} />
216 </ComposerBanner.Root>
217 </ComposerBanner.Attachment>
218 );
219});
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+18-8
......@@ -1377,7 +1377,10 @@ describe("deriveMessagesTimelineRows", () => {
13771377 expect(assistantRow?.showAssistantCopyButton).toBe(false);
13781378 });
13791379
1380 it("models work log overflow expansion as inserted list rows", () => {
1380 it.each([
1381 ["tools", "tool", "Used 3 tools"],
1382 ["tools and status updates", "info", "Used 2 tools and received 1 update"],
1383 ] as const)("expands %s through the same activity group", (_, middleTone, summary) => {
13811384 const timelineEntries = [
13821385 {
13831386 id: "work-entry-1",
......@@ -1398,9 +1401,9 @@ describe("deriveMessagesTimelineRows", () => {
13981401 entry: {
13991402 id: "work-2",
14001403 createdAt: "2026-01-01T00:00:02Z",
1401 label: "edit",
1404 label: "Status updated",
14021405 detail: "Editing MessagesTimeline.tsx",
1403 tone: "tool" as const,
1406 tone: middleTone,
14041407 },
14051408 },
14061409 {
......@@ -1435,8 +1438,7 @@ describe("deriveMessagesTimelineRows", () => {
14351438 groupId: "work-group:work-entry-1",
14361439 hiddenCount: 3,
14371440 expanded: false,
1438 onlyToolEntries: true,
1439 summary: "Used 3 tools",
1441 summary,
14401442 });
14411443 expect(expandedRows.map((row) => row.id)).toEqual([
14421444 "work-toggle:work-entry-1",
......@@ -1488,7 +1490,7 @@ describe("deriveMessagesTimelineRows", () => {
14881490 ["an error-toned entry recovers", ["error", "info", "completed"], false],
14891491 ["the final failure is hidden", ["completed", "failed", "info"], true],
14901492 ["the final failure is visible", ["failed", "info", "failed"], true],
1491 ["the only failure is visible", ["completed", "info", "failed"], false],
1493 ["the only failure is visible", ["completed", "info", "failed"], true],
14921494 ] as const)(
14931495 "uses the final tool call for mixed work groups when %s",
14941496 (_, statuses, hasFailure) => {
......@@ -1524,10 +1526,18 @@ describe("deriveMessagesTimelineRows", () => {
15241526 });
15251527
15261528 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
1527 hiddenCount: 2,
1528 summary: null,
1529 hiddenCount: statuses.some((status) => status === "error") ? 2 : 3,
1530 summary: statuses.some((status) => status === "error")
1531 ? "Received 1 update and used 1 tool"
1532 : "Used 2 tools and received 1 update",
15291533 hasFailure,
15301534 });
1535 if (statuses.some((status) => status === "error")) {
1536 expect(rows[0]).toMatchObject({
1537 kind: "work",
1538 groupedEntries: [{ tone: "error", label: "Command failed" }],
1539 });
1540 }
15311541 },
15321542 );
15331543});
apps/web/src/components/chat/MessagesTimeline.logic.ts+33-109
......@@ -6,13 +6,11 @@ import {
66 workEntryIndicatesToolNeutralStatus,
77 workLogEntryIsToolLike,
88 type TimelineEntry,
9 type TurnPlanEntry,
109 type WorkLogEntry,
1110} from "../../session-logic";
1211import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
1312import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
1413
15export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
1614export const TIMELINE_MINIMAP_ITEM_SPACING = 8;
1715export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
1816export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
......@@ -203,9 +201,8 @@ export type MessagesTimelineRow =
203201 groupId: string;
204202 hiddenCount: number;
205203 expanded: boolean;
206 onlyToolEntries: boolean;
207 summary: string | null;
208 summaryKind: ToolGroupSummaryKind | null;
204 summary: string;
205 summaryKind: ToolGroupSummaryKind;
209206 hasFailure: boolean;
210207 }
211208 | {
......@@ -234,12 +231,6 @@ export type MessagesTimelineRow =
234231 createdAt: string;
235232 proposedPlan: ProposedPlan;
236233 }
237 | {
238 kind: "turn-plan";
239 id: string;
240 createdAt: string;
241 turnPlan: TurnPlanEntry;
242 }
243234 | {
244235 kind: "working";
245236 id: string;
......@@ -275,7 +266,7 @@ export function normalizeCompactToolLabel(value: string): string {
275266 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
276267}
277268
278type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other";
269type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update";
279270type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed";
280271
281272export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {
......@@ -305,7 +296,7 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
305296 }
306297 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
307298 if (entry.itemType === "web_search") return "search";
308 return "other";
299 return workLogEntryIsToolLike(entry) ? "other" : "update";
309300}
310301
311302function toolGroupActionCount(
......@@ -340,6 +331,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
340331 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
341332 case "other":
342333 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
334 case "update":
335 return `Received ${count} ${count === 1 ? "update" : "updates"}`;
343336 }
344337}
345338
......@@ -513,9 +506,6 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
513506 if (entry.kind === "message") {
514507 return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null;
515508 }
516 if (entry.kind === "turn-plan") {
517 return entry.turnPlan.turnId;
518 }
519509 if (entry.kind === "proposed-plan") {
520510 return entry.proposedPlan.turnId;
521511 }
......@@ -712,8 +702,6 @@ export function deriveMessagesTimelineRows(input: {
712702 unsettledTurnId !== null &&
713703 entry.toolLifecycleStatus === "inProgress" &&
714704 entry.turnId === unsettledTurnId;
715 const isVisibleActiveToolEntry = (entry: WorkLogEntry) =>
716 workLogEntryIsToolLike(entry) && workEntryIsVisibleInGroup(entry, true);
717705 const activeEntries = input.isWorking
718706 ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
719707 : [];
......@@ -728,7 +716,7 @@ export function deriveMessagesTimelineRows(input: {
728716 entry.entry.toolLifecycleStatus === "inProgress"
729717 );
730718 }
731 if (entry.kind === "proposed-plan" || entry.kind === "turn-plan") return true;
719 if (entry.kind === "proposed-plan") return true;
732720 return false;
733721 });
734722
......@@ -739,8 +727,7 @@ export function deriveMessagesTimelineRows(input: {
739727 !entryBelongsToActiveTurn(entry, index) ||
740728 entry.kind !== "work" ||
741729 entry.entry.agentSpawn !== undefined ||
742 entry.entry.tone === "error" ||
743 !workLogEntryIsToolLike(entry.entry)
730 entry.entry.tone === "error"
744731 ) {
745732 break;
746733 }
......@@ -748,7 +735,7 @@ export function deriveMessagesTimelineRows(input: {
748735 }
749736 const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
750737 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
751 activeToolEntries.filter((entry) => isVisibleActiveToolEntry(entry.entry)),
738 activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),
752739 (entry) => entry.entry,
753740 );
754741 const activeWorkAnchor = activeToolEntries[0];
......@@ -828,6 +815,17 @@ export function deriveMessagesTimelineRows(input: {
828815 }
829816
830817 if (timelineEntry.kind === "work") {
818 if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") {
819 nextRows.push({
820 kind: "work",
821 id: timelineEntry.id,
822 createdAt: timelineEntry.createdAt,
823 groupedEntries: [timelineEntry.entry],
824 isExpandedToolGroupEntry: false,
825 isLastExpandedToolGroupEntry: false,
826 });
827 continue;
828 }
831829 const groupedEntries = [timelineEntry.entry];
832830 let cursor = index + 1;
833831 while (cursor < input.timelineEntries.length) {
......@@ -835,6 +833,8 @@ export function deriveMessagesTimelineRows(input: {
835833 if (
836834 !nextEntry ||
837835 nextEntry.kind !== "work" ||
836 nextEntry.entry.agentSpawn !== undefined ||
837 nextEntry.entry.tone === "error" ||
838838 activeWorkEntryIds.has(nextEntry.id) ||
839839 collapsedEntryIds.has(nextEntry.id) ||
840840 foldsByAnchorEntryId.has(nextEntry.id)
......@@ -851,14 +851,8 @@ export function deriveMessagesTimelineRows(input: {
851851 (entry) => entry,
852852 );
853853 if (visibleGroupedEntries.length > 0) {
854 const onlyToolEntries = visibleGroupedEntries.every(
855 (entry) =>
856 workLogEntryIsToolLike(entry) &&
857 entry.agentSpawn === undefined &&
858 entry.tone !== "error",
859 );
860854 const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);
861 if (onlyToolEntries && activeInProgressToolEntries.length > 0) {
855 if (activeInProgressToolEntries.length > 0) {
862856 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
863857 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
864858 const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;
......@@ -883,10 +877,11 @@ export function deriveMessagesTimelineRows(input: {
883877 });
884878 }
885879 }
886 } else if (onlyToolEntries) {
880 } else {
887881 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
888882 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
889883 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
884 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
890885 nextRows.push({
891886 kind: "work-toggle",
892887 id: `work-toggle:${timelineEntry.id}`,
......@@ -894,10 +889,15 @@ export function deriveMessagesTimelineRows(input: {
894889 groupId,
895890 hiddenCount: visibleGroupedEntries.length,
896891 expanded,
897 onlyToolEntries: true,
898 summary: summarizeToolGroup(visibleGroupedEntries),
892 summary:
893 visibleGroupedEntries.length === 1 &&
894 !workLogEntryIsToolLike(visibleGroupedEntries[0]!)
895 ? visibleGroupedEntries[0]!.label
896 : summarizeToolGroup(visibleGroupedEntries),
899897 summaryKind,
900 hasFailure: workEntryDisplayIndicatesToolFailure(visibleGroupedEntries.at(-1)!),
898 hasFailure:
899 latestToolEntry !== undefined &&
900 workEntryDisplayIndicatesToolFailure(latestToolEntry),
901901 });
902902 if (expanded) {
903903 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
......@@ -911,64 +911,6 @@ export function deriveMessagesTimelineRows(input: {
911911 });
912912 }
913913 }
914 } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
915 nextRows.push({
916 kind: "work",
917 id: timelineEntry.id,
918 createdAt: timelineEntry.createdAt,
919 groupedEntries: visibleGroupedEntries,
920 isExpandedToolGroupEntry: false,
921 isLastExpandedToolGroupEntry: false,
922 });
923 } else {
924 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
925 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
926 // Agent-spawn CTA rows are always visible: a running fleet must
927 // never hide behind a "+N tool calls" toggle. Selection is by
928 // membership (spawn OR recent-tail), preserving the group's
929 // chronological order in both collapsed and expanded states
930 // (review finding: concatenating two filtered lists moved a
931 // mid-group spawn row above earlier tool rows).
932 const overflowCandidates = visibleGroupedEntries.filter(
933 (entry) => entry.agentSpawn === undefined,
934 );
935 const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);
936 const hiddenIds = new Set(hiddenEntries.map((entry) => entry.id));
937 const visibleEntries = visibleGroupedEntries.filter(
938 (entry) => entry.agentSpawn !== undefined || !hiddenIds.has(entry.id),
939 );
940 const renderedEntries = expanded ? visibleGroupedEntries : visibleEntries;
941
942 for (const workEntry of renderedEntries) {
943 nextRows.push({
944 kind: "work",
945 id: workEntry.id,
946 createdAt: workEntry.createdAt,
947 groupedEntries: [workEntry],
948 isExpandedToolGroupEntry: false,
949 isLastExpandedToolGroupEntry: false,
950 });
951 }
952
953 if (hiddenEntries.length > 0) {
954 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
955
956 nextRows.push({
957 kind: "work-toggle",
958 id: `work-toggle:${timelineEntry.id}`,
959 createdAt: timelineEntry.createdAt,
960 groupId,
961 hiddenCount: hiddenEntries.length,
962 expanded,
963 onlyToolEntries: hiddenEntries.every(workLogEntryIsToolLike),
964 summary: null,
965 summaryKind: null,
966 hasFailure:
967 latestToolEntry !== undefined &&
968 workEntryDisplayIndicatesToolFailure(latestToolEntry) &&
969 hiddenEntries.some(workEntryDisplayIndicatesToolFailure),
970 });
971 }
972914 }
973915 }
974916 index = cursor - 1;
......@@ -985,16 +927,6 @@ export function deriveMessagesTimelineRows(input: {
985927 continue;
986928 }
987929
988 if (timelineEntry.kind === "turn-plan") {
989 nextRows.push({
990 kind: "turn-plan",
991 id: timelineEntry.id,
992 createdAt: timelineEntry.createdAt,
993 turnPlan: timelineEntry.turnPlan,
994 });
995 continue;
996 }
997
998930 const assistantTurnStillInProgress =
999931 timelineEntry.message.role === "assistant" &&
1000932 unsettledTurnId !== null &&
......@@ -1076,13 +1008,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
10761008 case "proposed-plan":
10771009 return a.proposedPlan === (b as typeof a).proposedPlan;
10781010
1079 case "turn-plan": {
1080 const bp = b as typeof a;
1081 // Plans rewrite in place: compare the snapshot's identity fields so an
1082 // unchanged plan keeps its row reference (virtualization stability).
1083 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;
1084 }
1085
10861011 case "work": {
10871012 const bw = b as typeof a;
10881013 return (
......@@ -1110,7 +1035,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
11101035 a.groupId === bw.groupId &&
11111036 a.hiddenCount === bw.hiddenCount &&
11121037 a.expanded === bw.expanded &&
1113 a.onlyToolEntries === bw.onlyToolEntries &&
11141038 a.summary === bw.summary &&
11151039 a.summaryKind === bw.summaryKind &&
11161040 a.hasFailure === bw.hasFailure
apps/web/src/components/chat/MessagesTimeline.test.tsx+3-20
......@@ -1053,7 +1053,6 @@ describe("MessagesTimeline", () => {
10531053 );
10541054
10551055 expect(markup).toContain("Context compacted");
1056 expect(markup).toContain("Work Log");
10571056 });
10581057
10591058 it("summarizes changed files in one line", () => {
......@@ -1210,7 +1209,7 @@ describe("MessagesTimeline", () => {
12101209 />,
12111210 );
12121211
1213 expect(markup).toContain("+2 previous log entries");
1212 expect(markup).toContain("Ran 2 commands and received 1 update");
12141213 expect(markup).not.toContain('aria-label="Hidden work includes a failure"');
12151214 });
12161215
......@@ -1348,21 +1347,6 @@ describe("MessagesTimeline", () => {
13481347 expect(markup).toContain("tool call failed");
13491348 });
13501349
1351 it("aligns the iconless Thinking row with the working timer", () => {
1352 const markup = renderToStaticMarkup(
1353 <MessagesTimeline
1354 {...buildProps()}
1355 isWorking
1356 activeTurnStartedAt={MESSAGE_CREATED_AT}
1357 timelineEntries={[]}
1358 />,
1359 );
1360
1361 expect(markup).toContain("Working for");
1362 expect(markup).toContain("Thinking");
1363 expect(markup).toContain("gap-1.5 py-0.5 px-1");
1364 });
1365
13661350 it("renders review comment contexts as structured cards instead of raw tags", () => {
13671351 const markup = renderToStaticMarkup(
13681352 <MessagesTimeline
......@@ -1440,7 +1424,7 @@ describe("MessagesTimeline", () => {
14401424 expect(markup).not.toContain('data-testid="file-diff"');
14411425 });
14421426
1443 it("renders a muted failure marker for failed tool lifecycle entries", () => {
1427 it("keeps failed lifecycle entries discoverable in mixed activity summaries", () => {
14441428 const markup = renderToStaticMarkup(
14451429 <MessagesTimeline
14461430 {...buildProps()}
......@@ -1473,8 +1457,7 @@ describe("MessagesTimeline", () => {
14731457 />,
14741458 );
14751459
1476 expect(markup).toContain("lucide-circle-alert");
1477 expect(markup).toContain('aria-label="Tool call failed"');
1460 expect(markup).toContain('aria-label="Received 1 update and used 1 tool, tool call failed"');
14781461 // Ordinary tool failures render muted, not red.
14791462 expect(markup).not.toContain("text-destructive");
14801463 });
apps/web/src/components/chat/MessagesTimeline.tsx+23-171
......@@ -963,8 +963,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
963963 const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;
964964 const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
965965 const isExpandedToolGroupHeader =
966 (row.kind === "work-toggle" && row.summary !== null && row.onlyToolEntries && row.expanded) ||
967 (row.kind === "work-live" && row.expanded);
966 (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);
968967
969968 return (
970969 <div
......@@ -984,8 +983,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
984983 !row.showAssistantMeta) ||
985984 row.kind === "work" ||
986985 row.kind === "work-live" ||
987 row.kind === "work-toggle" ||
988 row.kind === "turn-plan"
986 row.kind === "work-toggle"
989987 ? "pb-2"
990988 : "pb-4",
991989 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
......@@ -1009,7 +1007,6 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
10091007 <AssistantTimelineRow row={row} />
10101008 ) : null}
10111009 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
1012 {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null}
10131010 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
10141011 </div>
10151012 );
......@@ -1325,121 +1322,25 @@ function ProposedPlanTimelineRow({
13251322 );
13261323}
13271324
1328/**
1329 * Inline folded plan chip: one row per turn that produced plan/todo steps.
1330 * Collapsed by default — a segment bar plus the in-progress step label —
1331 * and expands in place to the full step list. Replaces the old plan sidebar.
1332 */
1333const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
1334 row,
1335}: {
1336 row: Extract<TimelineRow, { kind: "turn-plan" }>;
1337}) {
1338 const [expanded, setExpanded] = useState(false);
1339 const { steps } = row.turnPlan.plan;
1340 const completedCount = steps.filter((step) => step.status === "completed").length;
1341 const allDone = completedCount === steps.length;
1342 // Label priority: the in-progress step, else the next pending step (plan
1343 // just created), else the last step (plan finished, rendered muted).
1344 const label =
1345 steps.find((step) => step.status === "inProgress")?.step ??
1346 steps.find((step) => step.status === "pending")?.step ??
1347 steps.at(-1)?.step ??
1348 "Plan";
1349 const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon;
1350
1351 return (
1352 <div className="min-w-0 px-1 py-0.5">
1353 <button
1354 type="button"
1355 className="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1356 aria-expanded={expanded}
1357 onClick={() => setExpanded((value) => !value)}
1358 >
1359 <Chevron className="size-3.5 shrink-0 text-muted-foreground/65" />
1360 {steps.length > 1 ? (
1361 <span aria-hidden className="flex shrink-0 items-center gap-0.5">
1362 {steps.map((step) => (
1363 <span
1364 key={step.step}
1365 className={cn(
1366 "h-[3px] w-2.5 rounded-full",
1367 step.status === "completed"
1368 ? "bg-success"
1369 : step.status === "inProgress"
1370 ? "bg-primary"
1371 : "bg-muted-foreground/25",
1372 )}
1373 />
1374 ))}
1375 </span>
1376 ) : null}
1377 <span
1378 className={cn(
1379 "min-w-0 truncate",
1380 allDone ? "text-muted-foreground/65" : "font-medium text-foreground/85",
1381 )}
1382 >
1383 {label}
1384 </span>
1385 {steps.length > 1 ? (
1386 <span className="shrink-0 text-muted-foreground/50 tabular-nums">
1387 {completedCount}/{steps.length}
1388 </span>
1389 ) : null}
1390 </button>
1391 {expanded ? (
1392 <div className="mt-0.5 space-y-px pl-6">
1393 {steps.map((step) => (
1394 <div key={step.step} className="flex items-baseline gap-2 text-[12px] leading-5">
1395 <span
1396 className={cn(
1397 "w-3 shrink-0 text-center font-mono text-[10px]",
1398 step.status === "completed"
1399 ? "text-success"
1400 : step.status === "inProgress"
1401 ? "text-primary"
1402 : "text-muted-foreground/40",
1403 )}
1404 aria-hidden
1405 >
1406 {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}
1407 </span>
1408 <span
1409 className={cn(
1410 "min-w-0",
1411 step.status === "completed"
1412 ? "text-muted-foreground/55"
1413 : step.status === "inProgress"
1414 ? "text-foreground/90"
1415 : "text-muted-foreground/70",
1416 )}
1417 >
1418 {step.step}
1419 </span>
1420 </div>
1421 ))}
1422 </div>
1423 ) : null}
1424 </div>
1425 );
1426});
1427
14281325function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
14291326 const { workingStepLabel } = use(TimelineRowActivityCtx);
14301327 return (
14311328 <div>
14321329 <div className="border-b border-border/60 pb-2 pt-1">
1433 <div className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
1434 {row.createdAt ? (
1435 <>
1436 Working for <WorkingTimer createdAt={row.createdAt} />
1437 </>
1438 ) : (
1439 "Working..."
1440 )}
1330 <div className="flex min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
1331 <span className="shrink-0 whitespace-nowrap">
1332 {row.createdAt ? (
1333 <>
1334 Working for <WorkingTimer createdAt={row.createdAt} />
1335 </>
1336 ) : (
1337 "Working..."
1338 )}
1339 </span>
14411340 {workingStepLabel ? (
1442 <span className="ml-2 text-muted-foreground/55">· {workingStepLabel}</span>
1341 <span className="ml-2 min-w-0 truncate text-muted-foreground/55">
1342 · {workingStepLabel}
1343 </span>
14431344 ) : null}
14441345 </div>
14451346 </div>
......@@ -1499,12 +1400,6 @@ const WorkGroupSection = memo(function WorkGroupSection({
14991400 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),
15001401 [groupedEntries, isExpandedToolGroupEntry],
15011402 );
1502 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));
1503 const groupLabel = onlyToolEntries
1504 ? nonEmptyEntries.length === 1
1505 ? "1 tool call"
1506 : `${nonEmptyEntries.length} tool calls`
1507 : "Work Log";
15081403 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
15091404
15101405 if (nonEmptyEntries.length === 0) return null;
......@@ -1512,11 +1407,8 @@ const WorkGroupSection = memo(function WorkGroupSection({
15121407 return (
15131408 <GroupContainer
15141409 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
1515 aria-label={isExpandedToolGroupEntry ? undefined : groupLabel}
1410 aria-label={isExpandedToolGroupEntry ? undefined : "Activity"}
15161411 >
1517 {!onlyToolEntries && (
1518 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
1519 )}
15201412 <div className="space-y-px">
15211413 {nonEmptyEntries.map((workEntry) => (
15221414 <SimpleWorkEntryRow
......@@ -1649,8 +1541,8 @@ function toolGroupSummaryIconName(
16491541 return "bot";
16501542 case "tone-tool":
16511543 return "zap";
1544 case "update":
16521545 case "mixed":
1653 case null:
16541546 return "hammer";
16551547 }
16561548}
......@@ -1661,61 +1553,21 @@ function WorkGroupToggleTimelineRow({
16611553 row: Extract<TimelineRow, { kind: "work-toggle" }>;
16621554}) {
16631555 const ctx = use(TimelineRowCtx);
1664 if (row.onlyToolEntries && row.summary) {
1665 return (
1666 <button
1667 type="button"
1668 className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1669 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
1670 aria-expanded={row.expanded}
1671 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1672 >
1673 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1674 <WorkEntryIconSvg
1675 name={toolGroupSummaryIconName(row.summaryKind)}
1676 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1677 />
1678 </span>
1679 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
1680 </button>
1681 );
1682 }
1683 const labelNoun = row.onlyToolEntries
1684 ? row.hiddenCount === 1
1685 ? "tool call"
1686 : "tool calls"
1687 : row.hiddenCount === 1
1688 ? "log entry"
1689 : "log entries";
16901556 return (
16911557 <button
16921558 type="button"
1693 className="flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1694 aria-label={
1695 row.hasFailure && !row.expanded
1696 ? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
1697 : undefined
1698 }
1559 className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1560 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
16991561 aria-expanded={row.expanded}
17001562 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
17011563 >
17021564 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1703 <ChevronDownIcon
1704 className={cn(
1705 "size-4 shrink-0 opacity-70 transition-transform duration-200",
1706 row.expanded && "rotate-180",
1707 )}
1565 <WorkEntryIconSvg
1566 name={toolGroupSummaryIconName(row.summaryKind)}
1567 className="size-4 shrink-0 stroke-[1.8] opacity-70"
17081568 />
17091569 </span>
1710 {row.expanded ? (
1711 <span className="font-medium text-foreground">
1712 Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"}
1713 </span>
1714 ) : (
1715 <span className="font-medium text-foreground">
1716 +{row.hiddenCount} previous {labelNoun}
1717 </span>
1718 )}
1570 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
17191571 </button>
17201572 );
17211573}
apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx deleted-22
......@@ -1,22 +0,0 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ThreadSyncStatusPill } from "./ThreadSyncStatusPill";
5
6describe("ThreadSyncStatusPill", () => {
7 it.each([
8 ["loading", "Loading messages..."],
9 ["syncing", "Syncing messages..."],
10 ] as const)("renders the %s message sync phase", (phase, label) => {
11 const markup = renderToStaticMarkup(<ThreadSyncStatusPill phase={phase} />);
12
13 expect(markup).toContain('role="status"');
14 expect(markup).toContain('data-thread-sync-drawer="true"');
15 expect(markup).toContain("chat-composer-drawer-surface");
16 expect(markup).toContain("chat-composer-drawer-attached");
17 expect(markup).toContain("chat-composer-drawer-slot");
18 expect(markup).toContain("pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)]");
19 expect(markup).toContain(label);
20 expect(markup).not.toContain("animate-");
21 });
22});
apps/web/src/components/chat/ThreadSyncStatusPill.tsx deleted-19
......@@ -1,19 +0,0 @@
1import { LoaderCircleIcon } from "lucide-react";
2
3import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
4
5export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhase }) {
6 const label = threadSyncLabel(phase);
7
8 return (
9 <div
10 aria-label={label}
11 className="chat-composer-drawer-surface chat-composer-drawer-attached chat-composer-drawer-slot pointer-events-none flex items-center gap-2 px-3 pt-2 pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)] text-foreground text-xs font-medium sm:px-4"
12 data-thread-sync-drawer="true"
13 role="status"
14 >
15 <LoaderCircleIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
16 <span className="truncate">{label}</span>
17 </div>
18 );
19}
apps/web/src/index.css-524
......@@ -759,472 +759,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
759759 outline: none;
760760 }
761761
762 .chat-composer-shoulder-tab {
763 border-color: var(
764 --chat-composer-outline,
765 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
766 );
767 background: color-mix(
768 in srgb,
769 var(--chat-composer-glass-surface, var(--card)) var(--glass-opacity),
770 transparent
771 );
772 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
773 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
774 box-shadow: 0 -8px 24px -18px rgb(0 0 0 / 45%);
775
776 @variant dark {
777 box-shadow: 0 -10px 28px -18px rgb(0 0 0 / 80%);
778 }
779
780 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
781 background: var(--chat-composer-glass-surface, var(--card));
782 }
783 }
784
785 .chat-composer-banner-stack-cap {
786 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
787 --chat-composer-attached-outline: var(
788 --chat-composer-outline,
789 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
790 );
791 background: color-mix(
792 in srgb,
793 var(--chat-composer-attached-surface) var(--glass-opacity),
794 transparent
795 );
796 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
797 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
798
799 @variant dark {
800 --chat-composer-attached-surface: var(
801 --chat-composer-glass-surface,
802 color-mix(in srgb, var(--background) 96%, var(--color-white))
803 );
804 --chat-composer-attached-outline: var(
805 --chat-composer-outline,
806 color-mix(in srgb, var(--color-white) 5%, transparent)
807 );
808 }
809
810 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
811 background: var(--chat-composer-attached-surface);
812 }
813 }
814
815 /* Mirror the run-context strip above the composer. These surfaces deliberately
816 own no card fill or border themselves: a masked pseudo-element draws the
817 same inset glass, 16px outer corners, quiet outline, and open attachment
818 seam as the strip below. */
819 .chat-composer-drawer-surface,
820 .chat-composer-top-drawer {
821 --chat-composer-attachment-overlap: calc(1rem + 1px);
822 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
823 --chat-composer-attached-outline: var(
824 --chat-composer-outline,
825 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
826 );
827 --chat-composer-attached-tint: transparent;
828 position: relative;
829 isolation: isolate;
830 border: 0 !important;
831 background: transparent !important;
832 box-shadow: none;
833
834 @variant dark {
835 --chat-composer-attached-surface: var(
836 --chat-composer-glass-surface,
837 color-mix(in srgb, var(--background) 96%, var(--color-white))
838 );
839 --chat-composer-attached-outline: var(
840 --chat-composer-outline,
841 color-mix(in srgb, var(--color-white) 5%, transparent)
842 );
843
844 &::before {
845 background:
846 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
847 linear-gradient(
848 to top,
849 transparent 0 var(--chat-composer-attachment-overlap),
850 rgb(0 0 0 / 18%) var(--chat-composer-attachment-overlap),
851 transparent calc(var(--chat-composer-attachment-overlap) + 10px)
852 ),
853 color-mix(
854 in srgb,
855 var(--chat-composer-attached-surface) var(--glass-opacity),
856 transparent
857 );
858 box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
859 }
860 }
861 }
862
863 .chat-composer-drawer-attached,
864 .chat-composer-top-drawer {
865 padding-bottom: var(--chat-composer-attachment-overlap);
866 }
867
868 [data-chat-composer-form="true"],
869 .chat-composer-drawer-slot,
870 .chat-composer-top-drawer {
871 --chat-composer-drawer-inset: 1.375rem;
872 }
873
874 .chat-composer-drawer-slot,
875 .chat-composer-top-drawer {
876 --chat-composer-attachment-overlap: calc(1rem + 1px);
877 width: calc(100% - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
878 max-width: calc(48rem - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
879 margin-inline: auto;
880 margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap));
881 }
882
883 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)::before {
884 pointer-events: none;
885 position: absolute;
886 z-index: -1;
887 inset: 0;
888 border: 1px solid var(--chat-composer-attached-outline);
889 border-radius: 16px 16px 0 0;
890 background:
891 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
892 color-mix(in srgb, var(--chat-composer-attached-surface) var(--glass-opacity), transparent);
893 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
894 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
895 -webkit-mask-image: linear-gradient(
896 to top,
897 transparent 0 var(--chat-composer-attachment-overlap),
898 black var(--chat-composer-attachment-overlap)
899 );
900 mask-image: linear-gradient(
901 to top,
902 transparent 0 var(--chat-composer-attachment-overlap),
903 black var(--chat-composer-attachment-overlap)
904 );
905 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
906 content: "";
907
908 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
909 background:
910 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
911 var(--chat-composer-attached-surface) !important;
912 }
913 }
914
915 .chat-composer-drawer-slot + .chat-composer-drawer-slot::before,
916 .chat-composer-drawer-slot + :has(.chat-composer-top-drawer) .chat-composer-top-drawer::before {
917 border-top: 0;
918 border-radius: 0;
919 }
920
921 .chat-composer-top-drawer {
922 z-index: 0;
923 }
924
925 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="error"] {
926 --chat-composer-attached-outline: color-mix(in srgb, var(--error) 32%, transparent);
927 --chat-composer-attached-tint: color-mix(in srgb, var(--error) 8%, transparent);
928 }
929
930 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="info"] {
931 --chat-composer-attached-outline: color-mix(in srgb, var(--info) 32%, transparent);
932 --chat-composer-attached-tint: color-mix(in srgb, var(--info) 4%, transparent);
933 }
934
935 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="success"] {
936 --chat-composer-attached-outline: color-mix(in srgb, var(--success) 32%, transparent);
937 --chat-composer-attached-tint: color-mix(in srgb, var(--success) 4%, transparent);
938 }
939
940 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="warning"] {
941 --chat-composer-attached-outline: color-mix(in srgb, var(--warning) 28%, transparent);
942 --chat-composer-attached-tint: color-mix(in srgb, var(--warning) 8%, transparent);
943 }
944
945 .chat-composer-glass-shell {
946 --chat-composer-attachment-overlap: calc(1rem + 1px);
947 --chat-composer-glass-surface: var(--card);
948 --chat-composer-outline: rgb(0 0 0 / 8%);
949
950 isolation: isolate;
951
952 @variant dark {
953 --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white));
954 --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent);
955 --chat-composer-highlight: rgb(255 255 255 / 3%);
956 }
957 }
958
959 .chat-composer-glass-shell::before {
960 pointer-events: none;
961 position: absolute;
962 z-index: 0;
963 inset: 0;
964 border-radius: 22px;
965 background: color-mix(
966 in srgb,
967 var(--chat-composer-glass-surface) var(--glass-opacity),
968 transparent
969 );
970 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
971 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
972 content: "";
973 }
974
975 .chat-composer-glass-shell-with-context {
976 --chat-composer-context-extension: 2.25rem;
977 }
978
979 .chat-composer-glass-shell-with-context::before {
980 border-radius: 0;
981 /*
982 * One continuous glass layer: a 22px composer joined to a 16px strip. The
983 * strip is inset 1.375rem per side, so the step-in positions and their
984 * curve controls stay in rem to keep tracking it at non-default interface
985 * font sizes; the composer's 22px top radius and the strip's 16px bottom
986 * radius are px by design.
987 */
988 clip-path: shape(
989 from 0 22px,
990 curve to 22px 0 with 0 9.85px / 9.85px 0,
991 line to calc(100% - 22px) 0,
992 curve to 100% 22px with calc(100% - 9.85px) 0 / 100% 9.85px,
993 line to 100% calc(100% - var(--chat-composer-context-extension) - 1.375rem),
994 curve to calc(100% - 1.375rem) calc(100% - var(--chat-composer-context-extension)) with 100%
995 calc(100% - var(--chat-composer-context-extension) - 0.6156rem) / calc(100% - 0.6156rem)
996 calc(100% - var(--chat-composer-context-extension)),
997 line to calc(100% - 1.375rem) calc(100% - 16px),
998 curve to calc(100% - 1.375rem - 16px) 100% with calc(100% - 1.375rem) calc(100% - 7.16px) /
999 calc(100% - 1.375rem - 7.16px) 100%,
1000 line to calc(1.375rem + 16px) 100%,
1001 curve to 1.375rem calc(100% - 16px) with calc(1.375rem + 7.16px) 100% / 1.375rem
1002 calc(100% - 7.16px),
1003 line to 1.375rem calc(100% - var(--chat-composer-context-extension)),
1004 curve to 0 calc(100% - var(--chat-composer-context-extension) - 1.375rem) with 0.6156rem
1005 calc(100% - var(--chat-composer-context-extension)) / 0
1006 calc(100% - var(--chat-composer-context-extension) - 0.6156rem),
1007 line to 0 22px,
1008 close
1009 );
1010 }
1011
1012 @media (min-width: 40rem) {
1013 .chat-composer-glass-shell-with-context {
1014 /* The xs toolbar controls shrink by 4px at Tailwind's sm breakpoint. */
1015 --chat-composer-context-extension: 2rem;
1016 }
1017 }
1018
1019 .chat-composer-glass-host {
1020 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1021
1022 @variant dark {
1023 box-shadow: none;
1024
1025 &::after {
1026 box-shadow: inset 0 1px var(--chat-composer-highlight);
1027 }
1028 }
1029 }
1030
1031 .chat-composer-glass-host::after {
1032 pointer-events: none;
1033 position: absolute;
1034 z-index: 1;
1035 inset: 0;
1036 border: 1px solid var(--chat-composer-outline);
1037 border-radius: inherit;
1038 content: "";
1039 }
1040
1041 /* A top attachment cannot share the shell's single clipped backdrop: it
1042 needs the composer surface and outline to paint in front of its overlap.
1043 Split the glass into exact surfaces while a drawer or tab is present; the
1044 default composer keeps the cheaper continuous layer. */
1045 .chat-composer-glass-shell:is(
1046 .chat-composer-glass-shell-attached,
1047 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1048 )::before,
1049 .chat-composer-glass-shell:is(
1050 .chat-composer-glass-shell-attached,
1051 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1052 )
1053 .chat-composer-glass-host::after {
1054 display: none;
1055 }
1056
1057 .chat-composer-glass-shell:is(
1058 .chat-composer-glass-shell-attached,
1059 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1060 )
1061 .chat-composer-glass-host {
1062 box-shadow: none;
1063 }
1064
1065 .chat-composer-glass-shell:is(
1066 .chat-composer-glass-shell-attached,
1067 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1068 )
1069 .chat-composer-glass-host
1070 [data-chat-composer-main-surface="true"] {
1071 background: color-mix(
1072 in srgb,
1073 var(--chat-composer-glass-surface) var(--glass-opacity),
1074 transparent
1075 );
1076 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1077 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1078 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1079
1080 &::after {
1081 pointer-events: none;
1082 position: absolute;
1083 z-index: 20;
1084 inset: 0;
1085 border: 1px solid var(--chat-composer-outline);
1086 border-radius: inherit;
1087 content: "";
1088 }
1089
1090 @variant dark {
1091 box-shadow: none;
1092
1093 &::after {
1094 box-shadow: inset 0 1px var(--chat-composer-highlight);
1095 }
1096 }
1097
1098 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
1099 background: var(--chat-composer-glass-surface);
1100 }
1101 }
1102
1103 .chat-composer-glass-shell:is(
1104 .chat-composer-glass-shell-attached,
1105 :has(.chat-composer-top-drawer)
1106 )
1107 .chat-composer-glass-host
1108 [data-chat-composer-mobile-collapsed="true"] {
1109 min-height: var(--chat-composer-attachment-overlap);
1110 }
1111
1112 .chat-composer-glass-shell-with-context .chat-composer-glass-host::after,
1113 .chat-composer-glass-shell-with-context:is(
1114 .chat-composer-glass-shell-attached,
1115 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1116 )
1117 [data-chat-composer-main-surface="true"]::after {
1118 /* Keep the attachment seam open; the strip continues the outer outline below. */
1119 clip-path: polygon(
1120 0 0,
1121 100% 0,
1122 100% 100%,
1123 calc(100% - 22px) 100%,
1124 calc(100% - 22px) calc(100% - 2px),
1125 22px calc(100% - 2px),
1126 22px 100%,
1127 0 100%
1128 );
1129 }
1130
1131 .chat-composer-context-strip {
1132 position: relative;
1133 isolation: isolate;
1134
1135 @variant dark {
1136 &::before {
1137 border-color: rgb(255 255 255 / 7%);
1138 background:
1139 linear-gradient(
1140 to bottom,
1141 transparent 0 1rem,
1142 rgb(0 0 0 / 18%) 1rem,
1143 transparent calc(1rem + 10px)
1144 ),
1145 rgb(255 255 255 / 2%);
1146 box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
1147 }
1148 }
1149 }
1150
1151 .chat-composer-context-strip::before {
1152 position: absolute;
1153 z-index: -1;
1154 inset: 0;
1155 border: 1px solid var(--chat-composer-outline);
1156 border-radius: 0 0 16px 16px;
1157 /* Start the strip outline exactly where the composer's 22px curve reaches its tangent. */
1158 -webkit-mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem);
1159 mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem);
1160 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1161 content: "";
1162 }
1163
1164 .chat-composer-glass-shell:is(
1165 .chat-composer-glass-shell-attached,
1166 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1167 )
1168 .chat-composer-context-strip {
1169 &::before {
1170 background: color-mix(
1171 in srgb,
1172 var(--chat-composer-glass-surface) var(--glass-opacity),
1173 transparent
1174 );
1175 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1176 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1177 }
1178
1179 @variant dark {
1180 &::before {
1181 background:
1182 linear-gradient(
1183 to bottom,
1184 transparent 0 1rem,
1185 rgb(0 0 0 / 18%) 1rem,
1186 transparent calc(1rem + 10px)
1187 ),
1188 linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)),
1189 color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent);
1190 }
1191 }
1192 }
1193
1194 @supports not (clip-path: shape(from 0 0, line to 1px 1px)) {
1195 .chat-composer-glass-shell-with-context::before {
1196 inset-block-end: var(--chat-composer-context-extension);
1197 border-radius: 22px;
1198 clip-path: none;
1199 }
1200
1201 .chat-composer-context-strip::before {
1202 background: color-mix(
1203 in srgb,
1204 var(--chat-composer-glass-surface) var(--glass-opacity),
1205 transparent
1206 );
1207 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1208 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1209 }
1210
1211 .chat-composer-context-strip {
1212 @variant dark {
1213 &::before {
1214 background:
1215 linear-gradient(
1216 to bottom,
1217 transparent 0 1rem,
1218 rgb(0 0 0 / 18%) 1rem,
1219 transparent calc(1rem + 10px)
1220 ),
1221 linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)),
1222 color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent);
1223 }
1224 }
1225 }
1226 }
1227
1228762 .settings-slider {
1229763 --settings-slider-progress: 0%;
1230764 --settings-slider-fill-offset: 0.5rem;
......@@ -1339,12 +873,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
1339873 all: revert;
1340874 }
1341875 }
1342
1343 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
1344 .chat-composer-glass-shell::before {
1345 background: var(--chat-composer-glass-surface);
1346 }
1347 }
1348876}
1349877
1350878/* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering.
......@@ -1611,58 +1139,6 @@ html[data-theme-id]:not([data-theme-id=""]) {
16111139 --terminal-selection-background: var(--app-theme-terminal-selection-background);
16121140}
16131141
1614/* T3 Chat's composer is a translucent lift over --chat-background. Route its
1615 measured flattened color through the raised-surface role instead of deriving
1616 another tint from the canvas, which made the dark composer too red. */
1617html[data-theme-id] .chat-composer-glass-shell {
1618 --chat-composer-glass-surface: var(--app-theme-surface-raised);
1619 --chat-composer-outline: var(--app-theme-toolbar-border);
1620
1621 @variant dark {
1622 --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background));
1623 --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent);
1624 }
1625}
1626
1627html[data-theme-id]
1628 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer) {
1629 --chat-composer-attached-surface: var(--app-theme-surface-raised);
1630}
1631
1632html[data-theme-id]
1633 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
1634 :not([data-variant]),
1635 [data-variant="default"]
1636 ) {
1637 --chat-composer-attached-outline: var(--chat-composer-outline, var(--app-theme-toolbar-border));
1638
1639 @variant dark {
1640 --chat-composer-attached-outline: var(
1641 --chat-composer-outline,
1642 color-mix(in srgb, var(--app-theme-input) 30%, var(--background))
1643 );
1644 }
1645}
1646
1647html[data-theme-id="t3-chat"] .chat-composer-glass-shell {
1648 /* T3 Chat's visible composer edge is a dark plum, not the stock translucent
1649 white outline. Its highlight is derived from --chat-input-gradient. */
1650 @variant dark {
1651 --chat-composer-outline: #241e28;
1652 --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent);
1653 }
1654}
1655
1656html[data-theme-id="t3-chat"]
1657 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
1658 :not([data-variant]),
1659 [data-variant="default"]
1660 ) {
1661 @variant dark {
1662 --chat-composer-attached-outline: #241e28;
1663 }
1664}
1665
16661142/* Theme-token dependency probes are restored synchronously, before paint. Keep
16671143 transitions from observing the temporary sentinel color in between. */
16681144html[data-theme-token-probe],
apps/web/src/session-logic.test.ts+31-72
......@@ -11,7 +11,6 @@ import { describe, expect, it } from "vite-plus/test";
1111import {
1212 deriveActiveWorkStartedAt,
1313 deriveActivePlanState,
14 deriveTurnPlans,
1514 derivePendingApprovals,
1615 derivePendingUserInputs,
1716 deriveTimelineEntries,
......@@ -495,73 +494,6 @@ describe("deriveActivePlanState", () => {
495494 { durationMs: 3_000, step: "Check", status: "completed" },
496495 ]);
497496 });
498});
499
500describe("deriveTurnPlans", () => {
501 it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => {
502 const activities: OrchestrationThreadActivity[] = [
503 makeActivity({
504 id: "plan-1a",
505 createdAt: "2026-02-23T00:00:01.000Z",
506 kind: "turn.plan.updated",
507 summary: "Plan updated",
508 tone: "info",
509 turnId: "turn-1",
510 payload: {
511 plan: [{ step: "Inspect code", status: "inProgress" }],
512 },
513 }),
514 makeActivity({
515 id: "plan-1b",
516 createdAt: "2026-02-23T00:00:05.000Z",
517 kind: "turn.plan.updated",
518 summary: "Plan updated",
519 tone: "info",
520 turnId: "turn-1",
521 payload: {
522 plan: [{ step: "Inspect code", status: "completed" }],
523 },
524 }),
525 makeActivity({
526 id: "plan-2a",
527 createdAt: "2026-02-23T00:01:00.000Z",
528 kind: "turn.plan.updated",
529 summary: "Plan updated",
530 tone: "info",
531 turnId: "turn-2",
532 payload: {
533 plan: [{ step: "Ship it", status: "pending" }],
534 },
535 }),
536 ];
537
538 const turnPlans = deriveTurnPlans(activities);
539 expect(turnPlans).toHaveLength(2);
540 expect(turnPlans[0]).toMatchObject({
541 id: "turn-plan:turn-1",
542 createdAt: "2026-02-23T00:00:01.000Z",
543 turnId: "turn-1",
544 });
545 expect(turnPlans[0]?.plan.steps).toEqual([
546 { durationMs: 4_000, step: "Inspect code", status: "completed" },
547 ]);
548 expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]);
549 });
550
551 it("skips activities without parseable steps", () => {
552 const activities: OrchestrationThreadActivity[] = [
553 makeActivity({
554 id: "plan-bad",
555 createdAt: "2026-02-23T00:00:01.000Z",
556 kind: "turn.plan.updated",
557 summary: "Plan updated",
558 tone: "info",
559 turnId: "turn-1",
560 payload: { plan: [] },
561 }),
562 ];
563 expect(deriveTurnPlans(activities)).toEqual([]);
564 });
565497
566498 it("tracks repeated step labels independently", () => {
567499 const activities: OrchestrationThreadActivity[] = [
......@@ -609,7 +541,7 @@ describe("deriveTurnPlans", () => {
609541 }),
610542 ];
611543
612 expect(deriveTurnPlans(activities)[0]?.plan.steps).toEqual([
544 expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([
613545 { durationMs: 4_000, step: "Check", status: "completed" },
614546 { durationMs: 6_000, step: "Check", status: "completed" },
615547 ]);
......@@ -661,13 +593,13 @@ describe("deriveTurnPlans", () => {
661593 }),
662594 ];
663595
664 expect(deriveTurnPlans(activities)[0]?.plan.steps).toEqual([
596 expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([
665597 { durationMs: 5_000, step: "First", status: "completed" },
666598 { durationMs: 5_000, step: "Second", status: "completed" },
667599 ]);
668600 });
669601
670 it("drops a turn's chip when a later snapshot clears the plan", () => {
602 it("clears the active plan when a later snapshot has no steps", () => {
671603 const activities: OrchestrationThreadActivity[] = [
672604 makeActivity({
673605 id: "plan-set",
......@@ -688,7 +620,7 @@ describe("deriveTurnPlans", () => {
688620 payload: { plan: [] },
689621 }),
690622 ];
691 expect(deriveTurnPlans(activities)).toEqual([]);
623 expect(deriveActivePlanState(activities, TurnId.make("turn-1"))).toBeNull();
692624 });
693625});
694626
......@@ -915,6 +847,33 @@ describe("workEntryIndicatesToolFailure", () => {
915847});
916848
917849describe("deriveWorkLogEntries", () => {
850 it("keeps the latest task progress without emitting plan-update log entries", () => {
851 const activities = [
852 makeActivity({ id: "before", kind: "tool.completed", summary: "Read files", sequence: 0 }),
853 makeActivity({
854 id: "plan-1",
855 kind: "turn.plan.updated",
856 summary: "Plan updated",
857 turnId: "turn-1",
858 sequence: 1,
859 payload: { plan: [{ step: "Verify the composer", status: "inProgress" }] },
860 }),
861 makeActivity({
862 id: "plan-2",
863 kind: "turn.plan.updated",
864 summary: "Plan updated",
865 turnId: "turn-1",
866 sequence: 2,
867 payload: { plan: [{ step: "Verify the composer", status: "completed" }] },
868 }),
869 makeActivity({ id: "after", kind: "tool.completed", summary: "Ran tests", sequence: 3 }),
870 ];
871 expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual(["before", "after"]);
872 expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toMatchObject([
873 { step: "Verify the composer", status: "completed" },
874 ]);
875 });
876
918877 it("omits tool started entries and keeps completed entries", () => {
919878 const activities: OrchestrationThreadActivity[] = [
920879 makeActivity({
apps/web/src/session-logic.ts+2-70
......@@ -169,12 +169,6 @@ export type TimelineEntry =
169169 createdAt: string;
170170 proposedPlan: ProposedPlan;
171171 }
172 | {
173 id: string;
174 kind: "turn-plan";
175 createdAt: string;
176 turnPlan: TurnPlanEntry;
177 }
178172 | {
179173 id: string;
180174 kind: "work";
......@@ -712,62 +706,6 @@ export function deriveActivePlanState(
712706 return addPlanStepDurations(plan, matchingActivities.slice(latestClearIndex + 1));
713707}
714708
715export interface TurnPlanEntry {
716 /** Stable per-turn row id (plans rewrite constantly; the row must not churn). */
717 id: string;
718 /** Anchor timestamp: the turn's FIRST plan activity, so the chip renders where planning began. */
719 createdAt: string;
720 turnId: TurnId | null;
721 plan: ActivePlanState;
722}
723
724/**
725 * One inline plan chip per turn that produced plan/todo steps: the latest
726 * snapshot for the turn, anchored at the first snapshot's timestamp. Turn-less
727 * plan activities collapse into a single chip keyed by thread order.
728 */
729export function deriveTurnPlans(
730 activities: ReadonlyArray<OrchestrationThreadActivity>,
731): TurnPlanEntry[] {
732 const ordered = [...activities].toSorted(compareActivitiesByOrder);
733 const byTurn = new Map<
734 string,
735 { activities: OrchestrationThreadActivity[]; entry: TurnPlanEntry }
736 >();
737 for (const activity of ordered) {
738 if (activity.kind !== "turn.plan.updated") {
739 continue;
740 }
741 const plan = planStateFromActivity(activity);
742 const key = activity.turnId ?? "no-turn";
743 if (!plan) {
744 // A later snapshot with no steps clears the turn's plan; keeping the
745 // stale entry would freeze the chip on a withdrawn plan.
746 byTurn.delete(key);
747 continue;
748 }
749 const existing = byTurn.get(key);
750 if (existing) {
751 existing.entry.plan = plan;
752 existing.activities.push(activity);
753 } else {
754 byTurn.set(key, {
755 activities: [activity],
756 entry: {
757 id: `turn-plan:${key}`,
758 createdAt: activity.createdAt,
759 turnId: activity.turnId,
760 plan,
761 },
762 });
763 }
764 }
765 return [...byTurn.values()].map(({ activities: planActivities, entry }) => ({
766 ...entry,
767 plan: addPlanStepDurations(entry.plan, planActivities),
768 }));
769}
770
771709export function findLatestProposedPlan(
772710 proposedPlans: ReadonlyArray<ProposedPlan>,
773711 latestTurnId: TurnId | string | null | undefined,
......@@ -880,6 +818,7 @@ export function deriveWorkLogEntries(
880818 if (activity.kind === "task.updated") continue;
881819 if (activity.kind === "tool.progress") continue;
882820 if (activity.kind === "context-window.updated") continue;
821 if (activity.kind === "turn.plan.updated") continue;
883822 if (activity.summary === "Checkpoint captured") continue;
884823 if (isNoContentRuntimeWarning(activity)) continue;
885824 if (isPlanBoundaryToolActivity(activity)) continue;
......@@ -1847,7 +1786,6 @@ export function deriveTimelineEntries(
18471786 messages: ReadonlyArray<ChatMessage>,
18481787 proposedPlans: ReadonlyArray<ProposedPlan>,
18491788 workEntries: ReadonlyArray<WorkLogEntry>,
1850 turnPlans: ReadonlyArray<TurnPlanEntry> = [],
18511789): TimelineEntry[] {
18521790 const messageRows: TimelineEntry[] = messages.map((message) => ({
18531791 id: message.id,
......@@ -1861,19 +1799,13 @@ export function deriveTimelineEntries(
18611799 createdAt: proposedPlan.createdAt,
18621800 proposedPlan,
18631801 }));
1864 const turnPlanRows: TimelineEntry[] = turnPlans.map((turnPlan) => ({
1865 id: turnPlan.id,
1866 kind: "turn-plan",
1867 createdAt: turnPlan.createdAt,
1868 turnPlan,
1869 }));
18701802 const workRows: TimelineEntry[] = workEntries.map((entry) => ({
18711803 id: entry.id,
18721804 kind: "work",
18731805 createdAt: entry.createdAt,
18741806 entry,
18751807 }));
1876 return [...messageRows, ...proposedPlanRows, ...turnPlanRows, ...workRows].toSorted((a, b) =>
1808 return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) =>
18771809 a.createdAt.localeCompare(b.createdAt),
18781810 );
18791811}
apps/web/src/versionSkew.test.ts+36
......@@ -1,4 +1,5 @@
11import { EnvironmentId } from "@t3tools/contracts";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
23import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
34
45// Pinned so the direction cases below read as fixed versions instead of
......@@ -9,7 +10,9 @@ vi.mock("./branding", () => branding);
910import { APP_VERSION } from "./branding";
1011import {
1112 buildVersionMismatchDismissalKey,
13 dismissServerUpdateFailure,
1214 dismissVersionMismatch,
15 isServerUpdateFailureDismissed,
1316 isVersionMismatchDismissed,
1417 resolveServerConfigVersionMismatch,
1518 resolveServerSelfUpdateCapability,
......@@ -25,6 +28,39 @@ describe("versionSkew", () => {
2528 branding.APP_VERSION = "0.0.34";
2629 });
2730
31 it("dismisses only the current failed attempt without clearing its retry state", () => {
32 const failure = {
33 status: "failed",
34 stage: "downloading",
35 fromVersion: "0.0.33",
36 targetVersion: "0.0.34",
37 message: "Download failed.",
38 } as const satisfies ServerUpdateState;
39 const retryFailure = { ...failure };
40 const otherEnvironmentFailure = { ...failure };
41
42 dismissServerUpdateFailure(failure);
43
44 expect(isServerUpdateFailureDismissed(failure)).toBe(true);
45 expect(failure.status).toBe("failed");
46 expect(failure.message).toBe("Download failed.");
47 expect(isServerUpdateFailureDismissed(retryFailure)).toBe(false);
48 expect(isServerUpdateFailureDismissed(otherEnvironmentFailure)).toBe(false);
49 });
50
51 it("does not dismiss an update that is still running", () => {
52 const running = {
53 status: "running",
54 stage: "resuming",
55 fromVersion: "0.0.33",
56 targetVersion: "0.0.34",
57 } as const satisfies ServerUpdateState;
58
59 dismissServerUpdateFailure(running);
60
61 expect(isServerUpdateFailureDismissed(running)).toBe(false);
62 });
63
2864 it("does not warn when versions match", () => {
2965 expect(resolveVersionMismatch(APP_VERSION)).toBeNull();
3066 });
apps/web/src/versionSkew.ts+13
......@@ -1,4 +1,5 @@
11import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
23import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
34import * as Schema from "effect/Schema";
45
......@@ -13,6 +14,18 @@ export interface VersionMismatch {
1314
1415export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1";
1516
17// Runtime failures retain their identity until the next attempt. Dismiss only
18// that attempt, across chat remounts, without clearing the error in Settings.
19const dismissedServerUpdateFailures = new WeakSet<ServerUpdateState>();
20
21export function isServerUpdateFailureDismissed(state: ServerUpdateState): boolean {
22 return state.status === "failed" && dismissedServerUpdateFailures.has(state);
23}
24
25export function dismissServerUpdateFailure(state: ServerUpdateState): void {
26 if (state.status === "failed") dismissedServerUpdateFailures.add(state);
27}
28
1629const VersionMismatchDismissalsSchema = Schema.Struct({
1730 keys: Schema.Array(Schema.String),
1831});