authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-29 16:38:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-29 16:38:04-07:00
log3d32797f6f4614205debf8a61f09e950f5f9a81c
tree7640a3168b4883a7c643de0f48952c95eb88c563
parentc0e09f323ac9f6bf4b9119cbad841db3379588d6
signature Signed by PGP key B5690EEEBB952194

fix(web): unify activity logs and composer banners (#8693)


33 files changed, 1506 insertions(+), 2094 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
......@@ -32,7 +32,6 @@ import {
3232 resolveSendEnvMode,
3333 resolveDraftHeroState,
3434 scheduleEnvironmentReconnectWarning,
35 shoulderTabReserve,
3635 startNewThreadForProject,
3736 shouldDockDraftHeroForSubmission,
3837 shouldReleaseTimelineAnchorForToolActivity,
......@@ -46,24 +45,6 @@ const projectId = ProjectId.make("project-1");
4645const threadId = ThreadId.make("thread-1");
4746const now = "2026-03-29T00:00:00.000Z";
4847
49describe("shoulderTabReserve", () => {
50 it("ignores the top drawer when measuring the shoulder tab band", () => {
51 const elementAt = (top: number) => ({ getBoundingClientRect: () => ({ top }) }) as HTMLElement;
52 const elements = new Map<string, HTMLElement>([
53 ['[data-chat-composer-form="true"]', elementAt(20)],
54 [".chat-composer-shoulder-tab", elementAt(100)],
55 ['[data-chat-composer-main-surface="true"]', elementAt(128)],
56 ]);
57 const overlay = {
58 querySelector: (selector: string) => elements.get(selector) ?? null,
59 } as HTMLElement;
60
61 expect(shoulderTabReserve(overlay)).toBe(28);
62 elements.set(".chat-composer-tasks-tab", elementAt(100));
63 expect(shoulderTabReserve(overlay)).toBe(0);
64 });
65});
66
6748describe("draft hero submission transition", () => {
6849 it("does not dock the composer before a background submission", () => {
6950 expect(
apps/web/src/components/ChatView.logic.ts-11
......@@ -39,17 +39,6 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
3939
4040export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
4141
42export function shoulderTabReserve(overlay: HTMLElement): number {
43 if (overlay.querySelector(".chat-composer-tasks-tab")) return 0;
44 const tab = overlay.querySelector<HTMLElement>(".chat-composer-shoulder-tab");
45 const surface = overlay.querySelector<HTMLElement>('[data-chat-composer-main-surface="true"]');
46 if (!tab || !surface) return 0;
47 return Math.max(
48 0,
49 Math.round(surface.getBoundingClientRect().top - tab.getBoundingClientRect().top),
50 );
51}
52
5342export function shouldDockDraftHeroForSubmission(input: {
5443 isDraftHeroState: boolean;
5544 activeThreadKey: string | null;
apps/web/src/components/ChatView.tsx+61-88
......@@ -183,6 +183,7 @@ import {
183183 CheckCircle2Icon,
184184 ChevronDownIcon,
185185 GitBranchIcon,
186 InfoIcon,
186187 Minimize2Icon,
187188 PaperclipIcon,
188189 WifiOffIcon,
......@@ -316,14 +317,14 @@ import {
316317 threadChangeRequestSnapshotsAtom,
317318 useLinkedThreadPullRequest,
318319} from "./ThreadStatusIndicators";
319import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
320import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
321import { ComposerSurface } from "./chat/ComposerSurface";
320322import {
321323 hasAvailableClaudeCompactionProvider,
322324 hasDismissedResumeCompaction,
323325 shouldOfferResumeCompaction,
324326} from "./chat/ContextWindowMeter.logic";
325327import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
326import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
327328import {
328329 DRAFT_HERO_TRANSITION_ANIMATION_ID,
329330 DRAFT_HERO_TRANSITION_DURATION_MS,
......@@ -350,7 +351,6 @@ import {
350351 shouldDockDraftHeroForSubmission,
351352 shouldReleaseTimelineAnchorForToolActivity,
352353 shouldShowBranchMismatchBanner,
353 shoulderTabReserve,
354354 shouldShowPlanFollowUpPrompt,
355355 getStartedThreadModelChangeBlockReason,
356356 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
......@@ -401,10 +401,13 @@ import {
401401 AlertDialogTitle,
402402} from "./ui/alert-dialog";
403403import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
404import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction";
404import { ServerUpdateAction } from "./ServerUpdateAction";
405import { ComposerServerUpdateStatus } from "./chat/ComposerServerUpdateStatus";
405406import {
406407 buildVersionMismatchDismissalKey,
408 dismissServerUpdateFailure,
407409 dismissVersionMismatch,
410 isServerUpdateFailureDismissed,
408411 isVersionMismatchDismissed,
409412 resolveServerConfigVersionMismatch,
410413 resolveServerSelfUpdateCapability,
......@@ -2156,6 +2159,12 @@ function ChatViewContent(props: ChatViewProps) {
21562159 const serverUpdateState = useAtomValue(
21572160 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
21582161 );
2162 const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState<
2163 typeof serverUpdateState | null
2164 >(null);
2165 const serverUpdateFailureDismissed =
2166 serverUpdateState === dismissedServerUpdateState ||
2167 isServerUpdateFailureDismissed(serverUpdateState);
21592168 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
21602169 const items: ComposerBannerStackItem[] = [];
21612170 const updateRunning = serverUpdateState.status === "running";
......@@ -2183,8 +2192,8 @@ function ChatViewContent(props: ChatViewProps) {
21832192 items.push({
21842193 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
21852194 variant: "default",
2186 // Live connection status: calm styling, but it must front the stack.
2187 urgent: true,
2195 // Prioritize live connection progress among the notices.
2196 priority: "urgent",
21882197 icon: (
21892198 <span
21902199 className="size-1.5 animate-status-pulse rounded-full bg-foreground"
......@@ -2231,29 +2240,24 @@ function ChatViewContent(props: ChatViewProps) {
22312240 if (
22322241 serverUpdateEnvironmentId &&
22332242 !reconnectingThroughVersionSkew &&
2234 (serverUpdateState.status !== "idle" ||
2235 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))
2243 (serverUpdateState.status === "idle"
2244 ? showVersionMismatchBanner
2245 : !serverUpdateFailureDismissed)
22362246 ) {
22372247 const updateInProgress = serverUpdateState.status === "running";
22382248 const updateFailed = serverUpdateState.status === "failed";
22392249 items.push({
22402250 id: `server-version:${serverUpdateEnvironmentId}`,
22412251 variant: updateFailed ? "error" : "default",
2242 // A running update is live progress the user is waiting on; only the
2243 // idle "update available" offer is calm enough to stack behind.
2244 urgent: updateInProgress,
2245 // In-flight and failed states carry their own status dot inside
2246 // ServerUpdateProgress; only the idle offer needs an icon.
2247 icon:
2248 updateInProgress || updateFailed ? null : (
2249 <span
2250 className="size-1.5 rounded-full border border-muted-foreground/40"
2251 aria-hidden="true"
2252 />
2253 ),
2252 // Prioritize update progress over passive notices, but keep activity attached.
2253 priority: updateInProgress ? "urgent" : "notice",
2254 icon: <InfoIcon aria-hidden />,
22542255 title:
22552256 updateInProgress || updateFailed ? (
2256 `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
2257 <ComposerServerUpdateStatus
2258 state={serverUpdateState}
2259 serverLabel={versionMismatchServerLabel}
2260 />
22572261 ) : versionMismatch ? (
22582262 <Tooltip>
22592263 <TooltipTrigger
......@@ -2272,11 +2276,9 @@ function ChatViewContent(props: ChatViewProps) {
22722276 "Server update available"
22732277 ),
22742278 description:
2275 updateInProgress || updateFailed ? (
2276 <ServerUpdateProgress state={serverUpdateState} />
2277 ) : versionMismatchSelfUpdate === "desktop-managed" ? (
2278 serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2279 ) : null,
2279 !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed"
2280 ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2281 : undefined,
22802282 // The desktop-managed guidance is already the description; the action
22812283 // slot would only repeat it.
22822284 actions:
......@@ -2291,11 +2293,15 @@ function ChatViewContent(props: ChatViewProps) {
22912293 label={updateFailed ? "Retry" : "Update"}
22922294 />
22932295 ),
2294 ...(updateInProgress || updateFailed || !versionMismatchDismissKey
2296 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)
22952297 ? {}
22962298 : {
22972299 dismissLabel: "Dismiss update notice",
22982300 onDismiss: () => {
2301 if (updateFailed) {
2302 dismissServerUpdateFailure(serverUpdateState);
2303 setDismissedServerUpdateState(serverUpdateState);
2304 }
22992305 dismissVersionMismatch(versionMismatchDismissKey);
23002306 setDismissedVersionMismatchKey(versionMismatchDismissKey);
23012307 },
......@@ -2310,6 +2316,7 @@ function ChatViewContent(props: ChatViewProps) {
23102316 navigate,
23112317 setDismissedVersionMismatchKey,
23122318 showVersionMismatchBanner,
2319 serverUpdateFailureDismissed,
23132320 serverUpdateState,
23142321 versionMismatch,
23152322 versionMismatchDismissKey,
......@@ -2397,21 +2404,6 @@ function ChatViewContent(props: ChatViewProps) {
23972404 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
23982405 [activeLatestTurn?.turnId, threadActivities],
23992406 );
2400 // Current step for the in-chat working row: only for the running turn's own
2401 // plan (deriveActivePlanState falls back to older turns' plans, which must
2402 // not label fresh work). Falls back to the first pending step so an
2403 // all-pending freshly written plan labels the row, matching the chip and
2404 // the server's planProgress.
2405 const workingStepLabel = useMemo(() => {
2406 if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) {
2407 return null;
2408 }
2409 return (
2410 activePlan.steps.find((step) => step.status === "inProgress")?.step ??
2411 activePlan.steps.find((step) => step.status === "pending")?.step ??
2412 null
2413 );
2414 }, [activeLatestTurn?.turnId, activePlan]);
24152407 const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({
24162408 pendingUserInputCount: pendingUserInputs.length,
24172409 interactionMode,
......@@ -4442,10 +4434,20 @@ function ChatViewContent(props: ChatViewProps) {
44424434 // partition (same shell, same capability gate, same PR auto-settle input)
44434435 // so the banner and the sidebar row never disagree.
44444436 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
4445 const activeComposerTasksProgress =
4446 activeLatestTurn !== null && !latestTurnSettled
4447 ? (activeThreadShell?.planProgress ?? null)
4448 : null;
4437 const activeComposerTasksProgress = useMemo(() => {
4438 if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) {
4439 return null;
4440 }
4441 const currentStep =
4442 activePlan.steps.find((step) => step.status === "inProgress") ??
4443 activePlan.steps.find((step) => step.status === "pending");
4444 if (!currentStep) return null;
4445 return {
4446 step: currentStep.step,
4447 completedSteps: activePlan.steps.filter((step) => step.status === "completed").length,
4448 totalSteps: activePlan.steps.length,
4449 };
4450 }, [activeLatestTurn, activePlan, latestTurnSettled]);
44494451 const activeComposerTaskSteps =
44504452 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
44514453 ? activePlan.steps
......@@ -4460,9 +4462,8 @@ function ChatViewContent(props: ChatViewProps) {
44604462 setComposerOverlayHeight((currentHeight) =>
44614463 currentHeight === nextHeight ? currentHeight : nextHeight,
44624464 );
4463 const nextClearance = Math.max(0, nextHeight - shoulderTabReserve(composerOverlayElement));
44644465 setScrollToEndClearance((currentClearance) =>
4465 currentClearance === nextClearance ? currentClearance : nextClearance,
4466 currentClearance === nextHeight ? currentClearance : nextHeight,
44664467 );
44674468 };
44684469
......@@ -4471,11 +4472,8 @@ function ChatViewContent(props: ChatViewProps) {
44714472
44724473 const resizeObserver = new ResizeObserver(updateHeight);
44734474 resizeObserver.observe(composerOverlayElement);
4474 const tabObserver = new MutationObserver(updateHeight);
4475 tabObserver.observe(composerOverlayElement, { childList: true, subtree: true });
44764475 return () => {
44774476 resizeObserver.disconnect();
4478 tabObserver.disconnect();
44794477 };
44804478 }, [composerOverlayElement]);
44814479
......@@ -4803,6 +4801,7 @@ function ChatViewContent(props: ChatViewProps) {
48034801 return {
48044802 id: `background-liveness:${activeThread.id}`,
48054803 variant: "default",
4804 priority: "activity",
48064805 icon: (
48074806 <span
48084807 className={cn("size-1.5 rounded-full bg-foreground", working && "animate-status-pulse")}
......@@ -4849,13 +4848,6 @@ function ChatViewContent(props: ChatViewProps) {
48494848 onDismiss: acknowledgeActiveThreadWoke,
48504849 };
48514850 }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);
4852 // The stack renders items[0] front-most and tucks the rest behind hover, so
4853 // ordering is priority: urgent system banners (error/warning variants plus
4854 // calm-styled live states flagged `urgent`, like update progress), then
4855 // background liveness — its Stop button is the only stop affordance for
4856 // settled turns, so a passive "update available" notice must not cover it —
4857 // then calm system banners, the woke and branch-mismatch notices, and the
4858 // informational parked-thread banner last — it must never cover another.
48594851 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
48604852 if (!activeThreadSnoozed && !activeThreadSettled) {
48614853 return null;
......@@ -5006,10 +4998,6 @@ function ChatViewContent(props: ChatViewProps) {
50064998 void handleSwitchCheckoutToThread();
50074999 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
50085000 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
5009 const isUrgentSystemItem = (item: ComposerBannerStackItem) =>
5010 item.urgent === true || item.variant === "error" || item.variant === "warning";
5011 const urgentSystemItems = systemComposerBannerItems.filter(isUrgentSystemItem);
5012 const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item));
50135001 const backgroundLivenessItems =
50145002 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
50155003 const resumeCompactionItems =
......@@ -5018,18 +5006,16 @@ function ChatViewContent(props: ChatViewProps) {
50185006 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
50195007 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
50205008 return [
5021 ...urgentSystemItems,
5009 ...systemComposerBannerItems,
50225010 ...backgroundLivenessItems,
5023 ...calmSystemItems,
50245011 ...resumeCompactionItems,
50255012 ...wokeThreadItems,
50265013 ...parkedThreadItems,
50275014 ];
50285015 }
50295016 return [
5030 ...urgentSystemItems,
5017 ...systemComposerBannerItems,
50315018 ...backgroundLivenessItems,
5032 ...calmSystemItems,
50335019 ...resumeCompactionItems,
50345020 ...wokeThreadItems,
50355021 {
......@@ -7026,8 +7012,6 @@ function ChatViewContent(props: ChatViewProps) {
70267012 setDragActive: setIsWorkspaceFileDragActive,
70277013 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
70287014 });
7029 const externalComposerDrawerAttached =
7030 composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable);
70317015
70327016 return (
70337017 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
......@@ -7125,8 +7109,6 @@ function ChatViewContent(props: ChatViewProps) {
71257109 onOpenAgents={addAgentsSurface}
71267110 key={activeThread.id}
71277111 isWorking={isWorking}
7128 workingStepLabel={workingStepLabel}
7129 activeTurnStartedAt={activeWorkStartedAt}
71307112 listRef={legendListRef}
71317113 timelineEntries={timelineEntries}
71327114 latestTurn={activeLatestTurn}
......@@ -7194,7 +7176,7 @@ function ChatViewContent(props: ChatViewProps) {
71947176 {isDraftHeroState ? (
71957177 <div className="absolute inset-x-0 bottom-full z-0">
71967178 <div
7197 className="pb-8 group-has-[.chat-composer-shoulder-tab]/composer-stack:pb-4"
7179 className="pb-8 group-has-data-[composer-shoulder-tab]/composer-stack:pb-4"
71987180 style={
71997181 forceExpandedMobileComposer
72007182 ? {
......@@ -7208,13 +7190,7 @@ function ChatViewContent(props: ChatViewProps) {
72087190 activeProjectTitle={activeProject?.title ?? null}
72097191 />
72107192 </div>
7211 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
72127193 </div>
7213 ) : (
7214 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
7215 )}
7216 {threadSyncPhase && !activeEnvironmentUnavailable ? (
7217 <ThreadSyncStatusPill phase={threadSyncPhase} />
72187194 ) : null}
72197195 <div
72207196 className="relative"
......@@ -7224,14 +7200,8 @@ function ChatViewContent(props: ChatViewProps) {
72247200 : undefined
72257201 }
72267202 >
7227 <div
7228 className={cn(
7229 "chat-composer-glass-shell relative mx-auto w-full max-w-3xl",
7230 externalComposerDrawerAttached && "chat-composer-glass-shell-attached",
7231 showComposerContextStrip && "chat-composer-glass-shell-with-context",
7232 )}
7233 >
7234 <div className="chat-composer-glass-host relative z-10 w-full rounded-[22px]">
7203 <ComposerSurface.Shell contextStrip={showComposerContextStrip}>
7204 <ComposerSurface.Host>
72357205 <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">
72367206 <ChatComposer
72377207 composerRef={composerRef}
......@@ -7261,7 +7231,7 @@ function ChatViewContent(props: ChatViewProps) {
72617231 : null
72627232 }
72637233 isPreparingWorktree={isPreparingWorktree}
7264 externalDrawerAttached={externalComposerDrawerAttached}
7234 bannerItems={composerBannerItems}
72657235 environmentUnavailable={activeEnvironmentUnavailableState}
72667236 activePendingApproval={activePendingApproval}
72677237 pendingApprovals={pendingApprovals}
......@@ -7276,6 +7246,9 @@ function ChatViewContent(props: ChatViewProps) {
72767246 activeProposedPlan={activeProposedPlan}
72777247 activeTasksProgress={activeComposerTasksProgress}
72787248 activeTaskSteps={activeComposerTaskSteps}
7249 isWorking={isWorking}
7250 activeWorkStartedAt={activeWorkStartedAt}
7251 threadSyncPhase={activeEnvironmentUnavailable ? null : threadSyncPhase}
72797252 runtimeMode={runtimeMode}
72807253 interactionMode={interactionMode}
72817254 lockedProvider={lockedProvider}
......@@ -7322,7 +7295,7 @@ function ChatViewContent(props: ChatViewProps) {
73227295 onExpandImage={onExpandTimelineImage}
73237296 />
73247297 </div>
7325 </div>
7298 </ComposerSurface.Host>
73267299 <div className="min-h-0">
73277300 <div
73287301 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}
......@@ -7360,7 +7333,7 @@ function ChatViewContent(props: ChatViewProps) {
73607333 )}
73617334 </div>
73627335 </div>
7363 </div>
7336 </ComposerSurface.Shell>
73647337 <div
73657338 aria-hidden
73667339 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+218-173
......@@ -10,7 +10,6 @@ import type {
1010 ScopedThreadRef,
1111 ServerProvider,
1212 ThreadId,
13 TurnId,
1413} from "@t3tools/contracts";
1514import {
1615 ProviderDriverKind,
......@@ -72,10 +71,20 @@ import { ComposerStashBadge } from "./ComposerStashBadge";
7271import { ComposerStashMenu } from "./ComposerStashMenu";
7372import {
7473 ComposerTasksBadge,
74 ComposerTasksContent,
7575 ComposerTasksDrawer,
7676 type ComposerTaskStep,
7777 type ComposerTasksProgress,
7878} from "./ComposerTasksBadge";
79import { ComposerActivityRow, type ComposerActivityStatus } from "./ComposerActivityStatus";
80import type { ThreadSyncPhase } from "../../threadSync";
81import { ComposerBanner } from "./ComposerBanner";
82import { ComposerSurface } from "./ComposerSurface";
83import {
84 ComposerBannerStack,
85 type ComposerBannerStackContent,
86 type ComposerBannerStackItem,
87} from "./ComposerBannerStack";
7988import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
8089import {
8190 fileAttachmentTooLargeMessage,
......@@ -595,7 +604,7 @@ export interface ChatComposerProps {
595604 isSendBusy: boolean;
596605 sendDisabledReason: string | null;
597606 isPreparingWorktree: boolean;
598 externalDrawerAttached: boolean;
607 bannerItems: readonly ComposerBannerStackItem[];
599608 environmentUnavailable: {
600609 readonly label: string;
601610 readonly connection: EnvironmentConnectionPresentation;
......@@ -623,6 +632,9 @@ export interface ChatComposerProps {
623632 activeProposedPlan: Thread["proposedPlans"][number] | null;
624633 activeTasksProgress: ComposerTasksProgress | null;
625634 activeTaskSteps: readonly ComposerTaskStep[] | null;
635 isWorking: boolean;
636 activeWorkStartedAt: string | null;
637 threadSyncPhase: ThreadSyncPhase | null;
626638
627639 // Mode
628640 runtimeMode: RuntimeMode;
......@@ -723,8 +735,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
723735 respondingRequestIds,
724736 showPlanFollowUpPrompt,
725737 activeProposedPlan,
726 activeTasksProgress,
727 activeTaskSteps,
738 isWorking,
739 activeWorkStartedAt,
728740 runtimeMode,
729741 interactionMode,
730742 lockedProvider,
......@@ -763,6 +775,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
763775 setThreadError,
764776 onExpandImage,
765777 } = props;
778 const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;
779 const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;
766780 // ------------------------------------------------------------------
767781 // Store subscriptions (prompt / images / terminal contexts)
768782 // ------------------------------------------------------------------
......@@ -1164,7 +1178,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11641178 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
11651179 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
11661180 const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);
1167 const [dismissedTasksTurnId, setDismissedTasksTurnId] = useState<TurnId | null>(null);
11681181 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
11691182 key: 0,
11701183 active: false,
......@@ -2574,6 +2587,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25742587 const deleteStashEntry = useCallback(
25752588 (entry: PromptStashEntry) => {
25762589 const { entry: removed, durable } = takeStashEntry(entry.id);
2590 if (!stashQueue.some((candidate) => candidate.id !== entry.id)) {
2591 setIsStashMenuOpen(false);
2592 }
25772593 if (durable && removed) {
25782594 for (const file of removed.files ?? []) {
25792595 releasePersistedAttachmentUpload({
......@@ -2593,7 +2609,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25932609 });
25942610 }
25952611 },
2596 [takeStashEntry],
2612 [stashQueue, takeStashEntry],
25972613 );
25982614
25992615 const stashCurrentPrompt = useCallback(async () => {
......@@ -2806,26 +2822,22 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28062822 const toggleTasksDrawer = useCallback(() => {
28072823 setIsTasksDrawerOpen((open) => !open);
28082824 }, []);
2809 const activeTasksTurnId = activeThread?.latestTurn?.turnId ?? null;
2810 const tasksDismissedForActiveTurn =
2811 activeTasksTurnId !== null && dismissedTasksTurnId === activeTasksTurnId;
2812 const visibleTasksProgress = tasksDismissedForActiveTurn ? null : activeTasksProgress;
2813 const visibleTaskSteps = tasksDismissedForActiveTurn ? null : activeTaskSteps;
2825 const activityStatus = useMemo<ComposerActivityStatus | undefined>(
2826 () =>
2827 props.threadSyncPhase
2828 ? { kind: "sync", phase: props.threadSyncPhase }
2829 : isWorking
2830 ? { kind: "working", startedAt: activeWorkStartedAt }
2831 : undefined,
2832 [props.threadSyncPhase, isWorking, activeWorkStartedAt],
2833 );
2834 const hasBannerItems = props.bannerItems.length > 0;
28142835 const hasBlockingComposerTopDrawer =
28152836 activePendingApproval !== null || pendingUserInputs.length > 0;
2816 const dismissTasks = useCallback(() => {
2817 if (activeTasksTurnId !== null) {
2818 setDismissedTasksTurnId(activeTasksTurnId);
2819 }
2820 setIsTasksDrawerOpen(false);
2821 }, [activeTasksTurnId]);
28222837 const showInlineStashBadge =
28232838 stashQueue.length > 0 &&
28242839 !isComposerApprovalState &&
2825 (props.externalDrawerAttached ||
2826 showComposerTopDrawer ||
2827 isTasksDrawerOpen ||
2828 isComposerCollapsedMobile);
2840 (hasBannerItems || showComposerTopDrawer || isTasksDrawerOpen || isMobileViewport);
28292841 const inlineStashBadge = showInlineStashBadge ? (
28302842 <ComposerStashBadge
28312843 count={stashQueue.length}
......@@ -2837,37 +2849,66 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28372849 />
28382850 ) : null;
28392851 const showInlineTasksBadge =
2840 visibleTasksProgress !== null &&
2841 visibleTaskSteps !== null &&
2852 activeTasksProgress !== null &&
2853 activeTaskSteps !== null &&
28422854 !isTasksDrawerOpen &&
28432855 !hasBlockingComposerTopDrawer &&
2844 (props.externalDrawerAttached || showComposerTopDrawer || isComposerCollapsedMobile);
2856 (hasBannerItems || showComposerTopDrawer || isComposerCollapsedMobile);
28452857 const inlineTasksBadge = showInlineTasksBadge ? (
28462858 <ComposerTasksBadge
28472859 expanded={false}
2848 onDismiss={dismissTasks}
28492860 onToggle={toggleTasksDrawer}
28502861 placement="inline"
2851 progress={visibleTasksProgress}
2852 steps={visibleTaskSteps}
2862 progress={activeTasksProgress}
2863 steps={activeTaskSteps}
2864 activityStatus={activityStatus}
28532865 />
28542866 ) : null;
28552867 const showShoulderTabs =
2856 !props.externalDrawerAttached &&
2857 !showComposerTopDrawer &&
2858 !isTasksDrawerOpen &&
2859 !isComposerCollapsedMobile;
2868 !hasBannerItems && !showComposerTopDrawer && !isTasksDrawerOpen && !isComposerCollapsedMobile;
28602869 const hasShoulderTab =
28612870 showShoulderTabs &&
2862 (stashQueue.length > 0 ||
2863 (visibleTasksProgress !== null &&
2864 visibleTaskSteps !== null &&
2865 visibleTasksProgress.totalSteps > 0));
2871 (activityStatus !== undefined ||
2872 (stashQueue.length > 0 && !showInlineStashBadge) ||
2873 (activeTasksProgress !== null &&
2874 activeTaskSteps !== null &&
2875 activeTasksProgress.totalSteps > 0));
2876 const tasksHostActivityStatus =
2877 activeTasksProgress !== null &&
2878 activeTasksProgress.totalSteps > 0 &&
2879 activeTaskSteps !== null &&
2880 !hasBlockingComposerTopDrawer &&
2881 (showShoulderTabs || showInlineTasksBadge || isTasksDrawerOpen);
2882 const standaloneActivityStatus = !tasksHostActivityStatus ? activityStatus : undefined;
2883 const activityStackContent = hasBannerItems ? (
2884 !hasBlockingComposerTopDrawer && activeTasksProgress && activeTaskSteps ? (
2885 <ComposerTasksContent
2886 expanded={isTasksDrawerOpen}
2887 onToggle={toggleTasksDrawer}
2888 progress={activeTasksProgress}
2889 steps={activeTaskSteps}
2890 activityStatus={activityStatus}
2891 />
2892 ) : activityStatus ? (
2893 <ComposerActivityRow status={activityStatus} />
2894 ) : null
2895 ) : null;
2896 const activityStackItem: ComposerBannerStackContent | null = activityStackContent
2897 ? {
2898 id: "composer-activity",
2899 variant: "default",
2900 priority: "activity",
2901 content: activityStackContent,
2902 }
2903 : null;
2904 const bannerStackItems = activityStackItem
2905 ? [...props.bannerItems, activityStackItem]
2906 : props.bannerItems;
28662907 useEffect(() => {
2867 if (visibleTasksProgress === null || visibleTaskSteps === null) {
2908 if (activeTasksProgress === null || activeTaskSteps === null) {
28682909 setIsTasksDrawerOpen(false);
28692910 }
2870 }, [visibleTaskSteps, visibleTasksProgress]);
2911 }, [activeTaskSteps, activeTasksProgress]);
28712912
28722913 useEffect(() => {
28732914 if (hasBlockingComposerTopDrawer) {
......@@ -3374,62 +3415,49 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
33743415 onDragOverCapture={composerMentionDragHandlers.onDragOver}
33753416 onDragLeaveCapture={onComposerMentionDragLeaveCapture}
33763417 onDropCapture={composerMentionDragHandlers.onDrop}
3377 className={cn("mx-auto w-full min-w-0 max-w-3xl", hasShoulderTab && "pt-7")}
3418 className="mx-auto w-full min-w-0 max-w-3xl"
33783419 data-chat-composer-form="true"
33793420 >
3421 <ComposerBannerStack key={activeThreadId} className="relative z-0" items={bannerStackItems} />
3422 {!activityStackItem &&
3423 (inlineTasksBadge || (standaloneActivityStatus && !showShoulderTabs)) ? (
3424 <ComposerBanner.Attachment>
3425 <ComposerBanner.Root data-chat-composer-activity-strip="true">
3426 {inlineTasksBadge ??
3427 (standaloneActivityStatus ? (
3428 <ComposerActivityRow status={standaloneActivityStatus} />
3429 ) : null)}
3430 </ComposerBanner.Root>
3431 </ComposerBanner.Attachment>
3432 ) : null}
33803433 {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
3381 <div
3382 className="chat-composer-top-drawer"
3383 data-chat-composer-top-drawer="true"
3384 data-variant={activePendingApproval ? "warning" : "info"}
3385 >
3386 {!isComposerCollapsedMobile && activePendingApproval ? (
3387 <div className="flex min-w-0 flex-wrap items-center gap-1 px-3 py-1.5 sm:px-4">
3388 <ComposerPendingApprovalPanel
3389 approval={activePendingApproval}
3390 pendingCount={pendingApprovals.length}
3391 />
3392 <div className="flex min-w-0 flex-wrap items-center gap-0.5">
3393 <ComposerPendingApprovalActions
3394 requestId={activePendingApproval.requestId}
3395 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3396 options={activePendingApproval.options}
3397 onRespondToApproval={onRespondToApproval}
3398 />
3399 </div>
3400 </div>
3401 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3402 <ComposerPendingUserInputPanel
3403 pendingUserInputs={pendingUserInputs}
3404 respondingRequestIds={respondingRequestIds}
3405 answers={activePendingDraftAnswers}
3406 questionIndex={activePendingQuestionIndex}
3407 onToggleOption={onSelectActivePendingUserInputOption}
3408 onAdvance={onAdvanceActivePendingUserInput}
3409 />
3410 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (
3411 <ComposerPlanFollowUpBanner
3412 key={activeProposedPlan.id}
3413 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
3414 />
3415 ) : isComposerCollapsedMobile && activePendingApproval ? (
3416 <div data-chat-composer-collapsed-controls="true">
3417 <ComposerPendingApprovalPanel
3418 approval={activePendingApproval}
3419 pendingCount={pendingApprovals.length}
3420 className="px-3 pt-2 sm:px-4"
3421 />
3422 <div className="flex flex-wrap items-center justify-end gap-1 px-3 pt-2 pb-3 sm:px-4">
3423 <ComposerPendingApprovalActions
3424 requestId={activePendingApproval.requestId}
3425 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3426 options={activePendingApproval.options}
3427 onRespondToApproval={onRespondToApproval}
3428 />
3429 </div>
3430 </div>
3431 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3432 <div data-chat-composer-collapsed-controls="true">
3434 <ComposerBanner.Attachment>
3435 <ComposerBanner.Root
3436 data-chat-composer-top-drawer="true"
3437 variant={activePendingApproval ? "warning" : "info"}
3438 >
3439 {activePendingApproval ? (
3440 <ComposerBanner.Row
3441 layout="wrap-actions"
3442 data-chat-composer-collapsed-controls="true"
3443 >
3444 <ComposerBanner.Icon />
3445 <ComposerBanner.Content>
3446 <ComposerPendingApprovalPanel
3447 approval={activePendingApproval}
3448 pendingCount={pendingApprovals.length}
3449 />
3450 </ComposerBanner.Content>
3451 <ComposerBanner.Actions>
3452 <ComposerPendingApprovalActions
3453 requestId={activePendingApproval.requestId}
3454 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3455 options={activePendingApproval.options}
3456 onRespondToApproval={onRespondToApproval}
3457 />
3458 </ComposerBanner.Actions>
3459 </ComposerBanner.Row>
3460 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
34333461 <ComposerPendingUserInputPanel
34343462 pendingUserInputs={pendingUserInputs}
34353463 respondingRequestIds={respondingRequestIds}
......@@ -3438,96 +3466,116 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34383466 onToggleOption={onSelectActivePendingUserInputOption}
34393467 onAdvance={onAdvanceActivePendingUserInput}
34403468 />
3441 <div className="px-3 pb-3 sm:px-4">
3442 <div
3443 data-chat-composer-mobile-pending-compact="true"
3444 className={cn(
3445 "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",
3446 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
3447 )}
3448 >
3449 <button
3450 type="button"
3469 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (
3470 <ComposerPlanFollowUpBanner
3471 key={activeProposedPlan.id}
3472 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
3473 />
3474 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3475 <div data-chat-composer-collapsed-controls="true">
3476 <ComposerPendingUserInputPanel
3477 pendingUserInputs={pendingUserInputs}
3478 respondingRequestIds={respondingRequestIds}
3479 answers={activePendingDraftAnswers}
3480 questionIndex={activePendingQuestionIndex}
3481 onToggleOption={onSelectActivePendingUserInputOption}
3482 onAdvance={onAdvanceActivePendingUserInput}
3483 />
3484 <ComposerBanner.Body>
3485 <div
3486 data-chat-composer-mobile-pending-compact="true"
34513487 className={cn(
3452 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
3453 activePendingProgress?.customAnswer ? "text-foreground" : "text-placeholder",
3454 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
3488 "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",
3489 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
34553490 )}
3456 onPointerDown={(event) => event.preventDefault()}
3457 onClick={expandMobileComposer}
3458 aria-label="Write custom answer"
34593491 >
3460 {activePendingProgress?.customAnswer || "Write custom answer"}
3461 </button>
3462 {inlineTasksBadge}
3463 {inlineStashBadge}
3464 {activePendingProgress?.activeQuestion?.multiSelect ? (
3465 <ComposerPrimaryActions
3466 compact
3467 pendingAction={pendingPrimaryAction}
3468 isRunning={false}
3469 showPlanFollowUpPrompt={false}
3470 promptHasText={false}
3471 isSendBusy={isSendBusy}
3472 sendDisabledReason={sendDisabledReason}
3473 isConnecting={isConnecting}
3474 isEnvironmentUnavailable={
3475 environmentUnavailable !== null ||
3476 noProviderAvailable ||
3477 projectSelectionRequired
3478 }
3479 isPreparingWorktree={false}
3480 hasSendableContent={false}
3481 preserveComposerFocusOnPointerDown
3482 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3483 onInterrupt={handleInterruptPrimaryAction}
3484 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3485 />
3486 ) : null}
3487 </div>
3492 <button
3493 type="button"
3494 className={cn(
3495 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
3496 activePendingProgress?.customAnswer
3497 ? "text-foreground"
3498 : "text-placeholder",
3499 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
3500 )}
3501 onPointerDown={(event) => event.preventDefault()}
3502 onClick={expandMobileComposer}
3503 aria-label="Write custom answer"
3504 >
3505 {activePendingProgress?.customAnswer || "Write custom answer"}
3506 </button>
3507 {inlineStashBadge}
3508 {activePendingProgress?.activeQuestion?.multiSelect ? (
3509 <ComposerPrimaryActions
3510 compact
3511 pendingAction={pendingPrimaryAction}
3512 isRunning={false}
3513 showPlanFollowUpPrompt={false}
3514 promptHasText={false}
3515 isSendBusy={isSendBusy}
3516 sendDisabledReason={sendDisabledReason}
3517 isConnecting={isConnecting}
3518 isEnvironmentUnavailable={
3519 environmentUnavailable !== null ||
3520 noProviderAvailable ||
3521 projectSelectionRequired
3522 }
3523 isPreparingWorktree={false}
3524 hasSendableContent={false}
3525 preserveComposerFocusOnPointerDown
3526 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3527 onInterrupt={handleInterruptPrimaryAction}
3528 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3529 />
3530 ) : null}
3531 </div>
3532 </ComposerBanner.Body>
34883533 </div>
3489 </div>
3490 ) : null}
3491 </div>
3534 ) : null}
3535 </ComposerBanner.Root>
3536 </ComposerBanner.Attachment>
34923537 ) : null}
3493 {isTasksDrawerOpen &&
3538 {!activityStackItem &&
3539 isTasksDrawerOpen &&
34943540 !hasBlockingComposerTopDrawer &&
3495 visibleTasksProgress &&
3496 visibleTaskSteps ? (
3541 activeTasksProgress &&
3542 activeTaskSteps ? (
34973543 <ComposerTasksDrawer
3498 onDismiss={dismissTasks}
34993544 onCollapse={toggleTasksDrawer}
3500 progress={visibleTasksProgress}
3501 steps={visibleTaskSteps}
3545 progress={activeTasksProgress}
3546 steps={activeTaskSteps}
3547 activityStatus={activityStatus}
35023548 />
35033549 ) : null}
3550 {hasShoulderTab ? (
3551 <ComposerBanner.Dock>
3552 {standaloneActivityStatus ? (
3553 <ComposerBanner.Root width="content" data-composer-shoulder-tab>
3554 <ComposerActivityRow status={standaloneActivityStatus} />
3555 </ComposerBanner.Root>
3556 ) : null}
3557 {activeTasksProgress && activeTaskSteps ? (
3558 <ComposerTasksBadge
3559 expanded={false}
3560 onToggle={toggleTasksDrawer}
3561 progress={activeTasksProgress}
3562 steps={activeTaskSteps}
3563 activityStatus={activityStatus}
3564 />
3565 ) : null}
3566 {!showInlineStashBadge ? (
3567 <ComposerStashBadge
3568 count={stashQueue.length}
3569 menuOpen={isStashMenuOpen}
3570 pulseKey={stashPulse.key}
3571 pulsing={stashPulse.active}
3572 onToggleMenu={toggleStashMenu}
3573 />
3574 ) : null}
3575 </ComposerBanner.Dock>
3576 ) : null}
35043577 <div className="relative">
3505 {showShoulderTabs && visibleTasksProgress && visibleTaskSteps ? (
3506 <ComposerTasksBadge
3507 expanded={false}
3508 hasTrailingShoulder={stashQueue.length > 0}
3509 onDismiss={dismissTasks}
3510 onToggle={toggleTasksDrawer}
3511 progress={visibleTasksProgress}
3512 steps={visibleTaskSteps}
3513 />
3514 ) : null}
3515 {showShoulderTabs ? (
3516 <ComposerStashBadge
3517 count={stashQueue.length}
3518 menuOpen={isStashMenuOpen}
3519 pulseKey={stashPulse.key}
3520 pulsing={stashPulse.active}
3521 onToggleMenu={toggleStashMenu}
3522 />
3523 ) : null}
3524 <div
3525 data-chat-composer-main-surface="true"
3526 className={cn(
3527 "group relative z-10 rounded-[22px] p-px transition-colors duration-200",
3528 composerProviderState.composerFrameClassName,
3529 )}
3530 >
3578 <ComposerSurface.Main className={composerProviderState.composerFrameClassName}>
35313579 <div
35323580 ref={composerSurfaceRef}
35333581 data-chat-composer-surface="true"
......@@ -3559,7 +3607,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
35593607 : prompt.trim() ||
35603608 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
35613609 </button>
3562 {inlineTasksBadge}
35633610 {inlineStashBadge}
35643611 <button
35653612 type="button"
......@@ -3920,7 +3967,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39203967 data-chat-composer-mobile-pending-actions="true"
39213968 className="absolute bottom-0 right-0 flex items-center justify-end gap-1"
39223969 >
3923 {inlineTasksBadge}
39243970 {inlineStashBadge}
39253971 <ComposerPrimaryActions
39263972 compact
......@@ -4078,7 +4124,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
40784124 </Tooltip>
40794125 </>
40804126 ) : null}
4081 {showMobilePendingAnswerActions ? null : inlineTasksBadge}
40824127 {showMobilePendingAnswerActions ? null : inlineStashBadge}
40834128 <ComposerFooterPrimaryActions
40844129 compact={isComposerPrimaryActionsCompact}
......@@ -4117,7 +4162,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
41174162 </div>
41184163 )}
41194164 </div>
4120 </div>
4165 </ComposerSurface.Main>
41214166 </div>
41224167 </form>
41234168 );
apps/web/src/components/chat/ComposerActivityStatus.tsx created+106
......@@ -0,0 +1,106 @@
1import { LoaderCircleIcon } from "lucide-react";
2import { useEffect, useRef } from "react";
3import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
4import { ComposerBanner } from "./ComposerBanner";
5
6export type ComposerActivityStatus =
7 | { readonly kind: "working"; readonly startedAt: string | null }
8 | { readonly kind: "sync"; readonly phase: ThreadSyncPhase };
9
10export function ComposerActivityIcon({ status }: { readonly status: ComposerActivityStatus }) {
11 if (status.kind !== "sync") return null;
12 return (
13 <ComposerBanner.Icon>
14 <LoaderCircleIcon className="motion-safe:animate-spin" />
15 </ComposerBanner.Icon>
16 );
17}
18
19export function ComposerActivityRow({ status }: { readonly status: ComposerActivityStatus }) {
20 return (
21 <ComposerBanner.Row>
22 <ComposerActivityIcon status={status} />
23 <ComposerBanner.Content>
24 <ComposerActivityLabel status={status} />
25 </ComposerBanner.Content>
26 </ComposerBanner.Row>
27 );
28}
29
30export function ComposerActivityLabel({ status }: { readonly status: ComposerActivityStatus }) {
31 if (status.kind === "sync") {
32 return (
33 <span
34 className="shrink-0 whitespace-nowrap text-muted-foreground"
35 data-composer-sync-status={status.phase}
36 role="status"
37 >
38 {threadSyncLabel(status.phase)}
39 </span>
40 );
41 }
42 return (
43 <span
44 className="shrink-0 whitespace-nowrap text-muted-foreground"
45 data-composer-working-status="true"
46 >
47 {status.startedAt ? (
48 <>
49 Working for <WorkingTimer createdAt={status.startedAt} />
50 </>
51 ) : (
52 "Working…"
53 )}
54 </span>
55 );
56}
57
58/** Updates only the elapsed text, without committing the composer or timeline each second. */
59function WorkingTimer({ createdAt }: { createdAt: string }) {
60 const textRef = useRef<HTMLSpanElement>(null);
61 const initialText = formatWorkingTimerNow(createdAt);
62
63 useEffect(() => {
64 const updateText = () => {
65 if (textRef.current) {
66 textRef.current.textContent = formatWorkingTimerNow(createdAt);
67 }
68 };
69 updateText();
70 const id = setInterval(updateText, 1000);
71 return () => clearInterval(id);
72 }, [createdAt]);
73
74 return (
75 <span ref={textRef} className="tabular-nums">
76 {initialText}
77 </span>
78 );
79}
80
81function formatWorkingTimer(startIso: string, endIso: string): string | null {
82 const startedAtMs = Date.parse(startIso);
83 const endedAtMs = Date.parse(endIso);
84 if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) {
85 return null;
86 }
87
88 const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000));
89 if (elapsedSeconds < 60) {
90 return `${elapsedSeconds}s`;
91 }
92
93 const hours = Math.floor(elapsedSeconds / 3600);
94 const minutes = Math.floor((elapsedSeconds % 3600) / 60);
95 const seconds = elapsedSeconds % 60;
96
97 if (hours > 0) {
98 return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
99 }
100
101 return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
102}
103
104function formatWorkingTimerNow(startIso: string): string {
105 return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
106}
apps/web/src/components/chat/ComposerBanner.tsx created+330
......@@ -0,0 +1,330 @@
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("flex items-end gap-1 *:data-[composer-banner-width=fill]:flex-1", className)}
123 {...props}
124 />
125 );
126}
127
128function Root({
129 className,
130 placement = "attached",
131 variant = "default",
132 width = "fill",
133 ...props
134}: ComponentProps<"div"> & {
135 placement?: "attached" | "floating";
136 variant?: ComposerBannerVariant;
137 width?: "fill" | "content";
138}) {
139 return (
140 <Surface
141 className={cn(
142 "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)]",
143 width === "content" ? "w-fit max-w-full flex-none" : "@container",
144 className,
145 )}
146 data-slot="composer-banner"
147 placement={placement}
148 data-composer-banner-width={width}
149 variant={variant}
150 {...props}
151 />
152 );
153}
154
155/** The same row can be a status, a list item, or an entire disclosure button. */
156function Row({
157 className,
158 render,
159 layout = "inline",
160 ...props
161}: useRender.ComponentProps<"div"> & {
162 layout?: "inline" | "wrap-actions";
163}) {
164 const rowProps = {
165 className: cn(
166 "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",
167 "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]",
168 "[&: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",
169 layout === "wrap-actions" &&
170 "@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]",
171 className,
172 ),
173 "data-composer-banner-row": "true",
174 "data-composer-banner-layout": layout,
175 };
176 return useRender({
177 defaultTagName: "div",
178 render,
179 props: mergeProps<"div">(rowProps, props),
180 });
181}
182
183function Icon({ className, ...props }: ComponentProps<"span">) {
184 return (
185 <span
186 aria-hidden
187 data-slot="composer-banner-icon"
188 className={cn(
189 "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",
190 className,
191 )}
192 {...props}
193 />
194 );
195}
196
197function Content({ className, ...props }: ComponentProps<"span">) {
198 return (
199 <span
200 data-slot="composer-banner-content"
201 className={cn(
202 "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0",
203 "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",
204 "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",
205 className,
206 )}
207 {...props}
208 />
209 );
210}
211
212function Separator() {
213 return (
214 <span
215 aria-hidden
216 data-slot="composer-banner-separator"
217 className="mx-1 inline-block flex-none text-muted-foreground/40"
218 >
219 ·
220 </span>
221 );
222}
223
224function Actions({ className, ...props }: ComponentProps<"span">) {
225 return (
226 <span
227 data-slot="composer-banner-actions"
228 className={cn(
229 "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1",
230 className,
231 )}
232 {...props}
233 />
234 );
235}
236
237/** Child rows keep their parent's columns and begin immediately after its header. */
238function Children({ className, render, ...props }: useRender.ComponentProps<"div">) {
239 return useRender({
240 defaultTagName: "div",
241 render,
242 props: mergeProps<"div">(
243 { className: cn("grid gap-px [&_[data-composer-banner-row]]:min-h-5", className) },
244 props,
245 ),
246 });
247}
248
249/** Bounded banner content uses the app's scroll area and fades only overflowing edges. */
250function Scroll({ className, ...props }: ComponentProps<typeof ScrollArea>) {
251 return (
252 <ScrollArea
253 scrollFade
254 className={cn(
255 "h-auto max-h-[min(24rem,40dvh)] rounded-none [&>[data-slot=scroll-area-viewport][data-has-overflow-y]]:pe-2",
256 className,
257 )}
258 {...props}
259 />
260 );
261}
262
263function Count({ className, ...props }: ComponentProps<"span">) {
264 return (
265 <span
266 className={cn(
267 "inline-flex min-w-(--composer-banner-icon-column,1em) flex-none justify-center font-medium text-muted-foreground tabular-nums",
268 className,
269 )}
270 {...props}
271 />
272 );
273}
274
275function Body({ className, ...props }: ComponentProps<"div">) {
276 return (
277 <div
278 className={cn(
279 "min-w-0 ps-[calc(var(--composer-banner-icon-column)+(--spacing(1)))]",
280 className,
281 )}
282 {...props}
283 />
284 );
285}
286
287function Dot({ className, ...props }: ComponentProps<"span">) {
288 return (
289 <span className={cn("size-1.5 flex-none rounded-full bg-current", className)} {...props} />
290 );
291}
292
293function ToggleIcon({ expanded, className }: { expanded: boolean; className?: string }) {
294 return (
295 <span
296 aria-hidden
297 className={cn(buttonVariants({ size: "icon-xs", variant: "ghost" }), className)}
298 >
299 <ChevronDownIcon className={cn("size-3.5", !expanded && "rotate-180")} />
300 </span>
301 );
302}
303
304function Dismiss({ className, children, ...props }: ComponentProps<typeof Button>) {
305 return (
306 <Button size="icon-xs" variant="ghost" className={className} {...props}>
307 {children ?? <XIcon className="size-3.5" />}
308 </Button>
309 );
310}
311
312export const ComposerBanner = {
313 Surface,
314 Peek,
315 Attachment,
316 Dock,
317 Root,
318 Row,
319 Icon,
320 Content,
321 Separator,
322 Actions,
323 Children,
324 Scroll,
325 Count,
326 Body,
327 Dot,
328 ToggleIcon,
329 Dismiss,
330};
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+27-27
......@@ -3,6 +3,7 @@ import { memo } from "react";
33
44import { cn } from "~/lib/utils";
55import { Button } from "../ui/button";
6import { ComposerBanner } from "./ComposerBanner";
67
78/**
89 * Bookmark control that shows the stash count and opens the stash menu. It
......@@ -24,17 +25,16 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
2425 if (props.count === 0) return null;
2526 const inline = props.placement === "inline";
2627 const count = (
27 <span
28 <ComposerBanner.Count
2829 key={props.pulseKey}
2930 className={cn(
30 "text-[10px] font-medium leading-none tabular-nums",
3131 props.pulsing
3232 ? "animate-[prompt-stash-count-enter_180ms_ease-out_both] text-primary motion-reduce:animate-none"
3333 : "text-muted-foreground",
3434 )}
3535 >
3636 {props.count}
37 </span>
37 </ComposerBanner.Count>
3838 );
3939
4040 if (inline) {
......@@ -46,7 +46,7 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
4646 aria-label={`Stashed prompts: ${props.count}. Open stash.`}
4747 aria-expanded={props.menuOpen}
4848 className={cn(
49 "shrink-0 gap-1 px-1.5",
49 "shrink-0 gap-1 px-1.5 text-xs sm:text-xs",
5050 (props.menuOpen || props.pulsing) &&
5151 "[--control-icon-color:currentColor] text-foreground",
5252 )}
......@@ -60,28 +60,28 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
6060 }
6161
6262 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>
63 <ComposerBanner.Root width="content" data-composer-shoulder-tab className="ml-auto">
64 <ComposerBanner.Row
65 render={<button type="button" />}
66 data-prompt-stash-badge="true"
67 aria-label={`Stashed prompts: ${props.count}. Open stash.`}
68 aria-expanded={props.menuOpen}
69 className={cn(
70 "transition-colors 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) => event.preventDefault()}
77 onClick={props.onToggleMenu}
78 >
79 <ComposerBanner.Icon>
80 <BookmarkIcon />
81 </ComposerBanner.Icon>
82 <ComposerBanner.Content>Stash</ComposerBanner.Content>
83 <ComposerBanner.Actions>{count}</ComposerBanner.Actions>
84 </ComposerBanner.Row>
85 </ComposerBanner.Root>
8686 );
8787});
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+156-171
......@@ -1,9 +1,14 @@
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";
7import {
8 ComposerActivityIcon,
9 ComposerActivityLabel,
10 type ComposerActivityStatus,
11} from "./ComposerActivityStatus";
712
813export interface ComposerTasksProgress {
914 readonly step: string;
......@@ -56,205 +61,185 @@ function TaskSegments({
5661 );
5762}
5863
59export const ComposerTasksBadge = memo(function ComposerTasksBadge({
64function TaskSummary({
6065 expanded,
61 hasTrailingShoulder = false,
62 onDismiss,
63 onToggle,
64 placement = "tab",
6566 progress,
6667 steps,
68 activityStatus,
6769}: {
6870 readonly expanded: boolean;
69 readonly hasTrailingShoulder?: boolean;
70 readonly onDismiss: () => void;
71 readonly onToggle: () => void;
72 readonly placement?: "inline" | "tab";
7371 readonly progress: ComposerTasksProgress;
7472 readonly steps: readonly ComposerTaskStep[];
73 readonly activityStatus: ComposerActivityStatus | undefined;
7574}) {
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
11875 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",
76 <>
77 {activityStatus !== undefined ? (
78 <ComposerActivityIcon status={activityStatus} />
79 ) : (
80 <ComposerBanner.Icon>
81 <ListTodoIcon />
82 </ComposerBanner.Icon>
12483 )}
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>
84 <ComposerBanner.Content>
85 {activityStatus !== undefined ? (
86 <ComposerActivityLabel status={activityStatus} />
87 ) : (
88 <span className="shrink-0 text-muted-foreground">Tasks</span>
89 )}
90 {activityStatus !== undefined ? <ComposerBanner.Separator /> : null}
13791 <span
13892 className="min-w-0 flex-1 truncate text-left font-medium text-foreground/80"
13993 data-composer-task-current="true"
14094 >
14195 {progress.step}
14296 </span>
143 <span
144 className={cn(
145 "shrink-0 font-medium tabular-nums",
146 allDone ? "text-success" : "text-muted-foreground",
147 )}
97 </ComposerBanner.Content>
98 <ComposerBanner.Actions>
99 <ComposerBanner.Count
100 className={progress.completedSteps >= progress.totalSteps ? "text-success" : undefined}
101 data-composer-task-progress="true"
148102 >
149103 {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>
104 </ComposerBanner.Count>
105 <TaskSegments className="hidden w-20 sm:flex" steps={steps} />
106 <ComposerBanner.ToggleIcon expanded={expanded} />
107 </ComposerBanner.Actions>
108 </>
109 );
110}
111
112export const ComposerTasksBadge = memo(function ComposerTasksBadge({
113 expanded,
114 onToggle,
115 placement = "tab",
116 progress,
117 steps,
118 activityStatus,
119}: {
120 readonly expanded: boolean;
121 readonly onToggle: () => void;
122 readonly placement?: "inline" | "tab";
123 readonly progress: ComposerTasksProgress;
124 readonly steps: readonly ComposerTaskStep[];
125 readonly activityStatus?: ComposerActivityStatus | undefined;
126}) {
127 if (progress.totalSteps <= 0) return null;
128
129 const row = (
130 <ComposerBanner.Row
131 render={<button type="button" />}
132 aria-expanded={expanded}
133 aria-label={`${expanded ? "Collapse tasks" : "Tasks"}: ${progress.completedSteps} of ${progress.totalSteps} complete. Current task: ${progress.step}`}
134 data-composer-tasks-badge="true"
135 onClick={onToggle}
136 onPointerDown={(event) => event.preventDefault()}
137 >
138 <TaskSummary
139 expanded={expanded}
140 progress={progress}
141 steps={steps}
142 activityStatus={activityStatus}
143 />
144 </ComposerBanner.Row>
145 );
146 return placement === "inline" ? (
147 row
148 ) : (
149 <ComposerBanner.Root data-composer-shoulder-tab>{row}</ComposerBanner.Root>
164150 );
165151});
166152
167export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
168 onDismiss,
169 onCollapse,
153export const ComposerTasksContent = memo(function ComposerTasksContent({
154 expanded,
155 onToggle,
170156 progress,
171157 steps,
158 activityStatus,
172159}: {
173 readonly onDismiss: () => void;
174 readonly onCollapse: () => void;
160 readonly expanded: boolean;
161 readonly onToggle: () => void;
175162 readonly progress: ComposerTasksProgress;
176163 readonly steps: readonly ComposerTaskStep[];
164 readonly activityStatus?: ComposerActivityStatus | undefined;
177165}) {
178166 return (
179167 <div
180 className="chat-composer-top-drawer"
181168 data-chat-composer-collapsed-controls="true"
182 data-chat-composer-tasks-drawer="true"
169 data-chat-composer-tasks-drawer={expanded ? "true" : undefined}
183170 >
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>
171 <ComposerTasksBadge
172 expanded={expanded}
173 onToggle={onToggle}
174 placement="inline"
175 progress={progress}
176 steps={steps}
177 activityStatus={activityStatus}
178 />
179 {expanded ? (
180 <ComposerBanner.Scroll data-composer-tasks-scroll="true">
181 <ComposerBanner.Children
182 render={<ul />}
183 aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`}
184 data-composer-tasks-list="true"
185 >
186 {keyedTaskSteps(steps).map(({ key, step }) => (
187 <ComposerBanner.Row key={key} render={<li />}>
188 <ComposerBanner.Icon
189 className={cn(
190 "font-mono text-[10px]",
191 step.status === "completed"
192 ? "text-success"
193 : step.status === "inProgress"
194 ? "text-primary"
195 : "text-muted-foreground/40",
196 )}
197 >
198 {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}
199 </ComposerBanner.Icon>
200 <ComposerBanner.Content
201 className={cn(
202 step.status === "completed"
203 ? "text-muted-foreground/55"
204 : step.status === "inProgress"
205 ? "text-foreground/90"
206 : "text-muted-foreground/70",
207 )}
208 >
209 {step.step}
210 </ComposerBanner.Content>
211 <ComposerBanner.Actions>
212 <span
213 className="w-10 text-right text-[10px] text-muted-foreground/45 tabular-nums"
214 data-composer-task-duration="true"
215 >
216 {step.durationMs !== undefined
217 ? formatDuration(step.durationMs)
218 : step.status === "inProgress"
219 ? "now"
220 : null}
221 </span>
222 </ComposerBanner.Actions>
223 </ComposerBanner.Row>
224 ))}
225 </ComposerBanner.Children>
226 </ComposerBanner.Scroll>
227 ) : null}
258228 </div>
259229 );
260230});
231
232export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
233 onCollapse,
234 ...props
235}: Omit<ComponentProps<typeof ComposerTasksContent>, "expanded" | "onToggle"> & {
236 readonly onCollapse: () => void;
237}) {
238 return (
239 <ComposerBanner.Attachment>
240 <ComposerBanner.Root>
241 <ComposerTasksContent {...props} expanded onToggle={onCollapse} />
242 </ComposerBanner.Root>
243 </ComposerBanner.Attachment>
244 );
245});
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+22-39
......@@ -321,7 +321,6 @@ describe("deriveMessagesTimelineRows", () => {
321321 ],
322322 expandedTurnIds: new Set(["turn-1" as never]),
323323 isWorking: false,
324 activeTurnStartedAt: null,
325324 turnDiffSummaryByAssistantMessageId: new Map(),
326325 revertTurnCountByUserMessageId: new Map(),
327326 });
......@@ -375,7 +374,6 @@ describe("deriveMessagesTimelineRows", () => {
375374 completedAt: null,
376375 },
377376 isWorking: false,
378 activeTurnStartedAt: null,
379377 turnDiffSummaryByAssistantMessageId: new Map(),
380378 revertTurnCountByUserMessageId: new Map(),
381379 });
......@@ -432,7 +430,6 @@ describe("deriveMessagesTimelineRows", () => {
432430 },
433431 ],
434432 isWorking: false,
435 activeTurnStartedAt: null,
436433 turnDiffSummaryByAssistantMessageId: new Map([
437434 ["assistant-1" as never, assistantTurnDiffSummary],
438435 ]),
......@@ -513,7 +510,6 @@ describe("deriveMessagesTimelineRows", () => {
513510 const collapsedRows = deriveMessagesTimelineRows({
514511 timelineEntries,
515512 isWorking: false,
516 activeTurnStartedAt: null,
517513 turnDiffSummaryByAssistantMessageId: new Map(),
518514 revertTurnCountByUserMessageId: new Map(),
519515 });
......@@ -537,7 +533,6 @@ describe("deriveMessagesTimelineRows", () => {
537533 timelineEntries,
538534 expandedTurnIds: new Set(["turn-1" as never]),
539535 isWorking: false,
540 activeTurnStartedAt: null,
541536 turnDiffSummaryByAssistantMessageId: new Map(),
542537 revertTurnCountByUserMessageId: new Map(),
543538 });
......@@ -603,7 +598,6 @@ describe("deriveMessagesTimelineRows", () => {
603598 const rows = deriveMessagesTimelineRows({
604599 timelineEntries,
605600 isWorking: false,
606 activeTurnStartedAt: null,
607601 turnDiffSummaryByAssistantMessageId: new Map(),
608602 revertTurnCountByUserMessageId: new Map(),
609603 });
......@@ -698,7 +692,6 @@ describe("deriveMessagesTimelineRows", () => {
698692 completedAt: null,
699693 },
700694 isWorking: true,
701 activeTurnStartedAt: "2026-01-01T00:00:14Z",
702695 turnDiffSummaryByAssistantMessageId: new Map(),
703696 revertTurnCountByUserMessageId: new Map(),
704697 });
......@@ -735,7 +728,6 @@ describe("deriveMessagesTimelineRows", () => {
735728 completedAt: "2026-01-01T00:00:47Z",
736729 },
737730 isWorking: false,
738 activeTurnStartedAt: null,
739731 turnDiffSummaryByAssistantMessageId: new Map(),
740732 revertTurnCountByUserMessageId: new Map(),
741733 });
......@@ -803,7 +795,6 @@ describe("deriveMessagesTimelineRows", () => {
803795 completedAt: "2026-01-01T00:00:22Z",
804796 },
805797 isWorking: true,
806 activeTurnStartedAt: "2026-01-01T00:01:00Z",
807798 turnDiffSummaryByAssistantMessageId: new Map(),
808799 revertTurnCountByUserMessageId: new Map(),
809800 });
......@@ -812,7 +803,6 @@ describe("deriveMessagesTimelineRows", () => {
812803 "turn-fold:turn-1",
813804 "assistant-final-entry",
814805 "user-followup-entry",
815 "working-indicator-row",
816806 ]);
817807 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
818808 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
......@@ -855,14 +845,12 @@ describe("deriveMessagesTimelineRows", () => {
855845 completedAt: null,
856846 },
857847 isWorking: true,
858 activeTurnStartedAt: "2026-01-01T00:00:00Z",
859848 turnDiffSummaryByAssistantMessageId: new Map(),
860849 revertTurnCountByUserMessageId: new Map(),
861850 });
862851
863852 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
864853 expect(rows.map((row) => row.id)).toEqual([
865 "working-indicator-row",
866854 "assistant-thought-entry",
867855 "work-live:work-entry-1",
868856 ]);
......@@ -924,12 +912,11 @@ describe("deriveMessagesTimelineRows", () => {
924912 completedAt: null,
925913 },
926914 isWorking: true,
927 activeTurnStartedAt: "2026-01-01T00:00:00Z",
928915 turnDiffSummaryByAssistantMessageId: new Map(),
929916 revertTurnCountByUserMessageId: new Map(),
930917 });
931918
932 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
919 expect(rows.map((row) => row.kind)).toEqual(["work-live"]);
933920 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
934921 entry: { id: "running-command" },
935922 groupedEntries: [
......@@ -995,12 +982,11 @@ describe("deriveMessagesTimelineRows", () => {
995982 completedAt: null,
996983 },
997984 isWorking: true,
998 activeTurnStartedAt: "2026-01-01T00:00:00Z",
999985 turnDiffSummaryByAssistantMessageId: new Map(),
1000986 revertTurnCountByUserMessageId: new Map(),
1001987 });
1002988
1003 expect(rows.map((row) => row.kind)).toEqual(["working", "work-toggle", "message", "work-live"]);
989 expect(rows.map((row) => row.kind)).toEqual(["work-toggle", "message", "work-live"]);
1004990 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
1005991 hiddenCount: 1,
1006992 summary: "Ran 1 command",
......@@ -1062,12 +1048,11 @@ describe("deriveMessagesTimelineRows", () => {
10621048 completedAt: null,
10631049 },
10641050 isWorking: true,
1065 activeTurnStartedAt: "2026-01-01T00:00:00Z",
10661051 turnDiffSummaryByAssistantMessageId: new Map(),
10671052 revertTurnCountByUserMessageId: new Map(),
10681053 });
10691054
1070 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]);
1055 expect(rows.map((row) => row.kind)).toEqual(["work-live", "message", "work-live"]);
10711056 expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([
10721057 "first-running",
10731058 "second-running",
......@@ -1109,7 +1094,6 @@ describe("deriveMessagesTimelineRows", () => {
11091094 ],
11101095 latestTurn: null,
11111096 isWorking: true,
1112 activeTurnStartedAt: "2026-01-01T00:01:00Z",
11131097 turnDiffSummaryByAssistantMessageId: new Map(),
11141098 revertTurnCountByUserMessageId: new Map(),
11151099 });
......@@ -1170,7 +1154,6 @@ describe("deriveMessagesTimelineRows", () => {
11701154 completedAt: null,
11711155 },
11721156 isWorking: true,
1173 activeTurnStartedAt: "2026-01-01T00:00:00Z",
11741157 turnDiffSummaryByAssistantMessageId: new Map(),
11751158 revertTurnCountByUserMessageId: new Map(),
11761159 });
......@@ -1206,12 +1189,11 @@ describe("deriveMessagesTimelineRows", () => {
12061189 completedAt: null,
12071190 },
12081191 isWorking: true,
1209 activeTurnStartedAt: "2026-01-01T00:00:00Z",
12101192 turnDiffSummaryByAssistantMessageId: new Map(),
12111193 revertTurnCountByUserMessageId: new Map(),
12121194 });
12131195
1214 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
1196 expect(rows.map((row) => row.kind)).toEqual(["work-live"]);
12151197 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
12161198 entry: { id: "latest-command" },
12171199 groupedEntries: [{ id: "latest-command" }],
......@@ -1268,7 +1250,6 @@ describe("deriveMessagesTimelineRows", () => {
12681250 },
12691251 runningTurnId: "turn-2" as never,
12701252 isWorking: true,
1271 activeTurnStartedAt: "2026-01-01T00:01:00Z",
12721253 turnDiffSummaryByAssistantMessageId: new Map(),
12731254 revertTurnCountByUserMessageId: new Map(),
12741255 });
......@@ -1313,7 +1294,6 @@ describe("deriveMessagesTimelineRows", () => {
13131294 ],
13141295 expandedTurnIds: new Set(["turn-1" as never]),
13151296 isWorking: false,
1316 activeTurnStartedAt: null,
13171297 turnDiffSummaryByAssistantMessageId: new Map(),
13181298 revertTurnCountByUserMessageId: new Map(),
13191299 });
......@@ -1351,7 +1331,6 @@ describe("deriveMessagesTimelineRows", () => {
13511331 completedAt: null,
13521332 },
13531333 isWorking: true,
1354 activeTurnStartedAt: "2026-01-01T00:00:00Z",
13551334 turnDiffSummaryByAssistantMessageId: new Map(),
13561335 revertTurnCountByUserMessageId: new Map(),
13571336 });
......@@ -1365,7 +1344,10 @@ describe("deriveMessagesTimelineRows", () => {
13651344 expect(assistantRow?.showAssistantCopyButton).toBe(false);
13661345 });
13671346
1368 it("models work log overflow expansion as inserted list rows", () => {
1347 it.each([
1348 ["tools", "tool", "Used 3 tools"],
1349 ["tools and status updates", "info", "Used 2 tools and received 1 update"],
1350 ] as const)("expands %s through the same activity group", (_, middleTone, summary) => {
13691351 const timelineEntries = [
13701352 {
13711353 id: "work-entry-1",
......@@ -1386,9 +1368,9 @@ describe("deriveMessagesTimelineRows", () => {
13861368 entry: {
13871369 id: "work-2",
13881370 createdAt: "2026-01-01T00:00:02Z",
1389 label: "edit",
1371 label: "Status updated",
13901372 detail: "Editing MessagesTimeline.tsx",
1391 tone: "tool" as const,
1373 tone: middleTone,
13921374 },
13931375 },
13941376 {
......@@ -1408,7 +1390,6 @@ describe("deriveMessagesTimelineRows", () => {
14081390 const baseInput = {
14091391 timelineEntries,
14101392 isWorking: false,
1411 activeTurnStartedAt: null,
14121393 turnDiffSummaryByAssistantMessageId: new Map(),
14131394 revertTurnCountByUserMessageId: new Map(),
14141395 };
......@@ -1423,8 +1404,7 @@ describe("deriveMessagesTimelineRows", () => {
14231404 groupId: "work-group:work-entry-1",
14241405 hiddenCount: 3,
14251406 expanded: false,
1426 onlyToolEntries: true,
1427 summary: "Used 3 tools",
1407 summary,
14281408 });
14291409 expect(expandedRows.map((row) => row.id)).toEqual([
14301410 "work-toggle:work-entry-1",
......@@ -1459,7 +1439,6 @@ describe("deriveMessagesTimelineRows", () => {
14591439 const rows = deriveMessagesTimelineRows({
14601440 timelineEntries,
14611441 isWorking: false,
1462 activeTurnStartedAt: null,
14631442 turnDiffSummaryByAssistantMessageId: new Map(),
14641443 revertTurnCountByUserMessageId: new Map(),
14651444 });
......@@ -1476,7 +1455,7 @@ describe("deriveMessagesTimelineRows", () => {
14761455 ["an error-toned entry recovers", ["error", "info", "completed"], false],
14771456 ["the final failure is hidden", ["completed", "failed", "info"], true],
14781457 ["the final failure is visible", ["failed", "info", "failed"], true],
1479 ["the only failure is visible", ["completed", "info", "failed"], false],
1458 ["the only failure is visible", ["completed", "info", "failed"], true],
14801459 ] as const)(
14811460 "uses the final tool call for mixed work groups when %s",
14821461 (_, statuses, hasFailure) => {
......@@ -1506,16 +1485,23 @@ describe("deriveMessagesTimelineRows", () => {
15061485 const rows = deriveMessagesTimelineRows({
15071486 timelineEntries,
15081487 isWorking: false,
1509 activeTurnStartedAt: null,
15101488 turnDiffSummaryByAssistantMessageId: new Map(),
15111489 revertTurnCountByUserMessageId: new Map(),
15121490 });
15131491
15141492 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
1515 hiddenCount: 2,
1516 summary: null,
1493 hiddenCount: statuses.some((status) => status === "error") ? 2 : 3,
1494 summary: statuses.some((status) => status === "error")
1495 ? "Received 1 update and used 1 tool"
1496 : "Used 2 tools and received 1 update",
15171497 hasFailure,
15181498 });
1499 if (statuses.some((status) => status === "error")) {
1500 expect(rows[0]).toMatchObject({
1501 kind: "work",
1502 groupedEntries: [{ tone: "error", label: "Command failed" }],
1503 });
1504 }
15191505 },
15201506 );
15211507});
......@@ -1557,7 +1543,6 @@ describe("computeStableMessagesTimelineRows", () => {
15571543 },
15581544 ],
15591545 isWorking: false,
1560 activeTurnStartedAt: null,
15611546 turnDiffSummaryByAssistantMessageId: new Map(),
15621547 revertTurnCountByUserMessageId: new Map(),
15631548 });
......@@ -1606,7 +1591,6 @@ describe("computeStableMessagesTimelineRows", () => {
16061591 },
16071592 ],
16081593 isWorking: false,
1609 activeTurnStartedAt: null,
16101594 turnDiffSummaryByAssistantMessageId: new Map(),
16111595 revertTurnCountByUserMessageId: new Map(),
16121596 });
......@@ -1662,7 +1646,6 @@ describe("computeStableMessagesTimelineRows", () => {
16621646 },
16631647 ],
16641648 isWorking: false,
1665 activeTurnStartedAt: null,
16661649 turnDiffSummaryByAssistantMessageId: new Map(),
16671650 revertTurnCountByUserMessageId: new Map(),
16681651 });
apps/web/src/components/chat/MessagesTimeline.logic.ts+32-127
......@@ -11,7 +11,6 @@ import {
1111import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
1212import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
1313
14export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
1514export const TIMELINE_MINIMAP_ITEM_SPACING = 8;
1615export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
1716export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
......@@ -202,9 +201,8 @@ export type MessagesTimelineRow =
202201 groupId: string;
203202 hiddenCount: number;
204203 expanded: boolean;
205 onlyToolEntries: boolean;
206 summary: string | null;
207 summaryKind: ToolGroupSummaryKind | null;
204 summary: string;
205 summaryKind: ToolGroupSummaryKind;
208206 hasFailure: boolean;
209207 }
210208 | {
......@@ -238,12 +236,6 @@ export type MessagesTimelineRow =
238236 id: string;
239237 createdAt: string;
240238 turnPlan: TurnPlanEntry;
241 }
242 | {
243 kind: "working";
244 id: string;
245 createdAt: string | null;
246 showThinking: boolean;
247239 };
248240
249241export interface StableMessagesTimelineRowsState {
......@@ -274,7 +266,7 @@ export function normalizeCompactToolLabel(value: string): string {
274266 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
275267}
276268
277type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other";
269type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update";
278270type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed";
279271
280272export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {
......@@ -304,7 +296,7 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
304296 }
305297 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
306298 if (entry.itemType === "web_search") return "search";
307 return "other";
299 return workLogEntryIsToolLike(entry) ? "other" : "update";
308300}
309301
310302function toolGroupActionCount(
......@@ -339,6 +331,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
339331 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
340332 case "other":
341333 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
334 case "update":
335 return `Received ${count} ${count === 1 ? "update" : "updates"}`;
342336 }
343337}
344338
......@@ -664,7 +658,6 @@ export function deriveMessagesTimelineRows(input: {
664658 expandedTurnIds?: ReadonlySet<TurnId>;
665659 expandedWorkGroupIds?: ReadonlySet<string>;
666660 isWorking: boolean;
667 activeTurnStartedAt: string | null;
668661 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
669662 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;
670663}): MessagesTimelineRow[] {
......@@ -714,26 +707,6 @@ export function deriveMessagesTimelineRows(input: {
714707 unsettledTurnId !== null &&
715708 entry.toolLifecycleStatus === "inProgress" &&
716709 entry.turnId === unsettledTurnId;
717 const isVisibleActiveToolEntry = (entry: WorkLogEntry) =>
718 workLogEntryIsToolLike(entry) && workEntryIsVisibleInGroup(entry, true);
719 const activeEntries = input.isWorking
720 ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
721 : [];
722 const activeTurnHasVisibleContent = activeEntries.some((entry) => {
723 if (entry.kind === "message") {
724 return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0;
725 }
726 if (entry.kind === "work") {
727 return (
728 entry.entry.agentSpawn === undefined &&
729 workLogEntryIsToolLike(entry.entry) &&
730 entry.entry.toolLifecycleStatus === "inProgress"
731 );
732 }
733 if (entry.kind === "proposed-plan" || entry.kind === "turn-plan") return true;
734 return false;
735 });
736
737710 const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = [];
738711 for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {
739712 const entry = input.timelineEntries[index]!;
......@@ -741,8 +714,7 @@ export function deriveMessagesTimelineRows(input: {
741714 !entryBelongsToActiveTurn(entry, index) ||
742715 entry.kind !== "work" ||
743716 entry.entry.agentSpawn !== undefined ||
744 entry.entry.tone === "error" ||
745 !workLogEntryIsToolLike(entry.entry)
717 entry.entry.tone === "error"
746718 ) {
747719 break;
748720 }
......@@ -750,7 +722,7 @@ export function deriveMessagesTimelineRows(input: {
750722 }
751723 const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
752724 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
753 activeToolEntries.filter((entry) => isVisibleActiveToolEntry(entry.entry)),
725 activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),
754726 (entry) => entry.entry,
755727 );
756728 const activeWorkAnchor = activeToolEntries[0];
......@@ -771,14 +743,6 @@ export function deriveMessagesTimelineRows(input: {
771743 };
772744 })()
773745 : null;
774 const appendWorkingRow = () => {
775 nextRows.push({
776 kind: "working",
777 id: "working-indicator-row",
778 createdAt: input.activeTurnStartedAt,
779 showThinking: activeWorkRow === null && !activeTurnHasVisibleContent,
780 });
781 };
782746 const appendActiveWorkRows = () => {
783747 if (activeWorkRow === null) return;
784748 nextRows.push(activeWorkRow);
......@@ -801,10 +765,6 @@ export function deriveMessagesTimelineRows(input: {
801765 continue;
802766 }
803767
804 if (input.isWorking && index === activeTurnHeaderIndex) {
805 appendWorkingRow();
806 }
807
808768 if (timelineEntry.id === activeWorkPlacementEntryId) {
809769 appendActiveWorkRows();
810770 }
......@@ -830,6 +790,17 @@ export function deriveMessagesTimelineRows(input: {
830790 }
831791
832792 if (timelineEntry.kind === "work") {
793 if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") {
794 nextRows.push({
795 kind: "work",
796 id: timelineEntry.id,
797 createdAt: timelineEntry.createdAt,
798 groupedEntries: [timelineEntry.entry],
799 isExpandedToolGroupEntry: false,
800 isLastExpandedToolGroupEntry: false,
801 });
802 continue;
803 }
833804 const groupedEntries = [timelineEntry.entry];
834805 let cursor = index + 1;
835806 while (cursor < input.timelineEntries.length) {
......@@ -837,6 +808,8 @@ export function deriveMessagesTimelineRows(input: {
837808 if (
838809 !nextEntry ||
839810 nextEntry.kind !== "work" ||
811 nextEntry.entry.agentSpawn !== undefined ||
812 nextEntry.entry.tone === "error" ||
840813 activeWorkEntryIds.has(nextEntry.id) ||
841814 collapsedEntryIds.has(nextEntry.id) ||
842815 foldsByAnchorEntryId.has(nextEntry.id)
......@@ -853,14 +826,8 @@ export function deriveMessagesTimelineRows(input: {
853826 (entry) => entry,
854827 );
855828 if (visibleGroupedEntries.length > 0) {
856 const onlyToolEntries = visibleGroupedEntries.every(
857 (entry) =>
858 workLogEntryIsToolLike(entry) &&
859 entry.agentSpawn === undefined &&
860 entry.tone !== "error",
861 );
862829 const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);
863 if (onlyToolEntries && activeInProgressToolEntries.length > 0) {
830 if (activeInProgressToolEntries.length > 0) {
864831 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
865832 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
866833 const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;
......@@ -885,10 +852,11 @@ export function deriveMessagesTimelineRows(input: {
885852 });
886853 }
887854 }
888 } else if (onlyToolEntries) {
855 } else {
889856 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
890857 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
891858 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
859 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
892860 nextRows.push({
893861 kind: "work-toggle",
894862 id: `work-toggle:${timelineEntry.id}`,
......@@ -896,10 +864,15 @@ export function deriveMessagesTimelineRows(input: {
896864 groupId,
897865 hiddenCount: visibleGroupedEntries.length,
898866 expanded,
899 onlyToolEntries: true,
900 summary: summarizeToolGroup(visibleGroupedEntries),
867 summary:
868 visibleGroupedEntries.length === 1 &&
869 !workLogEntryIsToolLike(visibleGroupedEntries[0]!)
870 ? visibleGroupedEntries[0]!.label
871 : summarizeToolGroup(visibleGroupedEntries),
901872 summaryKind,
902 hasFailure: workEntryDisplayIndicatesToolFailure(visibleGroupedEntries.at(-1)!),
873 hasFailure:
874 latestToolEntry !== undefined &&
875 workEntryDisplayIndicatesToolFailure(latestToolEntry),
903876 });
904877 if (expanded) {
905878 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
......@@ -913,64 +886,6 @@ export function deriveMessagesTimelineRows(input: {
913886 });
914887 }
915888 }
916 } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
917 nextRows.push({
918 kind: "work",
919 id: timelineEntry.id,
920 createdAt: timelineEntry.createdAt,
921 groupedEntries: visibleGroupedEntries,
922 isExpandedToolGroupEntry: false,
923 isLastExpandedToolGroupEntry: false,
924 });
925 } else {
926 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
927 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
928 // Agent-spawn CTA rows are always visible: a running fleet must
929 // never hide behind a "+N tool calls" toggle. Selection is by
930 // membership (spawn OR recent-tail), preserving the group's
931 // chronological order in both collapsed and expanded states
932 // (review finding: concatenating two filtered lists moved a
933 // mid-group spawn row above earlier tool rows).
934 const overflowCandidates = visibleGroupedEntries.filter(
935 (entry) => entry.agentSpawn === undefined,
936 );
937 const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);
938 const hiddenIds = new Set(hiddenEntries.map((entry) => entry.id));
939 const visibleEntries = visibleGroupedEntries.filter(
940 (entry) => entry.agentSpawn !== undefined || !hiddenIds.has(entry.id),
941 );
942 const renderedEntries = expanded ? visibleGroupedEntries : visibleEntries;
943
944 for (const workEntry of renderedEntries) {
945 nextRows.push({
946 kind: "work",
947 id: workEntry.id,
948 createdAt: workEntry.createdAt,
949 groupedEntries: [workEntry],
950 isExpandedToolGroupEntry: false,
951 isLastExpandedToolGroupEntry: false,
952 });
953 }
954
955 if (hiddenEntries.length > 0) {
956 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
957
958 nextRows.push({
959 kind: "work-toggle",
960 id: `work-toggle:${timelineEntry.id}`,
961 createdAt: timelineEntry.createdAt,
962 groupId,
963 hiddenCount: hiddenEntries.length,
964 expanded,
965 onlyToolEntries: hiddenEntries.every(workLogEntryIsToolLike),
966 summary: null,
967 summaryKind: null,
968 hasFailure:
969 latestToolEntry !== undefined &&
970 workEntryDisplayIndicatesToolFailure(latestToolEntry) &&
971 hiddenEntries.some(workEntryDisplayIndicatesToolFailure),
972 });
973 }
974889 }
975890 }
976891 index = cursor - 1;
......@@ -1033,10 +948,6 @@ export function deriveMessagesTimelineRows(input: {
1033948 });
1034949 }
1035950
1036 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
1037 appendWorkingRow();
1038 }
1039
1040951 return nextRows;
1041952}
1042953
......@@ -1065,11 +976,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1065976 if (a.kind !== b.kind || a.id !== b.id) return false;
1066977
1067978 switch (a.kind) {
1068 case "working":
1069 return (
1070 a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking
1071 );
1072
1073979 case "turn-fold": {
1074980 const bf = b as typeof a;
1075981 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;
......@@ -1112,7 +1018,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
11121018 a.groupId === bw.groupId &&
11131019 a.hiddenCount === bw.hiddenCount &&
11141020 a.expanded === bw.expanded &&
1115 a.onlyToolEntries === bw.onlyToolEntries &&
11161021 a.summary === bw.summary &&
11171022 a.summaryKind === bw.summaryKind &&
11181023 a.hasFailure === bw.hasFailure
apps/web/src/components/chat/MessagesTimeline.test.tsx+3-26
......@@ -177,7 +177,6 @@ const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
177177function buildProps() {
178178 return {
179179 isWorking: false,
180 activeTurnStartedAt: null,
181180 listRef: createRef<LegendListRef | null>(),
182181 latestTurn: null,
183182 runningTurnId: null,
......@@ -675,7 +674,6 @@ describe("MessagesTimeline", () => {
675674 <MessagesTimeline
676675 {...buildProps()}
677676 isWorking
678 activeTurnStartedAt={MESSAGE_CREATED_AT}
679677 latestTurn={{
680678 turnId,
681679 state: "running",
......@@ -1015,7 +1013,6 @@ describe("MessagesTimeline", () => {
10151013 );
10161014
10171015 expect(markup).toContain("Context compacted");
1018 expect(markup).toContain("Work Log");
10191016 });
10201017
10211018 it("summarizes changed files in one line", () => {
......@@ -1172,7 +1169,7 @@ describe("MessagesTimeline", () => {
11721169 />,
11731170 );
11741171
1175 expect(markup).toContain("+2 previous log entries");
1172 expect(markup).toContain("Ran 2 commands and received 1 update");
11761173 expect(markup).not.toContain('aria-label="Hidden work includes a failure"');
11771174 });
11781175
......@@ -1182,7 +1179,6 @@ describe("MessagesTimeline", () => {
11821179 <MessagesTimeline
11831180 {...buildProps()}
11841181 isWorking
1185 activeTurnStartedAt={MESSAGE_CREATED_AT}
11861182 latestTurn={{
11871183 turnId,
11881184 state: "running",
......@@ -1211,7 +1207,6 @@ describe("MessagesTimeline", () => {
12111207 />,
12121208 );
12131209
1214 expect(markup).toContain("Working for");
12151210 expect(markup).toContain("Running pnpm");
12161211 expect(markup).toContain("live-activity-focus");
12171212 });
......@@ -1222,7 +1217,6 @@ describe("MessagesTimeline", () => {
12221217 <MessagesTimeline
12231218 {...buildProps()}
12241219 isWorking
1225 activeTurnStartedAt={MESSAGE_CREATED_AT}
12261220 latestTurn={{
12271221 turnId,
12281222 state: "running",
......@@ -1277,7 +1271,6 @@ describe("MessagesTimeline", () => {
12771271 <MessagesTimeline
12781272 {...buildProps()}
12791273 isWorking
1280 activeTurnStartedAt={MESSAGE_CREATED_AT}
12811274 latestTurn={{
12821275 turnId,
12831276 state: "running",
......@@ -1310,21 +1303,6 @@ describe("MessagesTimeline", () => {
13101303 expect(markup).toContain("tool call failed");
13111304 });
13121305
1313 it("aligns the iconless Thinking row with the working timer", () => {
1314 const markup = renderToStaticMarkup(
1315 <MessagesTimeline
1316 {...buildProps()}
1317 isWorking
1318 activeTurnStartedAt={MESSAGE_CREATED_AT}
1319 timelineEntries={[]}
1320 />,
1321 );
1322
1323 expect(markup).toContain("Working for");
1324 expect(markup).toContain("Thinking");
1325 expect(markup).toContain("gap-1.5 py-0.5 px-1");
1326 });
1327
13281306 it("renders review comment contexts as structured cards instead of raw tags", () => {
13291307 const markup = renderToStaticMarkup(
13301308 <MessagesTimeline
......@@ -1402,7 +1380,7 @@ describe("MessagesTimeline", () => {
14021380 expect(markup).not.toContain('data-testid="file-diff"');
14031381 });
14041382
1405 it("renders a muted failure marker for failed tool lifecycle entries", () => {
1383 it("keeps failed lifecycle entries discoverable in mixed activity summaries", () => {
14061384 const markup = renderToStaticMarkup(
14071385 <MessagesTimeline
14081386 {...buildProps()}
......@@ -1435,8 +1413,7 @@ describe("MessagesTimeline", () => {
14351413 />,
14361414 );
14371415
1438 expect(markup).toContain("lucide-x");
1439 expect(markup).toContain('aria-label="Tool call failed"');
1416 expect(markup).toContain('aria-label="Received 1 update and used 1 tool, tool call failed"');
14401417 // Ordinary tool failures render muted, not red.
14411418 expect(markup).not.toContain("text-destructive");
14421419 });
apps/web/src/components/chat/MessagesTimeline.tsx+22-164
......@@ -138,7 +138,7 @@ import {
138138// Context — shared state consumed by every row component via Context.
139139// Propagates through LegendList's memo boundaries for shared callbacks and
140140// non-row-scoped state. `nowIso` is intentionally excluded — self-ticking
141// components (WorkingTimer, LiveElapsed) handle it.
141// components (LiveElapsed) handle it.
142142// ---------------------------------------------------------------------------
143143
144144interface TimelineRowSharedState {
......@@ -164,8 +164,6 @@ interface TimelineRowActivityState {
164164 isWorking: boolean;
165165 isRevertingCheckpoint: boolean;
166166 latestTurnId: TurnId | null;
167 /** Current plan step label for the working row, when the turn has a plan. */
168 workingStepLabel: string | null;
169167}
170168
171169const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
......@@ -218,8 +216,6 @@ interface MessagesTimelineProps {
218216 agentPanelModel?: AgentPanelModel;
219217 onOpenAgents?: () => void;
220218 isWorking: boolean;
221 workingStepLabel?: string | null;
222 activeTurnStartedAt: string | null;
223219 listRef: React.RefObject<LegendListRef | null>;
224220 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
225221 latestTurn: TimelineLatestTurn | null;
......@@ -262,8 +258,6 @@ interface MessagesTimelineProps {
262258
263259export const MessagesTimeline = memo(function MessagesTimeline({
264260 isWorking,
265 workingStepLabel = null,
266 activeTurnStartedAt,
267261 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
268262 onOpenAgents = NOOP_OPEN_AGENTS,
269263 listRef,
......@@ -426,7 +420,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
426420 expandedTurnIds,
427421 expandedWorkGroupIds,
428422 isWorking,
429 activeTurnStartedAt,
430423 turnDiffSummaryByAssistantMessageId,
431424 revertTurnCountByUserMessageId,
432425 }),
......@@ -437,7 +430,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
437430 expandedTurnIds,
438431 expandedWorkGroupIds,
439432 isWorking,
440 activeTurnStartedAt,
441433 turnDiffSummaryByAssistantMessageId,
442434 revertTurnCountByUserMessageId,
443435 ],
......@@ -566,9 +558,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
566558 isWorking,
567559 isRevertingCheckpoint,
568560 latestTurnId: latestTurn?.turnId ?? null,
569 workingStepLabel,
570561 }),
571 [isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel],
562 [isRevertingCheckpoint, isWorking, latestTurn?.turnId],
572563 );
573564
574565 // Stable renderItem — no closure deps. Row components read shared state
......@@ -946,8 +937,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
946937 const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;
947938 const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
948939 const isExpandedToolGroupHeader =
949 (row.kind === "work-toggle" && row.summary !== null && row.onlyToolEntries && row.expanded) ||
950 (row.kind === "work-live" && row.expanded);
940 (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);
951941
952942 return (
953943 <div
......@@ -960,7 +950,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
960950 : "pb-0"
961951 : isExpandedToolGroupHeader
962952 ? "pb-0"
963 : row.kind === "turn-fold" || row.kind === "working"
953 : row.kind === "turn-fold"
964954 ? "pb-1.5"
965955 : (row.kind === "message" &&
966956 row.message.role === "assistant" &&
......@@ -993,7 +983,6 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
993983 ) : null}
994984 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
995985 {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null}
996 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
997986 </div>
998987 );
999988});
......@@ -1277,7 +1266,7 @@ function ProposedPlanTimelineRow({
12771266
12781267/**
12791268 * Inline folded plan chip: one row per turn that produced plan/todo steps.
1280 * Collapsed by default — a segment bar plus the in-progress step label —
1269 * Collapsed by default, with a segment bar and the current step label,
12811270 * and expands in place to the full step list. Replaces the old plan sidebar.
12821271 */
12831272const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
......@@ -1299,15 +1288,19 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
12991288 const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon;
13001289
13011290 return (
1302 <div className="min-w-0 px-1 py-0.5">
1291 <div className="min-w-0 py-0.5">
13031292 <button
13041293 type="button"
1305 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"
1294 className="flex min-h-6 w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
13061295 aria-expanded={expanded}
13071296 onClick={() => setExpanded((value) => !value)}
13081297 >
1309 <Chevron className="size-3.5 shrink-0 text-muted-foreground/65" />
1310 {steps.length > 1 ? (
1298 <span className="flex h-5 w-6 shrink-0 items-center justify-center text-muted-foreground/65">
1299 <Chevron
1300 className={cn("size-4 shrink-0", expanded ? "-translate-x-0.5" : "-translate-x-1")}
1301 />
1302 </span>
1303 {steps.length > 1 && steps.length <= 10 ? (
13111304 <span aria-hidden className="flex shrink-0 items-center gap-0.5">
13121305 {steps.map((step) => (
13131306 <span
......@@ -1339,7 +1332,7 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
13391332 ) : null}
13401333 </button>
13411334 {expanded ? (
1342 <div className="mt-0.5 space-y-px pl-6">
1335 <div className="mt-0.5 space-y-px pl-8">
13431336 {steps.map((step) => (
13441337 <div key={step.step} className="flex items-baseline gap-2 text-[12px] leading-5">
13451338 <span
......@@ -1375,61 +1368,6 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
13751368 );
13761369});
13771370
1378function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1379 const { workingStepLabel } = use(TimelineRowActivityCtx);
1380 return (
1381 <div>
1382 <div className="border-b border-border/60 pb-2 pt-1">
1383 <div className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
1384 {row.createdAt ? (
1385 <>
1386 Working for <WorkingTimer createdAt={row.createdAt} />
1387 </>
1388 ) : (
1389 "Working..."
1390 )}
1391 {workingStepLabel ? (
1392 <span className="ml-2 text-muted-foreground/55">· {workingStepLabel}</span>
1393 ) : null}
1394 </div>
1395 </div>
1396 {row.showThinking ? (
1397 <div className="mt-1">
1398 <ThinkingActivityRow />
1399 </div>
1400 ) : null}
1401 </div>
1402 );
1403}
1404
1405// ---------------------------------------------------------------------------
1406// Self-ticking labels — update their own text nodes so elapsed-time display
1407// does not create a React commit every second while a response is streaming.
1408// ---------------------------------------------------------------------------
1409
1410/** Live "Working for Xs" label. */
1411function WorkingTimer({ createdAt }: { createdAt: string }) {
1412 const textRef = useRef<HTMLSpanElement>(null);
1413 const initialText = formatWorkingTimerNow(createdAt);
1414
1415 useEffect(() => {
1416 const updateText = () => {
1417 if (textRef.current) {
1418 textRef.current.textContent = formatWorkingTimerNow(createdAt);
1419 }
1420 };
1421 updateText();
1422 const id = setInterval(updateText, 1000);
1423 return () => clearInterval(id);
1424 }, [createdAt]);
1425
1426 return (
1427 <span ref={textRef} className="tabular-nums">
1428 {initialText}
1429 </span>
1430 );
1431}
1432
14331371// ---------------------------------------------------------------------------
14341372// Extracted row sections — own their state / store subscriptions so changes
14351373// re-render only the affected row, not the entire list.
......@@ -1449,12 +1387,6 @@ const WorkGroupSection = memo(function WorkGroupSection({
14491387 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),
14501388 [groupedEntries, isExpandedToolGroupEntry],
14511389 );
1452 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));
1453 const groupLabel = onlyToolEntries
1454 ? nonEmptyEntries.length === 1
1455 ? "1 tool call"
1456 : `${nonEmptyEntries.length} tool calls`
1457 : "Work Log";
14581390 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
14591391
14601392 if (nonEmptyEntries.length === 0) return null;
......@@ -1462,11 +1394,8 @@ const WorkGroupSection = memo(function WorkGroupSection({
14621394 return (
14631395 <GroupContainer
14641396 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
1465 aria-label={isExpandedToolGroupEntry ? undefined : groupLabel}
1397 aria-label={isExpandedToolGroupEntry ? undefined : "Activity"}
14661398 >
1467 {!onlyToolEntries && (
1468 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
1469 )}
14701399 <div className="space-y-px">
14711400 {nonEmptyEntries.map((workEntry) => (
14721401 <SimpleWorkEntryRow
......@@ -1512,10 +1441,6 @@ function LiveActivityRow({
15121441 );
15131442}
15141443
1515function ThinkingActivityRow() {
1516 return <LiveActivityRow label="Thinking" />;
1517}
1518
15191444function LiveActivityContent({
15201445 label,
15211446 iconName,
......@@ -1599,8 +1524,8 @@ function toolGroupSummaryIconName(
15991524 return "bot";
16001525 case "tone-tool":
16011526 return "zap";
1527 case "update":
16021528 case "mixed":
1603 case null:
16041529 return "hammer";
16051530 }
16061531}
......@@ -1611,61 +1536,21 @@ function WorkGroupToggleTimelineRow({
16111536 row: Extract<TimelineRow, { kind: "work-toggle" }>;
16121537}) {
16131538 const ctx = use(TimelineRowCtx);
1614 if (row.onlyToolEntries && row.summary) {
1615 return (
1616 <button
1617 type="button"
1618 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"
1619 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
1620 aria-expanded={row.expanded}
1621 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1622 >
1623 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1624 <WorkEntryIconSvg
1625 name={toolGroupSummaryIconName(row.summaryKind)}
1626 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1627 />
1628 </span>
1629 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
1630 </button>
1631 );
1632 }
1633 const labelNoun = row.onlyToolEntries
1634 ? row.hiddenCount === 1
1635 ? "tool call"
1636 : "tool calls"
1637 : row.hiddenCount === 1
1638 ? "log entry"
1639 : "log entries";
16401539 return (
16411540 <button
16421541 type="button"
1643 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"
1644 aria-label={
1645 row.hasFailure && !row.expanded
1646 ? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
1647 : undefined
1648 }
1542 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"
1543 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
16491544 aria-expanded={row.expanded}
16501545 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
16511546 >
16521547 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1653 <ChevronDownIcon
1654 className={cn(
1655 "size-4 shrink-0 opacity-70 transition-transform duration-200",
1656 row.expanded && "rotate-180",
1657 )}
1548 <WorkEntryIconSvg
1549 name={toolGroupSummaryIconName(row.summaryKind)}
1550 className="size-4 shrink-0 stroke-[1.8] opacity-70"
16581551 />
16591552 </span>
1660 {row.expanded ? (
1661 <span className="font-medium text-foreground">
1662 Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"}
1663 </span>
1664 ) : (
1665 <span className="font-medium text-foreground">
1666 +{row.hiddenCount} previous {labelNoun}
1667 </span>
1668 )}
1553 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
16691554 </button>
16701555 );
16711556}
......@@ -2163,33 +2048,6 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {
21632048// Pure helpers
21642049// ---------------------------------------------------------------------------
21652050
2166function formatWorkingTimer(startIso: string, endIso: string): string | null {
2167 const startedAtMs = Date.parse(startIso);
2168 const endedAtMs = Date.parse(endIso);
2169 if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) {
2170 return null;
2171 }
2172
2173 const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000));
2174 if (elapsedSeconds < 60) {
2175 return `${elapsedSeconds}s`;
2176 }
2177
2178 const hours = Math.floor(elapsedSeconds / 3600);
2179 const minutes = Math.floor((elapsedSeconds % 3600) / 60);
2180 const seconds = elapsedSeconds % 60;
2181
2182 if (hours > 0) {
2183 return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
2184 }
2185
2186 return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
2187}
2188
2189function formatWorkingTimerNow(startIso: string): string {
2190 return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
2191}
2192
21932051type WorkEntryIconName =
21942052 | "bot"
21952053 | "check"
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
......@@ -763,472 +763,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
763763 outline: none;
764764 }
765765
766 .chat-composer-shoulder-tab {
767 border-color: var(
768 --chat-composer-outline,
769 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
770 );
771 background: color-mix(
772 in srgb,
773 var(--chat-composer-glass-surface, var(--card)) var(--glass-opacity),
774 transparent
775 );
776 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
777 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
778 box-shadow: 0 -8px 24px -18px rgb(0 0 0 / 45%);
779
780 @variant dark {
781 box-shadow: 0 -10px 28px -18px rgb(0 0 0 / 80%);
782 }
783
784 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
785 background: var(--chat-composer-glass-surface, var(--card));
786 }
787 }
788
789 .chat-composer-banner-stack-cap {
790 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
791 --chat-composer-attached-outline: var(
792 --chat-composer-outline,
793 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
794 );
795 background: color-mix(
796 in srgb,
797 var(--chat-composer-attached-surface) var(--glass-opacity),
798 transparent
799 );
800 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
801 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
802
803 @variant dark {
804 --chat-composer-attached-surface: var(
805 --chat-composer-glass-surface,
806 color-mix(in srgb, var(--background) 96%, var(--color-white))
807 );
808 --chat-composer-attached-outline: var(
809 --chat-composer-outline,
810 color-mix(in srgb, var(--color-white) 5%, transparent)
811 );
812 }
813
814 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
815 background: var(--chat-composer-attached-surface);
816 }
817 }
818
819 /* Mirror the run-context strip above the composer. These surfaces deliberately
820 own no card fill or border themselves: a masked pseudo-element draws the
821 same inset glass, 16px outer corners, quiet outline, and open attachment
822 seam as the strip below. */
823 .chat-composer-drawer-surface,
824 .chat-composer-top-drawer {
825 --chat-composer-attachment-overlap: calc(1rem + 1px);
826 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
827 --chat-composer-attached-outline: var(
828 --chat-composer-outline,
829 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
830 );
831 --chat-composer-attached-tint: transparent;
832 position: relative;
833 isolation: isolate;
834 border: 0 !important;
835 background: transparent !important;
836 box-shadow: none;
837
838 @variant dark {
839 --chat-composer-attached-surface: var(
840 --chat-composer-glass-surface,
841 color-mix(in srgb, var(--background) 96%, var(--color-white))
842 );
843 --chat-composer-attached-outline: var(
844 --chat-composer-outline,
845 color-mix(in srgb, var(--color-white) 5%, transparent)
846 );
847
848 &::before {
849 background:
850 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
851 linear-gradient(
852 to top,
853 transparent 0 var(--chat-composer-attachment-overlap),
854 rgb(0 0 0 / 18%) var(--chat-composer-attachment-overlap),
855 transparent calc(var(--chat-composer-attachment-overlap) + 10px)
856 ),
857 color-mix(
858 in srgb,
859 var(--chat-composer-attached-surface) var(--glass-opacity),
860 transparent
861 );
862 box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
863 }
864 }
865 }
866
867 .chat-composer-drawer-attached,
868 .chat-composer-top-drawer {
869 padding-bottom: var(--chat-composer-attachment-overlap);
870 }
871
872 [data-chat-composer-form="true"],
873 .chat-composer-drawer-slot,
874 .chat-composer-top-drawer {
875 --chat-composer-drawer-inset: 1.375rem;
876 }
877
878 .chat-composer-drawer-slot,
879 .chat-composer-top-drawer {
880 --chat-composer-attachment-overlap: calc(1rem + 1px);
881 width: calc(100% - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
882 max-width: calc(48rem - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
883 margin-inline: auto;
884 margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap));
885 }
886
887 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)::before {
888 pointer-events: none;
889 position: absolute;
890 z-index: -1;
891 inset: 0;
892 border: 1px solid var(--chat-composer-attached-outline);
893 border-radius: 16px 16px 0 0;
894 background:
895 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
896 color-mix(in srgb, var(--chat-composer-attached-surface) var(--glass-opacity), transparent);
897 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
898 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
899 -webkit-mask-image: linear-gradient(
900 to top,
901 transparent 0 var(--chat-composer-attachment-overlap),
902 black var(--chat-composer-attachment-overlap)
903 );
904 mask-image: linear-gradient(
905 to top,
906 transparent 0 var(--chat-composer-attachment-overlap),
907 black var(--chat-composer-attachment-overlap)
908 );
909 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
910 content: "";
911
912 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
913 background:
914 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
915 var(--chat-composer-attached-surface) !important;
916 }
917 }
918
919 .chat-composer-drawer-slot + .chat-composer-drawer-slot::before,
920 .chat-composer-drawer-slot + :has(.chat-composer-top-drawer) .chat-composer-top-drawer::before {
921 border-top: 0;
922 border-radius: 0;
923 }
924
925 .chat-composer-top-drawer {
926 z-index: 0;
927 }
928
929 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="error"] {
930 --chat-composer-attached-outline: color-mix(in srgb, var(--error) 32%, transparent);
931 --chat-composer-attached-tint: color-mix(in srgb, var(--error) 8%, transparent);
932 }
933
934 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="info"] {
935 --chat-composer-attached-outline: color-mix(in srgb, var(--info) 32%, transparent);
936 --chat-composer-attached-tint: color-mix(in srgb, var(--info) 4%, transparent);
937 }
938
939 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="success"] {
940 --chat-composer-attached-outline: color-mix(in srgb, var(--success) 32%, transparent);
941 --chat-composer-attached-tint: color-mix(in srgb, var(--success) 4%, transparent);
942 }
943
944 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="warning"] {
945 --chat-composer-attached-outline: color-mix(in srgb, var(--warning) 28%, transparent);
946 --chat-composer-attached-tint: color-mix(in srgb, var(--warning) 8%, transparent);
947 }
948
949 .chat-composer-glass-shell {
950 --chat-composer-attachment-overlap: calc(1rem + 1px);
951 --chat-composer-glass-surface: var(--card);
952 --chat-composer-outline: rgb(0 0 0 / 8%);
953
954 isolation: isolate;
955
956 @variant dark {
957 --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white));
958 --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent);
959 --chat-composer-highlight: rgb(255 255 255 / 3%);
960 }
961 }
962
963 .chat-composer-glass-shell::before {
964 pointer-events: none;
965 position: absolute;
966 z-index: 0;
967 inset: 0;
968 border-radius: 22px;
969 background: color-mix(
970 in srgb,
971 var(--chat-composer-glass-surface) var(--glass-opacity),
972 transparent
973 );
974 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
975 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
976 content: "";
977 }
978
979 .chat-composer-glass-shell-with-context {
980 --chat-composer-context-extension: 2.25rem;
981 }
982
983 .chat-composer-glass-shell-with-context::before {
984 border-radius: 0;
985 /*
986 * One continuous glass layer: a 22px composer joined to a 16px strip. The
987 * strip is inset 1.375rem per side, so the step-in positions and their
988 * curve controls stay in rem to keep tracking it at non-default interface
989 * font sizes; the composer's 22px top radius and the strip's 16px bottom
990 * radius are px by design.
991 */
992 clip-path: shape(
993 from 0 22px,
994 curve to 22px 0 with 0 9.85px / 9.85px 0,
995 line to calc(100% - 22px) 0,
996 curve to 100% 22px with calc(100% - 9.85px) 0 / 100% 9.85px,
997 line to 100% calc(100% - var(--chat-composer-context-extension) - 1.375rem),
998 curve to calc(100% - 1.375rem) calc(100% - var(--chat-composer-context-extension)) with 100%
999 calc(100% - var(--chat-composer-context-extension) - 0.6156rem) / calc(100% - 0.6156rem)
1000 calc(100% - var(--chat-composer-context-extension)),
1001 line to calc(100% - 1.375rem) calc(100% - 16px),
1002 curve to calc(100% - 1.375rem - 16px) 100% with calc(100% - 1.375rem) calc(100% - 7.16px) /
1003 calc(100% - 1.375rem - 7.16px) 100%,
1004 line to calc(1.375rem + 16px) 100%,
1005 curve to 1.375rem calc(100% - 16px) with calc(1.375rem + 7.16px) 100% / 1.375rem
1006 calc(100% - 7.16px),
1007 line to 1.375rem calc(100% - var(--chat-composer-context-extension)),
1008 curve to 0 calc(100% - var(--chat-composer-context-extension) - 1.375rem) with 0.6156rem
1009 calc(100% - var(--chat-composer-context-extension)) / 0
1010 calc(100% - var(--chat-composer-context-extension) - 0.6156rem),
1011 line to 0 22px,
1012 close
1013 );
1014 }
1015
1016 @media (min-width: 40rem) {
1017 .chat-composer-glass-shell-with-context {
1018 /* The xs toolbar controls shrink by 4px at Tailwind's sm breakpoint. */
1019 --chat-composer-context-extension: 2rem;
1020 }
1021 }
1022
1023 .chat-composer-glass-host {
1024 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1025
1026 @variant dark {
1027 box-shadow: none;
1028
1029 &::after {
1030 box-shadow: inset 0 1px var(--chat-composer-highlight);
1031 }
1032 }
1033 }
1034
1035 .chat-composer-glass-host::after {
1036 pointer-events: none;
1037 position: absolute;
1038 z-index: 1;
1039 inset: 0;
1040 border: 1px solid var(--chat-composer-outline);
1041 border-radius: inherit;
1042 content: "";
1043 }
1044
1045 /* A top attachment cannot share the shell's single clipped backdrop: it
1046 needs the composer surface and outline to paint in front of its overlap.
1047 Split the glass into exact surfaces while a drawer or tab is present; the
1048 default composer keeps the cheaper continuous layer. */
1049 .chat-composer-glass-shell:is(
1050 .chat-composer-glass-shell-attached,
1051 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1052 )::before,
1053 .chat-composer-glass-shell:is(
1054 .chat-composer-glass-shell-attached,
1055 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1056 )
1057 .chat-composer-glass-host::after {
1058 display: none;
1059 }
1060
1061 .chat-composer-glass-shell:is(
1062 .chat-composer-glass-shell-attached,
1063 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1064 )
1065 .chat-composer-glass-host {
1066 box-shadow: none;
1067 }
1068
1069 .chat-composer-glass-shell:is(
1070 .chat-composer-glass-shell-attached,
1071 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1072 )
1073 .chat-composer-glass-host
1074 [data-chat-composer-main-surface="true"] {
1075 background: color-mix(
1076 in srgb,
1077 var(--chat-composer-glass-surface) var(--glass-opacity),
1078 transparent
1079 );
1080 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1081 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1082 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1083
1084 &::after {
1085 pointer-events: none;
1086 position: absolute;
1087 z-index: 20;
1088 inset: 0;
1089 border: 1px solid var(--chat-composer-outline);
1090 border-radius: inherit;
1091 content: "";
1092 }
1093
1094 @variant dark {
1095 box-shadow: none;
1096
1097 &::after {
1098 box-shadow: inset 0 1px var(--chat-composer-highlight);
1099 }
1100 }
1101
1102 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
1103 background: var(--chat-composer-glass-surface);
1104 }
1105 }
1106
1107 .chat-composer-glass-shell:is(
1108 .chat-composer-glass-shell-attached,
1109 :has(.chat-composer-top-drawer)
1110 )
1111 .chat-composer-glass-host
1112 [data-chat-composer-mobile-collapsed="true"] {
1113 min-height: var(--chat-composer-attachment-overlap);
1114 }
1115
1116 .chat-composer-glass-shell-with-context .chat-composer-glass-host::after,
1117 .chat-composer-glass-shell-with-context:is(
1118 .chat-composer-glass-shell-attached,
1119 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1120 )
1121 [data-chat-composer-main-surface="true"]::after {
1122 /* Keep the attachment seam open; the strip continues the outer outline below. */
1123 clip-path: polygon(
1124 0 0,
1125 100% 0,
1126 100% 100%,
1127 calc(100% - 22px) 100%,
1128 calc(100% - 22px) calc(100% - 2px),
1129 22px calc(100% - 2px),
1130 22px 100%,
1131 0 100%
1132 );
1133 }
1134
1135 .chat-composer-context-strip {
1136 position: relative;
1137 isolation: isolate;
1138
1139 @variant dark {
1140 &::before {
1141 border-color: rgb(255 255 255 / 7%);
1142 background:
1143 linear-gradient(
1144 to bottom,
1145 transparent 0 1rem,
1146 rgb(0 0 0 / 18%) 1rem,
1147 transparent calc(1rem + 10px)
1148 ),
1149 rgb(255 255 255 / 2%);
1150 box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
1151 }
1152 }
1153 }
1154
1155 .chat-composer-context-strip::before {
1156 position: absolute;
1157 z-index: -1;
1158 inset: 0;
1159 border: 1px solid var(--chat-composer-outline);
1160 border-radius: 0 0 16px 16px;
1161 /* Start the strip outline exactly where the composer's 22px curve reaches its tangent. */
1162 -webkit-mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem);
1163 mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem);
1164 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1165 content: "";
1166 }
1167
1168 .chat-composer-glass-shell:is(
1169 .chat-composer-glass-shell-attached,
1170 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1171 )
1172 .chat-composer-context-strip {
1173 &::before {
1174 background: color-mix(
1175 in srgb,
1176 var(--chat-composer-glass-surface) var(--glass-opacity),
1177 transparent
1178 );
1179 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1180 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1181 }
1182
1183 @variant dark {
1184 &::before {
1185 background:
1186 linear-gradient(
1187 to bottom,
1188 transparent 0 1rem,
1189 rgb(0 0 0 / 18%) 1rem,
1190 transparent calc(1rem + 10px)
1191 ),
1192 linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)),
1193 color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent);
1194 }
1195 }
1196 }
1197
1198 @supports not (clip-path: shape(from 0 0, line to 1px 1px)) {
1199 .chat-composer-glass-shell-with-context::before {
1200 inset-block-end: var(--chat-composer-context-extension);
1201 border-radius: 22px;
1202 clip-path: none;
1203 }
1204
1205 .chat-composer-context-strip::before {
1206 background: color-mix(
1207 in srgb,
1208 var(--chat-composer-glass-surface) var(--glass-opacity),
1209 transparent
1210 );
1211 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1212 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1213 }
1214
1215 .chat-composer-context-strip {
1216 @variant dark {
1217 &::before {
1218 background:
1219 linear-gradient(
1220 to bottom,
1221 transparent 0 1rem,
1222 rgb(0 0 0 / 18%) 1rem,
1223 transparent calc(1rem + 10px)
1224 ),
1225 linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)),
1226 color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent);
1227 }
1228 }
1229 }
1230 }
1231
1232766 .settings-slider {
1233767 --settings-slider-progress: 0%;
1234768 --settings-slider-fill-offset: 0.5rem;
......@@ -1343,12 +877,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
1343877 all: revert;
1344878 }
1345879 }
1346
1347 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
1348 .chat-composer-glass-shell::before {
1349 background: var(--chat-composer-glass-surface);
1350 }
1351 }
1352880}
1353881
1354882/* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering.
......@@ -1615,58 +1143,6 @@ html[data-theme-id]:not([data-theme-id=""]) {
16151143 --terminal-selection-background: var(--app-theme-terminal-selection-background);
16161144}
16171145
1618/* T3 Chat's composer is a translucent lift over --chat-background. Route its
1619 measured flattened color through the raised-surface role instead of deriving
1620 another tint from the canvas, which made the dark composer too red. */
1621html[data-theme-id] .chat-composer-glass-shell {
1622 --chat-composer-glass-surface: var(--app-theme-surface-raised);
1623 --chat-composer-outline: var(--app-theme-toolbar-border);
1624
1625 @variant dark {
1626 --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background));
1627 --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent);
1628 }
1629}
1630
1631html[data-theme-id]
1632 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer) {
1633 --chat-composer-attached-surface: var(--app-theme-surface-raised);
1634}
1635
1636html[data-theme-id]
1637 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
1638 :not([data-variant]),
1639 [data-variant="default"]
1640 ) {
1641 --chat-composer-attached-outline: var(--chat-composer-outline, var(--app-theme-toolbar-border));
1642
1643 @variant dark {
1644 --chat-composer-attached-outline: var(
1645 --chat-composer-outline,
1646 color-mix(in srgb, var(--app-theme-input) 30%, var(--background))
1647 );
1648 }
1649}
1650
1651html[data-theme-id="t3-chat"] .chat-composer-glass-shell {
1652 /* T3 Chat's visible composer edge is a dark plum, not the stock translucent
1653 white outline. Its highlight is derived from --chat-input-gradient. */
1654 @variant dark {
1655 --chat-composer-outline: #241e28;
1656 --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent);
1657 }
1658}
1659
1660html[data-theme-id="t3-chat"]
1661 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
1662 :not([data-variant]),
1663 [data-variant="default"]
1664 ) {
1665 @variant dark {
1666 --chat-composer-attached-outline: #241e28;
1667 }
1668}
1669
16701146/* Theme-token dependency probes are restored synchronously, before paint. Keep
16711147 transitions from observing the temporary sentinel color in between. */
16721148html[data-theme-token-probe],
apps/web/src/session-logic.test.ts+28
......@@ -915,6 +915,34 @@ describe("workEntryIndicatesToolFailure", () => {
915915});
916916
917917describe("deriveWorkLogEntries", () => {
918 it("keeps the latest task progress without emitting plan-update log entries", () => {
919 const activities = [
920 makeActivity({ id: "before", kind: "tool.completed", summary: "Read files", sequence: 0 }),
921 makeActivity({
922 id: "plan-1",
923 kind: "turn.plan.updated",
924 summary: "Plan updated",
925 turnId: "turn-1",
926 sequence: 1,
927 payload: { plan: [{ step: "Verify the composer", status: "inProgress" }] },
928 }),
929 makeActivity({
930 id: "plan-2",
931 kind: "turn.plan.updated",
932 summary: "Plan updated",
933 turnId: "turn-1",
934 sequence: 2,
935 payload: { plan: [{ step: "Verify the composer", status: "completed" }] },
936 }),
937 makeActivity({ id: "after", kind: "tool.completed", summary: "Ran tests", sequence: 3 }),
938 ];
939 expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual(["before", "after"]);
940 expect(deriveTurnPlans(activities)).toHaveLength(1);
941 expect(deriveTurnPlans(activities)[0]?.plan.steps).toMatchObject([
942 { step: "Verify the composer", status: "completed" },
943 ]);
944 });
945
918946 it("omits tool started entries and keeps completed entries", () => {
919947 const activities: OrchestrationThreadActivity[] = [
920948 makeActivity({
apps/web/src/session-logic.ts+1
......@@ -880,6 +880,7 @@ export function deriveWorkLogEntries(
880880 if (activity.kind === "task.updated") continue;
881881 if (activity.kind === "tool.progress") continue;
882882 if (activity.kind === "context-window.updated") continue;
883 if (activity.kind === "turn.plan.updated") continue;
883884 if (activity.summary === "Checkpoint captured") continue;
884885 if (isNoContentRuntimeWarning(activity)) continue;
885886 if (isPlanBoundaryToolActivity(activity)) continue;
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
......@@ -10,7 +11,9 @@ import { APP_VERSION } from "./branding";
1011import {
1112 appendVersionMismatchHint,
1213 buildVersionMismatchDismissalKey,
14 dismissServerUpdateFailure,
1315 dismissVersionMismatch,
16 isServerUpdateFailureDismissed,
1417 isVersionMismatchDismissed,
1518 resolveServerConfigVersionMismatch,
1619 resolveServerSelfUpdateCapability,
......@@ -26,6 +29,39 @@ describe("versionSkew", () => {
2629 branding.APP_VERSION = "0.0.34";
2730 });
2831
32 it("dismisses only the current failed attempt without clearing its retry state", () => {
33 const failure = {
34 status: "failed",
35 stage: "downloading",
36 fromVersion: "0.0.33",
37 targetVersion: "0.0.34",
38 message: "Download failed.",
39 } as const satisfies ServerUpdateState;
40 const retryFailure = { ...failure };
41 const otherEnvironmentFailure = { ...failure };
42
43 dismissServerUpdateFailure(failure);
44
45 expect(isServerUpdateFailureDismissed(failure)).toBe(true);
46 expect(failure.status).toBe("failed");
47 expect(failure.message).toBe("Download failed.");
48 expect(isServerUpdateFailureDismissed(retryFailure)).toBe(false);
49 expect(isServerUpdateFailureDismissed(otherEnvironmentFailure)).toBe(false);
50 });
51
52 it("does not dismiss an update that is still running", () => {
53 const running = {
54 status: "running",
55 stage: "resuming",
56 fromVersion: "0.0.33",
57 targetVersion: "0.0.34",
58 } as const satisfies ServerUpdateState;
59
60 dismissServerUpdateFailure(running);
61
62 expect(isServerUpdateFailureDismissed(running)).toBe(false);
63 });
64
2965 it("does not warn when versions match", () => {
3066 expect(resolveVersionMismatch(APP_VERSION)).toBeNull();
3167 });
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});