authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-29 20:37:36-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-29 20:37:36-07:00
log8dcb96314c976899e4df6951fb9af03131c2a46f
tree79c740936fca47753470141b617e97b345ebc5ea
parent1f8ed54add4133ac39effceded8fc1fff12d8e03
signature Signed by PGP key B5690EEEBB952194

revert(web): restore previous composer banners (#8733)


33 files changed, 2094 insertions(+), 1506 deletions(-)

apps/web/src/components/BranchToolbar.tsx+3-3
......@@ -40,7 +40,6 @@ import {
4040 MenuTrigger,
4141} from "./ui/menu";
4242import { Separator } from "./ui/separator";
43import { ComposerSurface } from "./chat/ComposerSurface";
4443
4544interface BranchToolbarProps {
4645 environmentId: EnvironmentId;
......@@ -467,9 +466,10 @@ export const BranchToolbar = memo(function BranchToolbar({
467466 if (!hasActiveThread || !activeProject) return null;
468467
469468 return (
470 <ComposerSurface.ContextStrip
469 <div
471470 ref={setStripElement}
472471 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 </ComposerSurface.ContextStrip>
537 </div>
538538 );
539539});
apps/web/src/components/ChatView.logic.test.ts+19
......@@ -32,6 +32,7 @@ import {
3232 resolveSendEnvMode,
3333 resolveDraftHeroState,
3434 scheduleEnvironmentReconnectWarning,
35 shoulderTabReserve,
3536 startNewThreadForProject,
3637 shouldDockDraftHeroForSubmission,
3738 shouldReleaseTimelineAnchorForToolActivity,
......@@ -45,6 +46,24 @@ const projectId = ProjectId.make("project-1");
4546const threadId = ThreadId.make("thread-1");
4647const now = "2026-03-29T00:00:00.000Z";
4748
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
4867describe("draft hero submission transition", () => {
4968 it("does not dock the composer before a background submission", () => {
5069 expect(
apps/web/src/components/ChatView.logic.ts+11
......@@ -39,6 +39,17 @@ 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
4253export function shouldDockDraftHeroForSubmission(input: {
4354 isDraftHeroState: boolean;
4455 activeThreadKey: string | null;
apps/web/src/components/ChatView.tsx+88-61
......@@ -183,7 +183,6 @@ import {
183183 CheckCircle2Icon,
184184 ChevronDownIcon,
185185 GitBranchIcon,
186 InfoIcon,
187186 Minimize2Icon,
188187 PaperclipIcon,
189188 WifiOffIcon,
......@@ -317,14 +316,14 @@ import {
317316 threadChangeRequestSnapshotsAtom,
318317 useLinkedThreadPullRequest,
319318} from "./ThreadStatusIndicators";
320import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
321import { ComposerSurface } from "./chat/ComposerSurface";
319import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
322320import {
323321 hasAvailableClaudeCompactionProvider,
324322 hasDismissedResumeCompaction,
325323 shouldOfferResumeCompaction,
326324} from "./chat/ContextWindowMeter.logic";
327325import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
326import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
328327import {
329328 DRAFT_HERO_TRANSITION_ANIMATION_ID,
330329 DRAFT_HERO_TRANSITION_DURATION_MS,
......@@ -351,6 +350,7 @@ import {
351350 shouldDockDraftHeroForSubmission,
352351 shouldReleaseTimelineAnchorForToolActivity,
353352 shouldShowBranchMismatchBanner,
353 shoulderTabReserve,
354354 shouldShowPlanFollowUpPrompt,
355355 getStartedThreadModelChangeBlockReason,
356356 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
......@@ -401,13 +401,10 @@ import {
401401 AlertDialogTitle,
402402} from "./ui/alert-dialog";
403403import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
404import { ServerUpdateAction } from "./ServerUpdateAction";
405import { ComposerServerUpdateStatus } from "./chat/ComposerServerUpdateStatus";
404import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction";
406405import {
407406 buildVersionMismatchDismissalKey,
408 dismissServerUpdateFailure,
409407 dismissVersionMismatch,
410 isServerUpdateFailureDismissed,
411408 isVersionMismatchDismissed,
412409 resolveServerConfigVersionMismatch,
413410 resolveServerSelfUpdateCapability,
......@@ -2159,12 +2156,6 @@ function ChatViewContent(props: ChatViewProps) {
21592156 const serverUpdateState = useAtomValue(
21602157 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
21612158 );
2162 const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState<
2163 typeof serverUpdateState | null
2164 >(null);
2165 const serverUpdateFailureDismissed =
2166 serverUpdateState === dismissedServerUpdateState ||
2167 isServerUpdateFailureDismissed(serverUpdateState);
21682159 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
21692160 const items: ComposerBannerStackItem[] = [];
21702161 const updateRunning = serverUpdateState.status === "running";
......@@ -2192,8 +2183,8 @@ function ChatViewContent(props: ChatViewProps) {
21922183 items.push({
21932184 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
21942185 variant: "default",
2195 // Prioritize live connection progress among the notices.
2196 priority: "urgent",
2186 // Live connection status: calm styling, but it must front the stack.
2187 urgent: true,
21972188 icon: (
21982189 <span
21992190 className="size-1.5 animate-status-pulse rounded-full bg-foreground"
......@@ -2240,24 +2231,29 @@ function ChatViewContent(props: ChatViewProps) {
22402231 if (
22412232 serverUpdateEnvironmentId &&
22422233 !reconnectingThroughVersionSkew &&
2243 (serverUpdateState.status === "idle"
2244 ? showVersionMismatchBanner
2245 : !serverUpdateFailureDismissed)
2234 (serverUpdateState.status !== "idle" ||
2235 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))
22462236 ) {
22472237 const updateInProgress = serverUpdateState.status === "running";
22482238 const updateFailed = serverUpdateState.status === "failed";
22492239 items.push({
22502240 id: `server-version:${serverUpdateEnvironmentId}`,
22512241 variant: updateFailed ? "error" : "default",
2252 // Prioritize update progress over passive notices, but keep activity attached.
2253 priority: updateInProgress ? "urgent" : "notice",
2254 icon: <InfoIcon aria-hidden />,
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 ),
22552254 title:
22562255 updateInProgress || updateFailed ? (
2257 <ComposerServerUpdateStatus
2258 state={serverUpdateState}
2259 serverLabel={versionMismatchServerLabel}
2260 />
2256 `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
22612257 ) : versionMismatch ? (
22622258 <Tooltip>
22632259 <TooltipTrigger
......@@ -2276,9 +2272,11 @@ function ChatViewContent(props: ChatViewProps) {
22762272 "Server update available"
22772273 ),
22782274 description:
2279 !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed"
2280 ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2281 : undefined,
2275 updateInProgress || updateFailed ? (
2276 <ServerUpdateProgress state={serverUpdateState} />
2277 ) : versionMismatchSelfUpdate === "desktop-managed" ? (
2278 serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2279 ) : null,
22822280 // The desktop-managed guidance is already the description; the action
22832281 // slot would only repeat it.
22842282 actions:
......@@ -2293,15 +2291,11 @@ function ChatViewContent(props: ChatViewProps) {
22932291 label={updateFailed ? "Retry" : "Update"}
22942292 />
22952293 ),
2296 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)
2294 ...(updateInProgress || updateFailed || !versionMismatchDismissKey
22972295 ? {}
22982296 : {
22992297 dismissLabel: "Dismiss update notice",
23002298 onDismiss: () => {
2301 if (updateFailed) {
2302 dismissServerUpdateFailure(serverUpdateState);
2303 setDismissedServerUpdateState(serverUpdateState);
2304 }
23052299 dismissVersionMismatch(versionMismatchDismissKey);
23062300 setDismissedVersionMismatchKey(versionMismatchDismissKey);
23072301 },
......@@ -2316,7 +2310,6 @@ function ChatViewContent(props: ChatViewProps) {
23162310 navigate,
23172311 setDismissedVersionMismatchKey,
23182312 showVersionMismatchBanner,
2319 serverUpdateFailureDismissed,
23202313 serverUpdateState,
23212314 versionMismatch,
23222315 versionMismatchDismissKey,
......@@ -2404,6 +2397,21 @@ function ChatViewContent(props: ChatViewProps) {
24042397 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
24052398 [activeLatestTurn?.turnId, threadActivities],
24062399 );
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]);
24072415 const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({
24082416 pendingUserInputCount: pendingUserInputs.length,
24092417 interactionMode,
......@@ -4434,20 +4442,10 @@ function ChatViewContent(props: ChatViewProps) {
44344442 // partition (same shell, same capability gate, same PR auto-settle input)
44354443 // so the banner and the sidebar row never disagree.
44364444 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : 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]);
4445 const activeComposerTasksProgress =
4446 activeLatestTurn !== null && !latestTurnSettled
4447 ? (activeThreadShell?.planProgress ?? null)
4448 : null;
44514449 const activeComposerTaskSteps =
44524450 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
44534451 ? activePlan.steps
......@@ -4462,8 +4460,9 @@ function ChatViewContent(props: ChatViewProps) {
44624460 setComposerOverlayHeight((currentHeight) =>
44634461 currentHeight === nextHeight ? currentHeight : nextHeight,
44644462 );
4463 const nextClearance = Math.max(0, nextHeight - shoulderTabReserve(composerOverlayElement));
44654464 setScrollToEndClearance((currentClearance) =>
4466 currentClearance === nextHeight ? currentClearance : nextHeight,
4465 currentClearance === nextClearance ? currentClearance : nextClearance,
44674466 );
44684467 };
44694468
......@@ -4472,8 +4471,11 @@ function ChatViewContent(props: ChatViewProps) {
44724471
44734472 const resizeObserver = new ResizeObserver(updateHeight);
44744473 resizeObserver.observe(composerOverlayElement);
4474 const tabObserver = new MutationObserver(updateHeight);
4475 tabObserver.observe(composerOverlayElement, { childList: true, subtree: true });
44754476 return () => {
44764477 resizeObserver.disconnect();
4478 tabObserver.disconnect();
44774479 };
44784480 }, [composerOverlayElement]);
44794481
......@@ -4801,7 +4803,6 @@ function ChatViewContent(props: ChatViewProps) {
48014803 return {
48024804 id: `background-liveness:${activeThread.id}`,
48034805 variant: "default",
4804 priority: "activity",
48054806 icon: (
48064807 <span
48074808 className={cn("size-1.5 rounded-full bg-foreground", working && "animate-status-pulse")}
......@@ -4848,6 +4849,13 @@ function ChatViewContent(props: ChatViewProps) {
48484849 onDismiss: acknowledgeActiveThreadWoke,
48494850 };
48504851 }, [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.
48514859 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
48524860 if (!activeThreadSnoozed && !activeThreadSettled) {
48534861 return null;
......@@ -4998,6 +5006,10 @@ function ChatViewContent(props: ChatViewProps) {
49985006 void handleSwitchCheckoutToThread();
49995007 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
50005008 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));
50015013 const backgroundLivenessItems =
50025014 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
50035015 const resumeCompactionItems =
......@@ -5006,16 +5018,18 @@ function ChatViewContent(props: ChatViewProps) {
50065018 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
50075019 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
50085020 return [
5009 ...systemComposerBannerItems,
5021 ...urgentSystemItems,
50105022 ...backgroundLivenessItems,
5023 ...calmSystemItems,
50115024 ...resumeCompactionItems,
50125025 ...wokeThreadItems,
50135026 ...parkedThreadItems,
50145027 ];
50155028 }
50165029 return [
5017 ...systemComposerBannerItems,
5030 ...urgentSystemItems,
50185031 ...backgroundLivenessItems,
5032 ...calmSystemItems,
50195033 ...resumeCompactionItems,
50205034 ...wokeThreadItems,
50215035 {
......@@ -7012,6 +7026,8 @@ function ChatViewContent(props: ChatViewProps) {
70127026 setDragActive: setIsWorkspaceFileDragActive,
70137027 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
70147028 });
7029 const externalComposerDrawerAttached =
7030 composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable);
70157031
70167032 return (
70177033 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
......@@ -7109,6 +7125,8 @@ function ChatViewContent(props: ChatViewProps) {
71097125 onOpenAgents={addAgentsSurface}
71107126 key={activeThread.id}
71117127 isWorking={isWorking}
7128 workingStepLabel={workingStepLabel}
7129 activeTurnStartedAt={activeWorkStartedAt}
71127130 listRef={legendListRef}
71137131 timelineEntries={timelineEntries}
71147132 latestTurn={activeLatestTurn}
......@@ -7176,7 +7194,7 @@ function ChatViewContent(props: ChatViewProps) {
71767194 {isDraftHeroState ? (
71777195 <div className="absolute inset-x-0 bottom-full z-0">
71787196 <div
7179 className="pb-8 group-has-data-[composer-shoulder-tab]/composer-stack:pb-4"
7197 className="pb-8 group-has-[.chat-composer-shoulder-tab]/composer-stack:pb-4"
71807198 style={
71817199 forceExpandedMobileComposer
71827200 ? {
......@@ -7190,7 +7208,13 @@ function ChatViewContent(props: ChatViewProps) {
71907208 activeProjectTitle={activeProject?.title ?? null}
71917209 />
71927210 </div>
7211 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
71937212 </div>
7213 ) : (
7214 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
7215 )}
7216 {threadSyncPhase && !activeEnvironmentUnavailable ? (
7217 <ThreadSyncStatusPill phase={threadSyncPhase} />
71947218 ) : null}
71957219 <div
71967220 className="relative"
......@@ -7200,8 +7224,14 @@ function ChatViewContent(props: ChatViewProps) {
72007224 : undefined
72017225 }
72027226 >
7203 <ComposerSurface.Shell contextStrip={showComposerContextStrip}>
7204 <ComposerSurface.Host>
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]">
72057235 <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">
72067236 <ChatComposer
72077237 composerRef={composerRef}
......@@ -7231,7 +7261,7 @@ function ChatViewContent(props: ChatViewProps) {
72317261 : null
72327262 }
72337263 isPreparingWorktree={isPreparingWorktree}
7234 bannerItems={composerBannerItems}
7264 externalDrawerAttached={externalComposerDrawerAttached}
72357265 environmentUnavailable={activeEnvironmentUnavailableState}
72367266 activePendingApproval={activePendingApproval}
72377267 pendingApprovals={pendingApprovals}
......@@ -7246,9 +7276,6 @@ function ChatViewContent(props: ChatViewProps) {
72467276 activeProposedPlan={activeProposedPlan}
72477277 activeTasksProgress={activeComposerTasksProgress}
72487278 activeTaskSteps={activeComposerTaskSteps}
7249 isWorking={isWorking}
7250 activeWorkStartedAt={activeWorkStartedAt}
7251 threadSyncPhase={activeEnvironmentUnavailable ? null : threadSyncPhase}
72527279 runtimeMode={runtimeMode}
72537280 interactionMode={interactionMode}
72547281 lockedProvider={lockedProvider}
......@@ -7295,7 +7322,7 @@ function ChatViewContent(props: ChatViewProps) {
72957322 onExpandImage={onExpandTimelineImage}
72967323 />
72977324 </div>
7298 </ComposerSurface.Host>
7325 </div>
72997326 <div className="min-h-0">
73007327 <div
73017328 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}
......@@ -7333,7 +7360,7 @@ function ChatViewContent(props: ChatViewProps) {
73337360 )}
73347361 </div>
73357362 </div>
7336 </ComposerSurface.Shell>
7363 </div>
73377364 <div
73387365 aria-hidden
73397366 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+173-218
......@@ -10,6 +10,7 @@ import type {
1010 ScopedThreadRef,
1111 ServerProvider,
1212 ThreadId,
13 TurnId,
1314} from "@t3tools/contracts";
1415import {
1516 ProviderDriverKind,
......@@ -71,20 +72,10 @@ import { ComposerStashBadge } from "./ComposerStashBadge";
7172import { ComposerStashMenu } from "./ComposerStashMenu";
7273import {
7374 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";
8879import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
8980import {
9081 fileAttachmentTooLargeMessage,
......@@ -604,7 +595,7 @@ export interface ChatComposerProps {
604595 isSendBusy: boolean;
605596 sendDisabledReason: string | null;
606597 isPreparingWorktree: boolean;
607 bannerItems: readonly ComposerBannerStackItem[];
598 externalDrawerAttached: boolean;
608599 environmentUnavailable: {
609600 readonly label: string;
610601 readonly connection: EnvironmentConnectionPresentation;
......@@ -632,9 +623,6 @@ export interface ChatComposerProps {
632623 activeProposedPlan: Thread["proposedPlans"][number] | null;
633624 activeTasksProgress: ComposerTasksProgress | null;
634625 activeTaskSteps: readonly ComposerTaskStep[] | null;
635 isWorking: boolean;
636 activeWorkStartedAt: string | null;
637 threadSyncPhase: ThreadSyncPhase | null;
638626
639627 // Mode
640628 runtimeMode: RuntimeMode;
......@@ -735,8 +723,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
735723 respondingRequestIds,
736724 showPlanFollowUpPrompt,
737725 activeProposedPlan,
738 isWorking,
739 activeWorkStartedAt,
726 activeTasksProgress,
727 activeTaskSteps,
740728 runtimeMode,
741729 interactionMode,
742730 lockedProvider,
......@@ -775,8 +763,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
775763 setThreadError,
776764 onExpandImage,
777765 } = props;
778 const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;
779 const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;
780766 // ------------------------------------------------------------------
781767 // Store subscriptions (prompt / images / terminal contexts)
782768 // ------------------------------------------------------------------
......@@ -1178,6 +1164,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11781164 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
11791165 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
11801166 const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);
1167 const [dismissedTasksTurnId, setDismissedTasksTurnId] = useState<TurnId | null>(null);
11811168 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
11821169 key: 0,
11831170 active: false,
......@@ -2587,9 +2574,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25872574 const deleteStashEntry = useCallback(
25882575 (entry: PromptStashEntry) => {
25892576 const { entry: removed, durable } = takeStashEntry(entry.id);
2590 if (!stashQueue.some((candidate) => candidate.id !== entry.id)) {
2591 setIsStashMenuOpen(false);
2592 }
25932577 if (durable && removed) {
25942578 for (const file of removed.files ?? []) {
25952579 releasePersistedAttachmentUpload({
......@@ -2609,7 +2593,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26092593 });
26102594 }
26112595 },
2612 [stashQueue, takeStashEntry],
2596 [takeStashEntry],
26132597 );
26142598
26152599 const stashCurrentPrompt = useCallback(async () => {
......@@ -2822,22 +2806,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28222806 const toggleTasksDrawer = useCallback(() => {
28232807 setIsTasksDrawerOpen((open) => !open);
28242808 }, []);
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;
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;
28352814 const hasBlockingComposerTopDrawer =
28362815 activePendingApproval !== null || pendingUserInputs.length > 0;
2816 const dismissTasks = useCallback(() => {
2817 if (activeTasksTurnId !== null) {
2818 setDismissedTasksTurnId(activeTasksTurnId);
2819 }
2820 setIsTasksDrawerOpen(false);
2821 }, [activeTasksTurnId]);
28372822 const showInlineStashBadge =
28382823 stashQueue.length > 0 &&
28392824 !isComposerApprovalState &&
2840 (hasBannerItems || showComposerTopDrawer || isTasksDrawerOpen || isMobileViewport);
2825 (props.externalDrawerAttached ||
2826 showComposerTopDrawer ||
2827 isTasksDrawerOpen ||
2828 isComposerCollapsedMobile);
28412829 const inlineStashBadge = showInlineStashBadge ? (
28422830 <ComposerStashBadge
28432831 count={stashQueue.length}
......@@ -2849,66 +2837,37 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28492837 />
28502838 ) : null;
28512839 const showInlineTasksBadge =
2852 activeTasksProgress !== null &&
2853 activeTaskSteps !== null &&
2840 visibleTasksProgress !== null &&
2841 visibleTaskSteps !== null &&
28542842 !isTasksDrawerOpen &&
28552843 !hasBlockingComposerTopDrawer &&
2856 (hasBannerItems || showComposerTopDrawer || isComposerCollapsedMobile);
2844 (props.externalDrawerAttached || showComposerTopDrawer || isComposerCollapsedMobile);
28572845 const inlineTasksBadge = showInlineTasksBadge ? (
28582846 <ComposerTasksBadge
28592847 expanded={false}
2848 onDismiss={dismissTasks}
28602849 onToggle={toggleTasksDrawer}
28612850 placement="inline"
2862 progress={activeTasksProgress}
2863 steps={activeTaskSteps}
2864 activityStatus={activityStatus}
2851 progress={visibleTasksProgress}
2852 steps={visibleTaskSteps}
28652853 />
28662854 ) : null;
28672855 const showShoulderTabs =
2868 !hasBannerItems && !showComposerTopDrawer && !isTasksDrawerOpen && !isComposerCollapsedMobile;
2856 !props.externalDrawerAttached &&
2857 !showComposerTopDrawer &&
2858 !isTasksDrawerOpen &&
2859 !isComposerCollapsedMobile;
28692860 const hasShoulderTab =
28702861 showShoulderTabs &&
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;
2862 (stashQueue.length > 0 ||
2863 (visibleTasksProgress !== null &&
2864 visibleTaskSteps !== null &&
2865 visibleTasksProgress.totalSteps > 0));
29072866 useEffect(() => {
2908 if (activeTasksProgress === null || activeTaskSteps === null) {
2867 if (visibleTasksProgress === null || visibleTaskSteps === null) {
29092868 setIsTasksDrawerOpen(false);
29102869 }
2911 }, [activeTaskSteps, activeTasksProgress]);
2870 }, [visibleTaskSteps, visibleTasksProgress]);
29122871
29132872 useEffect(() => {
29142873 if (hasBlockingComposerTopDrawer) {
......@@ -3415,49 +3374,62 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34153374 onDragOverCapture={composerMentionDragHandlers.onDragOver}
34163375 onDragLeaveCapture={onComposerMentionDragLeaveCapture}
34173376 onDropCapture={composerMentionDragHandlers.onDrop}
3418 className="mx-auto w-full min-w-0 max-w-3xl"
3377 className={cn("mx-auto w-full min-w-0 max-w-3xl", hasShoulderTab && "pt-7")}
34193378 data-chat-composer-form="true"
34203379 >
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}
34333380 {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
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 ? (
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">
34613433 <ComposerPendingUserInputPanel
34623434 pendingUserInputs={pendingUserInputs}
34633435 respondingRequestIds={respondingRequestIds}
......@@ -3466,116 +3438,96 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
34663438 onToggleOption={onSelectActivePendingUserInputOption}
34673439 onAdvance={onAdvanceActivePendingUserInput}
34683440 />
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"
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"
34873451 className={cn(
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",
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",
34903455 )}
3456 onPointerDown={(event) => event.preventDefault()}
3457 onClick={expandMobileComposer}
3458 aria-label="Write custom answer"
34913459 >
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>
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>
35333488 </div>
3534 ) : null}
3535 </ComposerBanner.Root>
3536 </ComposerBanner.Attachment>
3489 </div>
3490 ) : null}
3491 </div>
35373492 ) : null}
3538 {!activityStackItem &&
3539 isTasksDrawerOpen &&
3493 {isTasksDrawerOpen &&
35403494 !hasBlockingComposerTopDrawer &&
3541 activeTasksProgress &&
3542 activeTaskSteps ? (
3495 visibleTasksProgress &&
3496 visibleTaskSteps ? (
35433497 <ComposerTasksDrawer
3498 onDismiss={dismissTasks}
35443499 onCollapse={toggleTasksDrawer}
3545 progress={activeTasksProgress}
3546 steps={activeTaskSteps}
3547 activityStatus={activityStatus}
3500 progress={visibleTasksProgress}
3501 steps={visibleTaskSteps}
35483502 />
35493503 ) : 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}
35773504 <div className="relative">
3578 <ComposerSurface.Main className={composerProviderState.composerFrameClassName}>
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 >
35793531 <div
35803532 ref={composerSurfaceRef}
35813533 data-chat-composer-surface="true"
......@@ -3607,6 +3559,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36073559 : prompt.trim() ||
36083560 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
36093561 </button>
3562 {inlineTasksBadge}
36103563 {inlineStashBadge}
36113564 <button
36123565 type="button"
......@@ -3967,6 +3920,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
39673920 data-chat-composer-mobile-pending-actions="true"
39683921 className="absolute bottom-0 right-0 flex items-center justify-end gap-1"
39693922 >
3923 {inlineTasksBadge}
39703924 {inlineStashBadge}
39713925 <ComposerPrimaryActions
39723926 compact
......@@ -4124,6 +4078,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
41244078 </Tooltip>
41254079 </>
41264080 ) : null}
4081 {showMobilePendingAnswerActions ? null : inlineTasksBadge}
41274082 {showMobilePendingAnswerActions ? null : inlineStashBadge}
41284083 <ComposerFooterPrimaryActions
41294084 compact={isComposerPrimaryActionsCompact}
......@@ -4162,7 +4117,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
41624117 </div>
41634118 )}
41644119 </div>
4165 </ComposerSurface.Main>
4120 </div>
41664121 </div>
41674122 </form>
41684123 );
apps/web/src/components/chat/ComposerActivityStatus.tsx deleted-106
......@@ -1,106 +0,0 @@
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 deleted-330
......@@ -1,330 +0,0 @@
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 created+108
......@@ -0,0 +1,108 @@
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+133-173
......@@ -1,52 +1,60 @@
1import { useEffect, useId, useRef, useState, type ReactNode } from "react";
1import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2import { XIcon } from "lucide-react";
23
34import { cn } from "~/lib/utils";
4import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
5import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert";
6import { Button } from "../ui/button";
57
6// Match the duration-220 exit transition before removing a dismissed notice.
78const 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};
835
936export interface ComposerBannerStackItem {
1037 readonly id: string;
11 readonly variant: ComposerBannerVariant;
12 readonly priority?: "urgent" | "activity" | "notice";
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;
1342 readonly icon: ReactNode;
1443 readonly title: ReactNode;
1544 readonly description?: ReactNode;
16 readonly children?: ReactNode;
1745 readonly actions?: ReactNode;
1846 readonly className?: string;
47 readonly actionClassName?: string;
1948 readonly dismissLabel?: string;
2049 readonly onDismiss?: () => void;
2150}
2251
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
4052interface ComposerBannerStackProps {
4153 readonly className?: string;
42 readonly items: ReadonlyArray<ComposerBannerStackEntry>;
54 readonly items: ReadonlyArray<ComposerBannerStackItem>;
4355}
4456
4557export 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();
5058 const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null);
5159 const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
5260 const exitingItemId =
......@@ -62,27 +70,21 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
6270 };
6371 }, []);
6472
65 useEffect(() => {
66 if (items.length < 2) setStackExpanded(false);
67 }, [items.length]);
68
6973 if (items.length === 0) {
7074 return null;
7175 }
7276
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];
77 const frontItem = items[0];
7678 if (!frontItem) {
7779 return null;
7880 }
79 const stackedItems = orderedItems.slice(1);
81 const stackedItems = items.slice(1);
8082 const hasStack = stackedItems.length > 0;
8183 const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;
8284 const firstStackedItem = stackedItems[0];
8385
84 const requestDismiss = (item: ComposerBannerStackEntry) => {
85 if (!("onDismiss" in item) || !item.onDismiss || exitingItemId) {
86 const requestDismiss = (item: ComposerBannerStackItem) => {
87 if (!item.onDismiss || exitingItemId) {
8688 return;
8789 }
8890 setExitingItemId(item.id);
......@@ -96,28 +98,37 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
9698 };
9799
98100 return (
99 <ComposerBanner.Attachment
100 className={className}
101 <div
102 className={cn("group/banner-stack chat-composer-drawer-slot", className)}
101103 data-composer-banner-drawer="true"
102 data-chat-composer-collapsed-controls="true"
103104 >
104 <div className={cn("relative flex flex-col-reverse", hasStack && stackExpanded && "z-50")}>
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}
105124 <div
106125 className={cn(
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",
126 "relative z-10",
127 exitingItemId === frontItem.id ? "pointer-events-none" : null,
111128 )}
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 }
129 style={{
130 ...exitTransitionStyle,
131 ...(exitingItemId === frontItem.id ? frontExitStyle : restingStyle),
121132 }}
122133 >
123134 <ComposerBannerStackAlert
......@@ -129,86 +140,44 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
129140 </div>
130141 {hasStack ? (
131142 <div
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 }}
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 )}
155148 >
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>
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 ))}
206175 </div>
207176 </div>
208177 </div>
209178 ) : null}
210179 </div>
211 </ComposerBanner.Attachment>
180 </div>
212181 );
213182}
214183
......@@ -218,59 +187,50 @@ function ComposerBannerStackAlert({
218187 exiting,
219188 onDismissRequest,
220189}: {
221 readonly item: ComposerBannerStackEntry;
190 readonly item: ComposerBannerStackItem;
222191 readonly attached: boolean;
223192 readonly exiting: boolean;
224193 readonly onDismissRequest: () => void;
225194}) {
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 }
195 const dismissOnly = item.onDismiss && !item.actions;
237196
238197 return (
239 <ComposerBanner.Root
240 role="alert"
241 placement={attached ? "attached" : "floating"}
198 <Alert
242199 variant={item.variant}
243 className={item.className}
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}
244207 >
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>
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>
270231 ) : null}
271 {item.children}
272 </ComposerBanner.Children>
232 </AlertAction>
273233 ) : null}
274 </ComposerBanner.Root>
234 </Alert>
275235 );
276236}
apps/web/src/components/chat/ComposerCommandMenu.test.tsx+2
......@@ -19,6 +19,8 @@ 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");
2224 expect(markup).not.toContain("dropdown-glass");
2325 });
2426
apps/web/src/components/chat/ComposerCommandMenu.tsx+3-4
......@@ -24,7 +24,6 @@ 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";
2827
2928export type ComposerCommandItem =
3029 | {
......@@ -89,9 +88,9 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
8988 );
9089 }}
9190 >
92 <ComposerBanner.Surface
91 <div
9392 ref={listRef}
94 className="w-full overflow-hidden pb-(--chat-composer-attachment-overlap) **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"
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"
9594 data-composer-command-drawer="true"
9695 >
9796 {props.items.length > 0 ? (
......@@ -126,7 +125,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
126125 </p>
127126 </div>
128127 )}
129 </ComposerBanner.Surface>
128 </div>
130129 </Command>
131130 );
132131});
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 <span
34 <div
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 </span>
57 </div>
5858 );
5959});
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+42-25
......@@ -5,10 +5,9 @@ import {
55 derivePendingUserInputProgress,
66 type PendingUserInputDraftAnswer,
77} from "../../pendingUserInput";
8import { CheckIcon } from "lucide-react";
8import { CheckIcon, ChevronDownIcon } from "lucide-react";
99import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
1010import { cn } from "~/lib/utils";
11import { ComposerBanner } from "./ComposerBanner";
1211
1312interface PendingUserInputPanelProps {
1413 pendingUserInputs: PendingUserInput[];
......@@ -168,40 +167,58 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
168167
169168 return (
170169 <Collapsible
170 className="py-2"
171171 open={!isCollapsed}
172172 onOpenChange={(open) => {
173173 setCollapsedQuestionId(open ? null : activeQuestion.id);
174174 }}
175175 >
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">
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">
186191 {activeQuestion.header}
187192 </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>
195193 {prompt.questions.length > 1 ? (
196194 <span className="text-[10px] font-medium text-muted-foreground tabular-nums">
197195 {questionIndex + 1}/{prompt.questions.length}
198196 </span>
199197 ) : null}
200 <ComposerBanner.ToggleIcon expanded={!isCollapsed} />
201 </ComposerBanner.Actions>
202 </CollapsibleTrigger>
203 <CollapsiblePanel>
204 <ComposerBanner.Body>
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">
205222 <p className="text-sm text-foreground/85">{activeQuestion.question}</p>
206223 {activeQuestion.multiSelect ? (
207224 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
......@@ -259,7 +276,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
259276 );
260277 })}
261278 </div>
262 </ComposerBanner.Body>
279 </div>
263280 </CollapsiblePanel>
264281 </Collapsible>
265282 );
apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx+9-8
......@@ -1,5 +1,4 @@
11import { memo } from "react";
2import { ComposerBanner } from "./ComposerBanner";
32
43export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({
54 planTitle,
......@@ -7,14 +6,16 @@ export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBann
76 planTitle: string | null;
87}) {
98 return (
10 <ComposerBanner.Row>
11 <ComposerBanner.Icon />
12 <ComposerBanner.Content>
13 <span className="shrink-0 font-medium text-muted-foreground">Plan ready</span>
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>
1412 {planTitle ? (
15 <span className="min-w-0 flex-1 truncate text-foreground/85">{planTitle}</span>
13 <span className="min-w-0 flex-1 truncate text-xs text-foreground/85">{planTitle}</span>
1614 ) : null}
17 </ComposerBanner.Content>
18 </ComposerBanner.Row>
15 </div>
16 {/* <div className="mt-2 text-xs text-muted-foreground">
17 Review the plan
18 </div> */}
19 </div>
1920 );
2021});
apps/web/src/components/chat/ComposerServerUpdateStatus.tsx deleted-49
......@@ -1,49 +0,0 @@
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 created+68
......@@ -0,0 +1,68 @@
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,7 +3,6 @@ import { memo } from "react";
33
44import { cn } from "~/lib/utils";
55import { Button } from "../ui/button";
6import { ComposerBanner } from "./ComposerBanner";
76
87/**
98 * Bookmark control that shows the stash count and opens the stash menu. It
......@@ -25,16 +24,17 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
2524 if (props.count === 0) return null;
2625 const inline = props.placement === "inline";
2726 const count = (
28 <ComposerBanner.Count
27 <span
2928 key={props.pulseKey}
3029 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 </ComposerBanner.Count>
37 </span>
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 text-xs sm:text-xs",
49 "shrink-0 gap-1 px-1.5",
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 <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>
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>
8686 );
8787});
apps/web/src/components/chat/ComposerStashMenu.test.tsx+44
......@@ -5,6 +5,41 @@ 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
843 it("shows saved image thumbnails and incomplete image states", () => {
944 const markup = renderToStaticMarkup(
1045 <ComposerStashMenu
......@@ -46,6 +81,14 @@ describe("ComposerStashMenu", () => {
4681 expect(markup).toContain('src="data:image/png;base64,AA=="');
4782 expect(markup).toContain("1 image dropped");
4883 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");
4992 });
5093
5194 it("labels mixed file and image stashes without treating images as files", () => {
......@@ -86,6 +129,7 @@ describe("ComposerStashMenu", () => {
86129 );
87130
88131 expect(markup).toContain("(2 attachments)");
132 expect(markup).toContain("size-3.5 text-secondary-label");
89133 expect(markup).not.toContain("(2 files)");
90134 });
91135});
apps/web/src/components/chat/ComposerStashMenu.tsx+80-88
......@@ -1,10 +1,11 @@
1import { BookmarkIcon, FileIcon, FileTextIcon } from "lucide-react";
1import { FileIcon, XIcon } 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 { ComposerBanner } from "./ComposerBanner";
7import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
8import { Button } from "../ui/button";
89
910const SNIPPET_MAX_CHARS = 90;
1011
......@@ -29,7 +30,7 @@ function stashEntrySnippet(entry: PromptStashEntry): string {
2930}
3031
3132/**
32 * Attached banner listing the stashed prompts. Keyboard-first: opened by ⌘S on an
33 * Popover listing the stashed prompts. Keyboard-first: opened by ⌘S on an
3334 * empty composer, navigated with arrows, restored with Enter, dismissed
3435 * with Escape. The listener runs capture-phase on window so it wins over
3536 * the Lexical editor's handlers while the menu is open.
......@@ -63,6 +64,13 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
6364 return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true);
6465 }, [onClose]);
6566
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
6674 useEffect(() => {
6775 const handler = (event: KeyboardEvent) => {
6876 if (event.key === "Escape") {
......@@ -75,17 +83,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
7583 if (entries.length === 0) return;
7684 event.preventDefault();
7785 event.stopPropagation();
78 const currentIndex = entries.findIndex((entry) => entry.id === highlightedEntry?.id);
86 const currentIndex = entries.findIndex((entry) => entry.id === highlightedId);
7987 const offset = event.key === "ArrowDown" ? 1 : -1;
8088 const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0;
8189 const nextIndex = (normalizedIndex + offset + entries.length) % entries.length;
8290 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 }
8991 return;
9092 }
9193 if (event.key === "Enter") {
......@@ -109,77 +111,64 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
109111 };
110112 window.addEventListener("keydown", handler, true);
111113 return () => window.removeEventListener("keydown", handler, true);
112 }, [entries, highlightedEntry, onClose, onDelete, onRestore]);
114 }, [entries, highlightedEntry, highlightedId, onClose, onDelete, onRestore]);
113115
114116 return (
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}
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"
122122 >
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">
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">
138138 Nothing stashed yet.
139139 {stashShortcutLabel
140140 ? ` Press ${stashShortcutLabel} with a prompt in the composer to stash it.`
141141 : null}
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 >
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">
172163 {stashEntrySnippet(entry)}
173 </button>
174 </ComposerBanner.Content>
175 <ComposerBanner.Actions>
164 </span>
176165 {entry.pendingImageCount ? (
177 <span className="shrink-0 text-muted-foreground">
166 <span className="shrink-0 text-[10px] text-secondary-label">
178167 saving {entry.pendingImageCount} image
179168 {entry.pendingImageCount === 1 ? "" : "s"}…
180169 </span>
181170 ) : missingImageCount(entry) > 0 ? (
182 <span className="shrink-0 text-warning-foreground">
171 <span className="shrink-0 text-[10px] text-warning-foreground">
183172 {missingImageCount(entry)} image
184173 {missingImageCount(entry) === 1 ? "" : "s"} dropped
185174 </span>
......@@ -192,35 +181,38 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
192181 src={attachment.dataUrl}
193182 alt=""
194183 aria-hidden="true"
195 className="size-4 rounded border border-border/70 object-cover"
184 className="size-5 rounded border border-border/70 object-cover"
196185 />
197186 ))}
198187 </span>
199188 ) : null}
200189 {(entry.files?.length ?? 0) > 0 ? (
201 <span className="flex shrink-0 items-center gap-1 text-muted-foreground">
202 <FileIcon className="size-3" aria-hidden />
190 <span className="flex shrink-0 items-center gap-1 text-secondary-label text-xs">
191 <FileIcon className="size-3.5 text-secondary-label" />
203192 {entry.files!.length}
204193 </span>
205194 ) : null}
206 <time
207 dateTime={entry.createdAt}
208 className="shrink-0 text-muted-foreground tabular-nums max-sm:hidden"
209 >
195 <span className="shrink-0 text-secondary-label text-xs max-sm:hidden">
210196 {formatRelativeTimeLabel(entry.createdAt)}
211 </time>
212 <ComposerBanner.Dismiss
213 className="z-10"
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"
214202 aria-label="Delete stashed prompt"
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>
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>
225217 );
226218});
apps/web/src/components/chat/ComposerSurface.tsx deleted-99
......@@ -1,99 +0,0 @@
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 created+190
......@@ -0,0 +1,190 @@
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+171-156
......@@ -1,14 +1,9 @@
1import { ListTodoIcon } from "lucide-react";
2import { memo, type ComponentProps } from "react";
1import { ChevronDownIcon, ListTodoIcon, XIcon } from "lucide-react";
2import { memo } from "react";
33
44import { formatDuration } from "../../session-logic";
55import { cn } from "~/lib/utils";
6import { ComposerBanner } from "./ComposerBanner";
7import {
8 ComposerActivityIcon,
9 ComposerActivityLabel,
10 type ComposerActivityStatus,
11} from "./ComposerActivityStatus";
6import { Button } from "../ui/button";
127
138export interface ComposerTasksProgress {
149 readonly step: string;
......@@ -61,185 +56,205 @@ function TaskSegments({
6156 );
6257}
6358
64function TaskSummary({
59export const ComposerTasksBadge = memo(function ComposerTasksBadge({
6560 expanded,
61 hasTrailingShoulder = false,
62 onDismiss,
63 onToggle,
64 placement = "tab",
6665 progress,
6766 steps,
68 activityStatus,
6967}: {
7068 readonly expanded: boolean;
69 readonly hasTrailingShoulder?: boolean;
70 readonly onDismiss: () => void;
71 readonly onToggle: () => void;
72 readonly placement?: "inline" | "tab";
7173 readonly progress: ComposerTasksProgress;
7274 readonly steps: readonly ComposerTaskStep[];
73 readonly activityStatus: ComposerActivityStatus | undefined;
7475}) {
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
75118 return (
76 <>
77 {activityStatus !== undefined ? (
78 <ComposerActivityIcon status={activityStatus} />
79 ) : (
80 <ComposerBanner.Icon>
81 <ListTodoIcon />
82 </ComposerBanner.Icon>
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",
83124 )}
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}
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>
91137 <span
92138 className="min-w-0 flex-1 truncate text-left font-medium text-foreground/80"
93139 data-composer-task-current="true"
94140 >
95141 {progress.step}
96142 </span>
97 </ComposerBanner.Content>
98 <ComposerBanner.Actions>
99 <ComposerBanner.Count
100 className={progress.completedSteps >= progress.totalSteps ? "text-success" : undefined}
101 data-composer-task-progress="true"
143 <span
144 className={cn(
145 "shrink-0 font-medium tabular-nums",
146 allDone ? "text-success" : "text-muted-foreground",
147 )}
102148 >
103149 {progress.completedSteps}/{progress.totalSteps}
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>
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>
150164 );
151165});
152166
153export const ComposerTasksContent = memo(function ComposerTasksContent({
154 expanded,
155 onToggle,
167export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
168 onDismiss,
169 onCollapse,
156170 progress,
157171 steps,
158 activityStatus,
159172}: {
160 readonly expanded: boolean;
161 readonly onToggle: () => void;
173 readonly onDismiss: () => void;
174 readonly onCollapse: () => void;
162175 readonly progress: ComposerTasksProgress;
163176 readonly steps: readonly ComposerTaskStep[];
164 readonly activityStatus?: ComposerActivityStatus | undefined;
165177}) {
166178 return (
167179 <div
180 className="chat-composer-top-drawer"
168181 data-chat-composer-collapsed-controls="true"
169 data-chat-composer-tasks-drawer={expanded ? "true" : undefined}
182 data-chat-composer-tasks-drawer="true"
170183 >
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}
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>
228258 </div>
229259 );
230260});
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+39-22
......@@ -321,6 +321,7 @@ describe("deriveMessagesTimelineRows", () => {
321321 ],
322322 expandedTurnIds: new Set(["turn-1" as never]),
323323 isWorking: false,
324 activeTurnStartedAt: null,
324325 turnDiffSummaryByAssistantMessageId: new Map(),
325326 revertTurnCountByUserMessageId: new Map(),
326327 });
......@@ -374,6 +375,7 @@ describe("deriveMessagesTimelineRows", () => {
374375 completedAt: null,
375376 },
376377 isWorking: false,
378 activeTurnStartedAt: null,
377379 turnDiffSummaryByAssistantMessageId: new Map(),
378380 revertTurnCountByUserMessageId: new Map(),
379381 });
......@@ -430,6 +432,7 @@ describe("deriveMessagesTimelineRows", () => {
430432 },
431433 ],
432434 isWorking: false,
435 activeTurnStartedAt: null,
433436 turnDiffSummaryByAssistantMessageId: new Map([
434437 ["assistant-1" as never, assistantTurnDiffSummary],
435438 ]),
......@@ -510,6 +513,7 @@ describe("deriveMessagesTimelineRows", () => {
510513 const collapsedRows = deriveMessagesTimelineRows({
511514 timelineEntries,
512515 isWorking: false,
516 activeTurnStartedAt: null,
513517 turnDiffSummaryByAssistantMessageId: new Map(),
514518 revertTurnCountByUserMessageId: new Map(),
515519 });
......@@ -533,6 +537,7 @@ describe("deriveMessagesTimelineRows", () => {
533537 timelineEntries,
534538 expandedTurnIds: new Set(["turn-1" as never]),
535539 isWorking: false,
540 activeTurnStartedAt: null,
536541 turnDiffSummaryByAssistantMessageId: new Map(),
537542 revertTurnCountByUserMessageId: new Map(),
538543 });
......@@ -598,6 +603,7 @@ describe("deriveMessagesTimelineRows", () => {
598603 const rows = deriveMessagesTimelineRows({
599604 timelineEntries,
600605 isWorking: false,
606 activeTurnStartedAt: null,
601607 turnDiffSummaryByAssistantMessageId: new Map(),
602608 revertTurnCountByUserMessageId: new Map(),
603609 });
......@@ -692,6 +698,7 @@ describe("deriveMessagesTimelineRows", () => {
692698 completedAt: null,
693699 },
694700 isWorking: true,
701 activeTurnStartedAt: "2026-01-01T00:00:14Z",
695702 turnDiffSummaryByAssistantMessageId: new Map(),
696703 revertTurnCountByUserMessageId: new Map(),
697704 });
......@@ -728,6 +735,7 @@ describe("deriveMessagesTimelineRows", () => {
728735 completedAt: "2026-01-01T00:00:47Z",
729736 },
730737 isWorking: false,
738 activeTurnStartedAt: null,
731739 turnDiffSummaryByAssistantMessageId: new Map(),
732740 revertTurnCountByUserMessageId: new Map(),
733741 });
......@@ -795,6 +803,7 @@ describe("deriveMessagesTimelineRows", () => {
795803 completedAt: "2026-01-01T00:00:22Z",
796804 },
797805 isWorking: true,
806 activeTurnStartedAt: "2026-01-01T00:01:00Z",
798807 turnDiffSummaryByAssistantMessageId: new Map(),
799808 revertTurnCountByUserMessageId: new Map(),
800809 });
......@@ -803,6 +812,7 @@ describe("deriveMessagesTimelineRows", () => {
803812 "turn-fold:turn-1",
804813 "assistant-final-entry",
805814 "user-followup-entry",
815 "working-indicator-row",
806816 ]);
807817 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
808818 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
......@@ -845,12 +855,14 @@ describe("deriveMessagesTimelineRows", () => {
845855 completedAt: null,
846856 },
847857 isWorking: true,
858 activeTurnStartedAt: "2026-01-01T00:00:00Z",
848859 turnDiffSummaryByAssistantMessageId: new Map(),
849860 revertTurnCountByUserMessageId: new Map(),
850861 });
851862
852863 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
853864 expect(rows.map((row) => row.id)).toEqual([
865 "working-indicator-row",
854866 "assistant-thought-entry",
855867 "work-live:work-entry-1",
856868 ]);
......@@ -912,11 +924,12 @@ describe("deriveMessagesTimelineRows", () => {
912924 completedAt: null,
913925 },
914926 isWorking: true,
927 activeTurnStartedAt: "2026-01-01T00:00:00Z",
915928 turnDiffSummaryByAssistantMessageId: new Map(),
916929 revertTurnCountByUserMessageId: new Map(),
917930 });
918931
919 expect(rows.map((row) => row.kind)).toEqual(["work-live"]);
932 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
920933 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
921934 entry: { id: "running-command" },
922935 groupedEntries: [
......@@ -982,11 +995,12 @@ describe("deriveMessagesTimelineRows", () => {
982995 completedAt: null,
983996 },
984997 isWorking: true,
998 activeTurnStartedAt: "2026-01-01T00:00:00Z",
985999 turnDiffSummaryByAssistantMessageId: new Map(),
9861000 revertTurnCountByUserMessageId: new Map(),
9871001 });
9881002
989 expect(rows.map((row) => row.kind)).toEqual(["work-toggle", "message", "work-live"]);
1003 expect(rows.map((row) => row.kind)).toEqual(["working", "work-toggle", "message", "work-live"]);
9901004 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
9911005 hiddenCount: 1,
9921006 summary: "Ran 1 command",
......@@ -1048,11 +1062,12 @@ describe("deriveMessagesTimelineRows", () => {
10481062 completedAt: null,
10491063 },
10501064 isWorking: true,
1065 activeTurnStartedAt: "2026-01-01T00:00:00Z",
10511066 turnDiffSummaryByAssistantMessageId: new Map(),
10521067 revertTurnCountByUserMessageId: new Map(),
10531068 });
10541069
1055 expect(rows.map((row) => row.kind)).toEqual(["work-live", "message", "work-live"]);
1070 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]);
10561071 expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([
10571072 "first-running",
10581073 "second-running",
......@@ -1094,6 +1109,7 @@ describe("deriveMessagesTimelineRows", () => {
10941109 ],
10951110 latestTurn: null,
10961111 isWorking: true,
1112 activeTurnStartedAt: "2026-01-01T00:01:00Z",
10971113 turnDiffSummaryByAssistantMessageId: new Map(),
10981114 revertTurnCountByUserMessageId: new Map(),
10991115 });
......@@ -1154,6 +1170,7 @@ describe("deriveMessagesTimelineRows", () => {
11541170 completedAt: null,
11551171 },
11561172 isWorking: true,
1173 activeTurnStartedAt: "2026-01-01T00:00:00Z",
11571174 turnDiffSummaryByAssistantMessageId: new Map(),
11581175 revertTurnCountByUserMessageId: new Map(),
11591176 });
......@@ -1189,11 +1206,12 @@ describe("deriveMessagesTimelineRows", () => {
11891206 completedAt: null,
11901207 },
11911208 isWorking: true,
1209 activeTurnStartedAt: "2026-01-01T00:00:00Z",
11921210 turnDiffSummaryByAssistantMessageId: new Map(),
11931211 revertTurnCountByUserMessageId: new Map(),
11941212 });
11951213
1196 expect(rows.map((row) => row.kind)).toEqual(["work-live"]);
1214 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
11971215 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
11981216 entry: { id: "latest-command" },
11991217 groupedEntries: [{ id: "latest-command" }],
......@@ -1250,6 +1268,7 @@ describe("deriveMessagesTimelineRows", () => {
12501268 },
12511269 runningTurnId: "turn-2" as never,
12521270 isWorking: true,
1271 activeTurnStartedAt: "2026-01-01T00:01:00Z",
12531272 turnDiffSummaryByAssistantMessageId: new Map(),
12541273 revertTurnCountByUserMessageId: new Map(),
12551274 });
......@@ -1294,6 +1313,7 @@ describe("deriveMessagesTimelineRows", () => {
12941313 ],
12951314 expandedTurnIds: new Set(["turn-1" as never]),
12961315 isWorking: false,
1316 activeTurnStartedAt: null,
12971317 turnDiffSummaryByAssistantMessageId: new Map(),
12981318 revertTurnCountByUserMessageId: new Map(),
12991319 });
......@@ -1331,6 +1351,7 @@ describe("deriveMessagesTimelineRows", () => {
13311351 completedAt: null,
13321352 },
13331353 isWorking: true,
1354 activeTurnStartedAt: "2026-01-01T00:00:00Z",
13341355 turnDiffSummaryByAssistantMessageId: new Map(),
13351356 revertTurnCountByUserMessageId: new Map(),
13361357 });
......@@ -1344,10 +1365,7 @@ describe("deriveMessagesTimelineRows", () => {
13441365 expect(assistantRow?.showAssistantCopyButton).toBe(false);
13451366 });
13461367
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) => {
1368 it("models work log overflow expansion as inserted list rows", () => {
13511369 const timelineEntries = [
13521370 {
13531371 id: "work-entry-1",
......@@ -1368,9 +1386,9 @@ describe("deriveMessagesTimelineRows", () => {
13681386 entry: {
13691387 id: "work-2",
13701388 createdAt: "2026-01-01T00:00:02Z",
1371 label: "Status updated",
1389 label: "edit",
13721390 detail: "Editing MessagesTimeline.tsx",
1373 tone: middleTone,
1391 tone: "tool" as const,
13741392 },
13751393 },
13761394 {
......@@ -1390,6 +1408,7 @@ describe("deriveMessagesTimelineRows", () => {
13901408 const baseInput = {
13911409 timelineEntries,
13921410 isWorking: false,
1411 activeTurnStartedAt: null,
13931412 turnDiffSummaryByAssistantMessageId: new Map(),
13941413 revertTurnCountByUserMessageId: new Map(),
13951414 };
......@@ -1404,7 +1423,8 @@ describe("deriveMessagesTimelineRows", () => {
14041423 groupId: "work-group:work-entry-1",
14051424 hiddenCount: 3,
14061425 expanded: false,
1407 summary,
1426 onlyToolEntries: true,
1427 summary: "Used 3 tools",
14081428 });
14091429 expect(expandedRows.map((row) => row.id)).toEqual([
14101430 "work-toggle:work-entry-1",
......@@ -1439,6 +1459,7 @@ describe("deriveMessagesTimelineRows", () => {
14391459 const rows = deriveMessagesTimelineRows({
14401460 timelineEntries,
14411461 isWorking: false,
1462 activeTurnStartedAt: null,
14421463 turnDiffSummaryByAssistantMessageId: new Map(),
14431464 revertTurnCountByUserMessageId: new Map(),
14441465 });
......@@ -1455,7 +1476,7 @@ describe("deriveMessagesTimelineRows", () => {
14551476 ["an error-toned entry recovers", ["error", "info", "completed"], false],
14561477 ["the final failure is hidden", ["completed", "failed", "info"], true],
14571478 ["the final failure is visible", ["failed", "info", "failed"], true],
1458 ["the only failure is visible", ["completed", "info", "failed"], true],
1479 ["the only failure is visible", ["completed", "info", "failed"], false],
14591480 ] as const)(
14601481 "uses the final tool call for mixed work groups when %s",
14611482 (_, statuses, hasFailure) => {
......@@ -1485,23 +1506,16 @@ describe("deriveMessagesTimelineRows", () => {
14851506 const rows = deriveMessagesTimelineRows({
14861507 timelineEntries,
14871508 isWorking: false,
1509 activeTurnStartedAt: null,
14881510 turnDiffSummaryByAssistantMessageId: new Map(),
14891511 revertTurnCountByUserMessageId: new Map(),
14901512 });
14911513
14921514 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
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",
1515 hiddenCount: 2,
1516 summary: null,
14971517 hasFailure,
14981518 });
1499 if (statuses.some((status) => status === "error")) {
1500 expect(rows[0]).toMatchObject({
1501 kind: "work",
1502 groupedEntries: [{ tone: "error", label: "Command failed" }],
1503 });
1504 }
15051519 },
15061520 );
15071521});
......@@ -1543,6 +1557,7 @@ describe("computeStableMessagesTimelineRows", () => {
15431557 },
15441558 ],
15451559 isWorking: false,
1560 activeTurnStartedAt: null,
15461561 turnDiffSummaryByAssistantMessageId: new Map(),
15471562 revertTurnCountByUserMessageId: new Map(),
15481563 });
......@@ -1591,6 +1606,7 @@ describe("computeStableMessagesTimelineRows", () => {
15911606 },
15921607 ],
15931608 isWorking: false,
1609 activeTurnStartedAt: null,
15941610 turnDiffSummaryByAssistantMessageId: new Map(),
15951611 revertTurnCountByUserMessageId: new Map(),
15961612 });
......@@ -1646,6 +1662,7 @@ describe("computeStableMessagesTimelineRows", () => {
16461662 },
16471663 ],
16481664 isWorking: false,
1665 activeTurnStartedAt: null,
16491666 turnDiffSummaryByAssistantMessageId: new Map(),
16501667 revertTurnCountByUserMessageId: new Map(),
16511668 });
apps/web/src/components/chat/MessagesTimeline.logic.ts+127-32
......@@ -11,6 +11,7 @@ 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;
1415export const TIMELINE_MINIMAP_ITEM_SPACING = 8;
1516export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
1617export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
......@@ -201,8 +202,9 @@ export type MessagesTimelineRow =
201202 groupId: string;
202203 hiddenCount: number;
203204 expanded: boolean;
204 summary: string;
205 summaryKind: ToolGroupSummaryKind;
205 onlyToolEntries: boolean;
206 summary: string | null;
207 summaryKind: ToolGroupSummaryKind | null;
206208 hasFailure: boolean;
207209 }
208210 | {
......@@ -236,6 +238,12 @@ export type MessagesTimelineRow =
236238 id: string;
237239 createdAt: string;
238240 turnPlan: TurnPlanEntry;
241 }
242 | {
243 kind: "working";
244 id: string;
245 createdAt: string | null;
246 showThinking: boolean;
239247 };
240248
241249export interface StableMessagesTimelineRowsState {
......@@ -266,7 +274,7 @@ export function normalizeCompactToolLabel(value: string): string {
266274 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
267275}
268276
269type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update";
277type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other";
270278type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed";
271279
272280export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {
......@@ -296,7 +304,7 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
296304 }
297305 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
298306 if (entry.itemType === "web_search") return "search";
299 return workLogEntryIsToolLike(entry) ? "other" : "update";
307 return "other";
300308}
301309
302310function toolGroupActionCount(
......@@ -331,8 +339,6 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
331339 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
332340 case "other":
333341 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
334 case "update":
335 return `Received ${count} ${count === 1 ? "update" : "updates"}`;
336342 }
337343}
338344
......@@ -658,6 +664,7 @@ export function deriveMessagesTimelineRows(input: {
658664 expandedTurnIds?: ReadonlySet<TurnId>;
659665 expandedWorkGroupIds?: ReadonlySet<string>;
660666 isWorking: boolean;
667 activeTurnStartedAt: string | null;
661668 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
662669 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;
663670}): MessagesTimelineRow[] {
......@@ -707,6 +714,26 @@ export function deriveMessagesTimelineRows(input: {
707714 unsettledTurnId !== null &&
708715 entry.toolLifecycleStatus === "inProgress" &&
709716 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
710737 const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = [];
711738 for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {
712739 const entry = input.timelineEntries[index]!;
......@@ -714,7 +741,8 @@ export function deriveMessagesTimelineRows(input: {
714741 !entryBelongsToActiveTurn(entry, index) ||
715742 entry.kind !== "work" ||
716743 entry.entry.agentSpawn !== undefined ||
717 entry.entry.tone === "error"
744 entry.entry.tone === "error" ||
745 !workLogEntryIsToolLike(entry.entry)
718746 ) {
719747 break;
720748 }
......@@ -722,7 +750,7 @@ export function deriveMessagesTimelineRows(input: {
722750 }
723751 const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
724752 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
725 activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),
753 activeToolEntries.filter((entry) => isVisibleActiveToolEntry(entry.entry)),
726754 (entry) => entry.entry,
727755 );
728756 const activeWorkAnchor = activeToolEntries[0];
......@@ -743,6 +771,14 @@ export function deriveMessagesTimelineRows(input: {
743771 };
744772 })()
745773 : 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 };
746782 const appendActiveWorkRows = () => {
747783 if (activeWorkRow === null) return;
748784 nextRows.push(activeWorkRow);
......@@ -765,6 +801,10 @@ export function deriveMessagesTimelineRows(input: {
765801 continue;
766802 }
767803
804 if (input.isWorking && index === activeTurnHeaderIndex) {
805 appendWorkingRow();
806 }
807
768808 if (timelineEntry.id === activeWorkPlacementEntryId) {
769809 appendActiveWorkRows();
770810 }
......@@ -790,17 +830,6 @@ export function deriveMessagesTimelineRows(input: {
790830 }
791831
792832 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 }
804833 const groupedEntries = [timelineEntry.entry];
805834 let cursor = index + 1;
806835 while (cursor < input.timelineEntries.length) {
......@@ -808,8 +837,6 @@ export function deriveMessagesTimelineRows(input: {
808837 if (
809838 !nextEntry ||
810839 nextEntry.kind !== "work" ||
811 nextEntry.entry.agentSpawn !== undefined ||
812 nextEntry.entry.tone === "error" ||
813840 activeWorkEntryIds.has(nextEntry.id) ||
814841 collapsedEntryIds.has(nextEntry.id) ||
815842 foldsByAnchorEntryId.has(nextEntry.id)
......@@ -826,8 +853,14 @@ export function deriveMessagesTimelineRows(input: {
826853 (entry) => entry,
827854 );
828855 if (visibleGroupedEntries.length > 0) {
856 const onlyToolEntries = visibleGroupedEntries.every(
857 (entry) =>
858 workLogEntryIsToolLike(entry) &&
859 entry.agentSpawn === undefined &&
860 entry.tone !== "error",
861 );
829862 const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);
830 if (activeInProgressToolEntries.length > 0) {
863 if (onlyToolEntries && activeInProgressToolEntries.length > 0) {
831864 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
832865 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
833866 const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;
......@@ -852,11 +885,10 @@ export function deriveMessagesTimelineRows(input: {
852885 });
853886 }
854887 }
855 } else {
888 } else if (onlyToolEntries) {
856889 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
857890 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
858891 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
859 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
860892 nextRows.push({
861893 kind: "work-toggle",
862894 id: `work-toggle:${timelineEntry.id}`,
......@@ -864,15 +896,10 @@ export function deriveMessagesTimelineRows(input: {
864896 groupId,
865897 hiddenCount: visibleGroupedEntries.length,
866898 expanded,
867 summary:
868 visibleGroupedEntries.length === 1 &&
869 !workLogEntryIsToolLike(visibleGroupedEntries[0]!)
870 ? visibleGroupedEntries[0]!.label
871 : summarizeToolGroup(visibleGroupedEntries),
899 onlyToolEntries: true,
900 summary: summarizeToolGroup(visibleGroupedEntries),
872901 summaryKind,
873 hasFailure:
874 latestToolEntry !== undefined &&
875 workEntryDisplayIndicatesToolFailure(latestToolEntry),
902 hasFailure: workEntryDisplayIndicatesToolFailure(visibleGroupedEntries.at(-1)!),
876903 });
877904 if (expanded) {
878905 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
......@@ -886,6 +913,64 @@ export function deriveMessagesTimelineRows(input: {
886913 });
887914 }
888915 }
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 }
889974 }
890975 }
891976 index = cursor - 1;
......@@ -948,6 +1033,10 @@ export function deriveMessagesTimelineRows(input: {
9481033 });
9491034 }
9501035
1036 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
1037 appendWorkingRow();
1038 }
1039
9511040 return nextRows;
9521041}
9531042
......@@ -976,6 +1065,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
9761065 if (a.kind !== b.kind || a.id !== b.id) return false;
9771066
9781067 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
9791073 case "turn-fold": {
9801074 const bf = b as typeof a;
9811075 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;
......@@ -1018,6 +1112,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
10181112 a.groupId === bw.groupId &&
10191113 a.hiddenCount === bw.hiddenCount &&
10201114 a.expanded === bw.expanded &&
1115 a.onlyToolEntries === bw.onlyToolEntries &&
10211116 a.summary === bw.summary &&
10221117 a.summaryKind === bw.summaryKind &&
10231118 a.hasFailure === bw.hasFailure
apps/web/src/components/chat/MessagesTimeline.test.tsx+26-3
......@@ -177,6 +177,7 @@ const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
177177function buildProps() {
178178 return {
179179 isWorking: false,
180 activeTurnStartedAt: null,
180181 listRef: createRef<LegendListRef | null>(),
181182 latestTurn: null,
182183 runningTurnId: null,
......@@ -674,6 +675,7 @@ describe("MessagesTimeline", () => {
674675 <MessagesTimeline
675676 {...buildProps()}
676677 isWorking
678 activeTurnStartedAt={MESSAGE_CREATED_AT}
677679 latestTurn={{
678680 turnId,
679681 state: "running",
......@@ -1013,6 +1015,7 @@ describe("MessagesTimeline", () => {
10131015 );
10141016
10151017 expect(markup).toContain("Context compacted");
1018 expect(markup).toContain("Work Log");
10161019 });
10171020
10181021 it("summarizes changed files in one line", () => {
......@@ -1169,7 +1172,7 @@ describe("MessagesTimeline", () => {
11691172 />,
11701173 );
11711174
1172 expect(markup).toContain("Ran 2 commands and received 1 update");
1175 expect(markup).toContain("+2 previous log entries");
11731176 expect(markup).not.toContain('aria-label="Hidden work includes a failure"');
11741177 });
11751178
......@@ -1179,6 +1182,7 @@ describe("MessagesTimeline", () => {
11791182 <MessagesTimeline
11801183 {...buildProps()}
11811184 isWorking
1185 activeTurnStartedAt={MESSAGE_CREATED_AT}
11821186 latestTurn={{
11831187 turnId,
11841188 state: "running",
......@@ -1207,6 +1211,7 @@ describe("MessagesTimeline", () => {
12071211 />,
12081212 );
12091213
1214 expect(markup).toContain("Working for");
12101215 expect(markup).toContain("Running pnpm");
12111216 expect(markup).toContain("live-activity-focus");
12121217 });
......@@ -1217,6 +1222,7 @@ describe("MessagesTimeline", () => {
12171222 <MessagesTimeline
12181223 {...buildProps()}
12191224 isWorking
1225 activeTurnStartedAt={MESSAGE_CREATED_AT}
12201226 latestTurn={{
12211227 turnId,
12221228 state: "running",
......@@ -1271,6 +1277,7 @@ describe("MessagesTimeline", () => {
12711277 <MessagesTimeline
12721278 {...buildProps()}
12731279 isWorking
1280 activeTurnStartedAt={MESSAGE_CREATED_AT}
12741281 latestTurn={{
12751282 turnId,
12761283 state: "running",
......@@ -1303,6 +1310,21 @@ describe("MessagesTimeline", () => {
13031310 expect(markup).toContain("tool call failed");
13041311 });
13051312
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
13061328 it("renders review comment contexts as structured cards instead of raw tags", () => {
13071329 const markup = renderToStaticMarkup(
13081330 <MessagesTimeline
......@@ -1380,7 +1402,7 @@ describe("MessagesTimeline", () => {
13801402 expect(markup).not.toContain('data-testid="file-diff"');
13811403 });
13821404
1383 it("keeps failed lifecycle entries discoverable in mixed activity summaries", () => {
1405 it("renders a muted failure marker for failed tool lifecycle entries", () => {
13841406 const markup = renderToStaticMarkup(
13851407 <MessagesTimeline
13861408 {...buildProps()}
......@@ -1413,7 +1435,8 @@ describe("MessagesTimeline", () => {
14131435 />,
14141436 );
14151437
1416 expect(markup).toContain('aria-label="Received 1 update and used 1 tool, tool call failed"');
1438 expect(markup).toContain("lucide-x");
1439 expect(markup).toContain('aria-label="Tool call failed"');
14171440 // Ordinary tool failures render muted, not red.
14181441 expect(markup).not.toContain("text-destructive");
14191442 });
apps/web/src/components/chat/MessagesTimeline.tsx+164-22
......@@ -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 (LiveElapsed) handle it.
141// components (WorkingTimer, LiveElapsed) handle it.
142142// ---------------------------------------------------------------------------
143143
144144interface TimelineRowSharedState {
......@@ -164,6 +164,8 @@ 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;
167169}
168170
169171const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
......@@ -216,6 +218,8 @@ interface MessagesTimelineProps {
216218 agentPanelModel?: AgentPanelModel;
217219 onOpenAgents?: () => void;
218220 isWorking: boolean;
221 workingStepLabel?: string | null;
222 activeTurnStartedAt: string | null;
219223 listRef: React.RefObject<LegendListRef | null>;
220224 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
221225 latestTurn: TimelineLatestTurn | null;
......@@ -258,6 +262,8 @@ interface MessagesTimelineProps {
258262
259263export const MessagesTimeline = memo(function MessagesTimeline({
260264 isWorking,
265 workingStepLabel = null,
266 activeTurnStartedAt,
261267 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
262268 onOpenAgents = NOOP_OPEN_AGENTS,
263269 listRef,
......@@ -420,6 +426,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
420426 expandedTurnIds,
421427 expandedWorkGroupIds,
422428 isWorking,
429 activeTurnStartedAt,
423430 turnDiffSummaryByAssistantMessageId,
424431 revertTurnCountByUserMessageId,
425432 }),
......@@ -430,6 +437,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
430437 expandedTurnIds,
431438 expandedWorkGroupIds,
432439 isWorking,
440 activeTurnStartedAt,
433441 turnDiffSummaryByAssistantMessageId,
434442 revertTurnCountByUserMessageId,
435443 ],
......@@ -558,8 +566,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
558566 isWorking,
559567 isRevertingCheckpoint,
560568 latestTurnId: latestTurn?.turnId ?? null,
569 workingStepLabel,
561570 }),
562 [isRevertingCheckpoint, isWorking, latestTurn?.turnId],
571 [isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel],
563572 );
564573
565574 // Stable renderItem — no closure deps. Row components read shared state
......@@ -937,7 +946,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
937946 const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;
938947 const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
939948 const isExpandedToolGroupHeader =
940 (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);
949 (row.kind === "work-toggle" && row.summary !== null && row.onlyToolEntries && row.expanded) ||
950 (row.kind === "work-live" && row.expanded);
941951
942952 return (
943953 <div
......@@ -950,7 +960,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
950960 : "pb-0"
951961 : isExpandedToolGroupHeader
952962 ? "pb-0"
953 : row.kind === "turn-fold"
963 : row.kind === "turn-fold" || row.kind === "working"
954964 ? "pb-1.5"
955965 : (row.kind === "message" &&
956966 row.message.role === "assistant" &&
......@@ -983,6 +993,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
983993 ) : null}
984994 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
985995 {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null}
996 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
986997 </div>
987998 );
988999});
......@@ -1266,7 +1277,7 @@ function ProposedPlanTimelineRow({
12661277
12671278/**
12681279 * Inline folded plan chip: one row per turn that produced plan/todo steps.
1269 * Collapsed by default, with a segment bar and the current step label,
1280 * Collapsed by default — a segment bar plus the in-progress step label —
12701281 * and expands in place to the full step list. Replaces the old plan sidebar.
12711282 */
12721283const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
......@@ -1288,19 +1299,15 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
12881299 const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon;
12891300
12901301 return (
1291 <div className="min-w-0 py-0.5">
1302 <div className="min-w-0 px-1 py-0.5">
12921303 <button
12931304 type="button"
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"
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"
12951306 aria-expanded={expanded}
12961307 onClick={() => setExpanded((value) => !value)}
12971308 >
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 ? (
1309 <Chevron className="size-3.5 shrink-0 text-muted-foreground/65" />
1310 {steps.length > 1 ? (
13041311 <span aria-hidden className="flex shrink-0 items-center gap-0.5">
13051312 {steps.map((step) => (
13061313 <span
......@@ -1332,7 +1339,7 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
13321339 ) : null}
13331340 </button>
13341341 {expanded ? (
1335 <div className="mt-0.5 space-y-px pl-8">
1342 <div className="mt-0.5 space-y-px pl-6">
13361343 {steps.map((step) => (
13371344 <div key={step.step} className="flex items-baseline gap-2 text-[12px] leading-5">
13381345 <span
......@@ -1368,6 +1375,61 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
13681375 );
13691376});
13701377
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
13711433// ---------------------------------------------------------------------------
13721434// Extracted row sections — own their state / store subscriptions so changes
13731435// re-render only the affected row, not the entire list.
......@@ -1387,6 +1449,12 @@ const WorkGroupSection = memo(function WorkGroupSection({
13871449 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),
13881450 [groupedEntries, isExpandedToolGroupEntry],
13891451 );
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";
13901458 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
13911459
13921460 if (nonEmptyEntries.length === 0) return null;
......@@ -1394,8 +1462,11 @@ const WorkGroupSection = memo(function WorkGroupSection({
13941462 return (
13951463 <GroupContainer
13961464 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
1397 aria-label={isExpandedToolGroupEntry ? undefined : "Activity"}
1465 aria-label={isExpandedToolGroupEntry ? undefined : groupLabel}
13981466 >
1467 {!onlyToolEntries && (
1468 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
1469 )}
13991470 <div className="space-y-px">
14001471 {nonEmptyEntries.map((workEntry) => (
14011472 <SimpleWorkEntryRow
......@@ -1441,6 +1512,10 @@ function LiveActivityRow({
14411512 );
14421513}
14431514
1515function ThinkingActivityRow() {
1516 return <LiveActivityRow label="Thinking" />;
1517}
1518
14441519function LiveActivityContent({
14451520 label,
14461521 iconName,
......@@ -1524,8 +1599,8 @@ function toolGroupSummaryIconName(
15241599 return "bot";
15251600 case "tone-tool":
15261601 return "zap";
1527 case "update":
15281602 case "mixed":
1603 case null:
15291604 return "hammer";
15301605 }
15311606}
......@@ -1536,21 +1611,61 @@ function WorkGroupToggleTimelineRow({
15361611 row: Extract<TimelineRow, { kind: "work-toggle" }>;
15371612}) {
15381613 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";
15391640 return (
15401641 <button
15411642 type="button"
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}
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 }
15441649 aria-expanded={row.expanded}
15451650 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
15461651 >
15471652 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1548 <WorkEntryIconSvg
1549 name={toolGroupSummaryIconName(row.summaryKind)}
1550 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1653 <ChevronDownIcon
1654 className={cn(
1655 "size-4 shrink-0 opacity-70 transition-transform duration-200",
1656 row.expanded && "rotate-180",
1657 )}
15511658 />
15521659 </span>
1553 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</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 )}
15541669 </button>
15551670 );
15561671}
......@@ -2048,6 +2163,33 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {
20482163// Pure helpers
20492164// ---------------------------------------------------------------------------
20502165
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
20512193type WorkEntryIconName =
20522194 | "bot"
20532195 | "check"
apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx created+22
......@@ -0,0 +1,22 @@
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 created+19
......@@ -0,0 +1,19 @@
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,6 +763,472 @@ 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
7661232 .settings-slider {
7671233 --settings-slider-progress: 0%;
7681234 --settings-slider-fill-offset: 0.5rem;
......@@ -877,6 +1343,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
8771343 all: revert;
8781344 }
8791345 }
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 }
8801352}
8811353
8821354/* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering.
......@@ -1143,6 +1615,58 @@ html[data-theme-id]:not([data-theme-id=""]) {
11431615 --terminal-selection-background: var(--app-theme-terminal-selection-background);
11441616}
11451617
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
11461670/* Theme-token dependency probes are restored synchronously, before paint. Keep
11471671 transitions from observing the temporary sentinel color in between. */
11481672html[data-theme-token-probe],
apps/web/src/session-logic.test.ts-28
......@@ -915,34 +915,6 @@ 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
946918 it("omits tool started entries and keeps completed entries", () => {
947919 const activities: OrchestrationThreadActivity[] = [
948920 makeActivity({
apps/web/src/session-logic.ts-1
......@@ -880,7 +880,6 @@ 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;
884883 if (activity.summary === "Checkpoint captured") continue;
885884 if (isNoContentRuntimeWarning(activity)) continue;
886885 if (isPlanBoundaryToolActivity(activity)) continue;
apps/web/src/versionSkew.test.ts-36
......@@ -1,5 +1,4 @@
11import { EnvironmentId } from "@t3tools/contracts";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
32import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
43
54// Pinned so the direction cases below read as fixed versions instead of
......@@ -11,9 +10,7 @@ import { APP_VERSION } from "./branding";
1110import {
1211 appendVersionMismatchHint,
1312 buildVersionMismatchDismissalKey,
14 dismissServerUpdateFailure,
1513 dismissVersionMismatch,
16 isServerUpdateFailureDismissed,
1714 isVersionMismatchDismissed,
1815 resolveServerConfigVersionMismatch,
1916 resolveServerSelfUpdateCapability,
......@@ -29,39 +26,6 @@ describe("versionSkew", () => {
2926 branding.APP_VERSION = "0.0.34";
3027 });
3128
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
6529 it("does not warn when versions match", () => {
6630 expect(resolveVersionMismatch(APP_VERSION)).toBeNull();
6731 });
apps/web/src/versionSkew.ts-13
......@@ -1,5 +1,4 @@
11import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
32import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
43import * as Schema from "effect/Schema";
54
......@@ -14,18 +13,6 @@ export interface VersionMismatch {
1413
1514export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1";
1615
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
2916const VersionMismatchDismissalsSchema = Schema.Struct({
3017 keys: Schema.Array(Schema.String),
3118});