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

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

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

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

apps/web/src/components/BranchToolbar.tsx+3-3
...@@ -40,6 +40,7 @@ import {...@@ -40,6 +40,7 @@ import {
40 MenuTrigger,40 MenuTrigger,
41} from "./ui/menu";41} from "./ui/menu";
42import { Separator } from "./ui/separator";42import { Separator } from "./ui/separator";
43import { ComposerSurface } from "./chat/ComposerSurface";
4344
44interface BranchToolbarProps {45interface BranchToolbarProps {
45 environmentId: EnvironmentId;46 environmentId: EnvironmentId;
...@@ -466,10 +467,9 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -466,10 +467,9 @@ export const BranchToolbar = memo(function BranchToolbar({
466 if (!hasActiveThread || !activeProject) return null;467 if (!hasActiveThread || !activeProject) return null;
467468
468 return (469 return (
469 <div470 <ComposerSurface.ContextStrip
470 ref={setStripElement}471 ref={setStripElement}
471 data-compact={labelsOverflow ? "" : undefined}472 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"
473 >473 >
474 {isMobile && showGitControls ? (474 {isMobile && showGitControls ? (
475 <MobileRunContextSelector475 <MobileRunContextSelector
...@@ -534,6 +534,6 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -534,6 +534,6 @@ export const BranchToolbar = memo(function BranchToolbar({
534 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}534 {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
535 />535 />
536 ) : null}536 ) : null}
537 </div>537 </ComposerSurface.ContextStrip>
538 );538 );
539});539});
apps/web/src/components/ChatView.logic.test.ts-19
...@@ -35,7 +35,6 @@ import {...@@ -35,7 +35,6 @@ import {
35 resolveSendEnvMode,35 resolveSendEnvMode,
36 resolveDraftHeroState,36 resolveDraftHeroState,
37 scheduleEnvironmentReconnectWarning,37 scheduleEnvironmentReconnectWarning,
38 shoulderTabReserve,
39 startNewThreadForProject,38 startNewThreadForProject,
40 codexArtifactTemplatePromptToAppend,39 codexArtifactTemplatePromptToAppend,
41 shouldDockDraftHeroForSubmission,40 shouldDockDraftHeroForSubmission,
...@@ -89,24 +88,6 @@ describe("artifact template composer insertion", () => {...@@ -89,24 +88,6 @@ describe("artifact template composer insertion", () => {
89 });88 });
90});89});
9190
92describe("shoulderTabReserve", () => {
93 it("ignores the top drawer when measuring the shoulder tab band", () => {
94 const elementAt = (top: number) => ({ getBoundingClientRect: () => ({ top }) }) as HTMLElement;
95 const elements = new Map<string, HTMLElement>([
96 ['[data-chat-composer-form="true"]', elementAt(20)],
97 [".chat-composer-shoulder-tab", elementAt(100)],
98 ['[data-chat-composer-main-surface="true"]', elementAt(128)],
99 ]);
100 const overlay = {
101 querySelector: (selector: string) => elements.get(selector) ?? null,
102 } as HTMLElement;
103
104 expect(shoulderTabReserve(overlay)).toBe(28);
105 elements.set(".chat-composer-tasks-tab", elementAt(100));
106 expect(shoulderTabReserve(overlay)).toBe(0);
107 });
108});
109
110describe("draft hero submission transition", () => {91describe("draft hero submission transition", () => {
111 it("does not dock the composer before a background submission", () => {92 it("does not dock the composer before a background submission", () => {
112 expect(93 expect(
apps/web/src/components/ChatView.logic.ts-10
...@@ -52,16 +52,6 @@ export function codexArtifactTemplatePromptToAppend(...@@ -52,16 +52,6 @@ export function codexArtifactTemplatePromptToAppend(
52 ? null52 ? null
53 : codexArtifactTemplateUsePrompt(template);53 : codexArtifactTemplateUsePrompt(template);
54}54}
55export function shoulderTabReserve(overlay: HTMLElement): number {
56 if (overlay.querySelector(".chat-composer-tasks-tab")) return 0;
57 const tab = overlay.querySelector<HTMLElement>(".chat-composer-shoulder-tab");
58 const surface = overlay.querySelector<HTMLElement>('[data-chat-composer-main-surface="true"]');
59 if (!tab || !surface) return 0;
60 return Math.max(
61 0,
62 Math.round(surface.getBoundingClientRect().top - tab.getBoundingClientRect().top),
63 );
64}
6555
66export function shouldDockDraftHeroForSubmission(input: {56export function shouldDockDraftHeroForSubmission(input: {
67 isDraftHeroState: boolean;57 isDraftHeroState: boolean;
apps/web/src/components/ChatView.tsx+62-81
...@@ -102,7 +102,6 @@ import {...@@ -102,7 +102,6 @@ import {
102 deriveTimelineEntries,102 deriveTimelineEntries,
103 deriveActiveWorkStartedAt,103 deriveActiveWorkStartedAt,
104 deriveActivePlanState,104 deriveActivePlanState,
105 deriveTurnPlans,
106 findLatestProposedPlan,105 findLatestProposedPlan,
107 deriveWorkLogEntries,106 deriveWorkLogEntries,
108 hasActionableProposedPlan,107 hasActionableProposedPlan,
...@@ -189,6 +188,7 @@ import {...@@ -189,6 +188,7 @@ import {
189 CheckCircle2Icon,188 CheckCircle2Icon,
190 ChevronDownIcon,189 ChevronDownIcon,
191 GitBranchIcon,190 GitBranchIcon,
191 InfoIcon,
192 Minimize2Icon,192 Minimize2Icon,
193 PaperclipIcon,193 PaperclipIcon,
194 WifiOffIcon,194 WifiOffIcon,
...@@ -322,14 +322,14 @@ import {...@@ -322,14 +322,14 @@ import {
322 threadChangeRequestSnapshotsAtom,322 threadChangeRequestSnapshotsAtom,
323 useLinkedThreadPullRequest,323 useLinkedThreadPullRequest,
324} from "./ThreadStatusIndicators";324} from "./ThreadStatusIndicators";
325import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";325import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";
326import { ComposerSurface } from "./chat/ComposerSurface";
326import {327import {
327 hasAvailableClaudeCompactionProvider,328 hasAvailableClaudeCompactionProvider,
328 hasDismissedResumeCompaction,329 hasDismissedResumeCompaction,
329 shouldOfferResumeCompaction,330 shouldOfferResumeCompaction,
330} from "./chat/ContextWindowMeter.logic";331} from "./chat/ContextWindowMeter.logic";
331import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";332import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
332import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
333import {333import {
334 DRAFT_HERO_TRANSITION_ANIMATION_ID,334 DRAFT_HERO_TRANSITION_ANIMATION_ID,
335 DRAFT_HERO_TRANSITION_DURATION_MS,335 DRAFT_HERO_TRANSITION_DURATION_MS,
...@@ -356,7 +356,6 @@ import {...@@ -356,7 +356,6 @@ import {
356 shouldDockDraftHeroForSubmission,356 shouldDockDraftHeroForSubmission,
357 shouldReleaseTimelineAnchorForToolActivity,357 shouldReleaseTimelineAnchorForToolActivity,
358 shouldShowBranchMismatchBanner,358 shouldShowBranchMismatchBanner,
359 shoulderTabReserve,
360 shouldShowPlanFollowUpPrompt,359 shouldShowPlanFollowUpPrompt,
361 getStartedThreadModelChangeBlockReason,360 getStartedThreadModelChangeBlockReason,
362 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,361 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
...@@ -410,10 +409,13 @@ import {...@@ -410,10 +409,13 @@ import {
410 AlertDialogTitle,409 AlertDialogTitle,
411} from "./ui/alert-dialog";410} from "./ui/alert-dialog";
412import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";411import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
413import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction";412import { ServerUpdateAction } from "./ServerUpdateAction";
413import { ComposerServerUpdateStatus } from "./chat/ComposerServerUpdateStatus";
414import {414import {
415 buildVersionMismatchDismissalKey,415 buildVersionMismatchDismissalKey,
416 dismissServerUpdateFailure,
416 dismissVersionMismatch,417 dismissVersionMismatch,
418 isServerUpdateFailureDismissed,
417 isVersionMismatchDismissed,419 isVersionMismatchDismissed,
418 resolveServerConfigVersionMismatch,420 resolveServerConfigVersionMismatch,
419 resolveServerSelfUpdateCapability,421 resolveServerSelfUpdateCapability,
...@@ -2180,6 +2182,12 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2180,6 +2182,12 @@ function ChatViewContent(props: ChatViewProps) {
2180 const serverUpdateState = useAtomValue(2182 const serverUpdateState = useAtomValue(
2181 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),2183 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
2182 );2184 );
2185 const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState<
2186 typeof serverUpdateState | null
2187 >(null);
2188 const serverUpdateFailureDismissed =
2189 serverUpdateState === dismissedServerUpdateState ||
2190 isServerUpdateFailureDismissed(serverUpdateState);
2183 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {2191 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
2184 const items: ComposerBannerStackItem[] = [];2192 const items: ComposerBannerStackItem[] = [];
2185 const updateRunning = serverUpdateState.status === "running";2193 const updateRunning = serverUpdateState.status === "running";
...@@ -2207,8 +2215,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2207,8 +2215,8 @@ function ChatViewContent(props: ChatViewProps) {
2207 items.push({2215 items.push({
2208 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,2216 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
2209 variant: "default",2217 variant: "default",
2210 // Live connection status: calm styling, but it must front the stack.2218 // Prioritize live connection progress among the notices.
2211 urgent: true,2219 priority: "urgent",
2212 icon: (2220 icon: (
2213 <span2221 <span
2214 className="size-1.5 animate-status-pulse rounded-full bg-foreground"2222 className="size-1.5 animate-status-pulse rounded-full bg-foreground"
...@@ -2255,29 +2263,24 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2255,29 +2263,24 @@ function ChatViewContent(props: ChatViewProps) {
2255 if (2263 if (
2256 serverUpdateEnvironmentId &&2264 serverUpdateEnvironmentId &&
2257 !reconnectingThroughVersionSkew &&2265 !reconnectingThroughVersionSkew &&
2258 (serverUpdateState.status !== "idle" ||2266 (serverUpdateState.status === "idle"
2259 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))2267 ? showVersionMismatchBanner
2268 : !serverUpdateFailureDismissed)
2260 ) {2269 ) {
2261 const updateInProgress = serverUpdateState.status === "running";2270 const updateInProgress = serverUpdateState.status === "running";
2262 const updateFailed = serverUpdateState.status === "failed";2271 const updateFailed = serverUpdateState.status === "failed";
2263 items.push({2272 items.push({
2264 id: `server-version:${serverUpdateEnvironmentId}`,2273 id: `server-version:${serverUpdateEnvironmentId}`,
2265 variant: updateFailed ? "error" : "default",2274 variant: updateFailed ? "error" : "default",
2266 // A running update is live progress the user is waiting on; only the2275 // Prioritize update progress over passive notices, but keep activity attached.
2267 // idle "update available" offer is calm enough to stack behind.2276 priority: updateInProgress ? "urgent" : "notice",
2268 urgent: updateInProgress,2277 icon: <InfoIcon aria-hidden />,
2269 // In-flight and failed states carry their own status dot inside
2270 // ServerUpdateProgress; only the idle offer needs an icon.
2271 icon:
2272 updateInProgress || updateFailed ? null : (
2273 <span
2274 className="size-1.5 rounded-full border border-muted-foreground/40"
2275 aria-hidden="true"
2276 />
2277 ),
2278 title:2278 title:
2279 updateInProgress || updateFailed ? (2279 updateInProgress || updateFailed ? (
2280 `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`2280 <ComposerServerUpdateStatus
2281 state={serverUpdateState}
2282 serverLabel={versionMismatchServerLabel}
2283 />
2281 ) : versionMismatch ? (2284 ) : versionMismatch ? (
2282 <Tooltip>2285 <Tooltip>
2283 <TooltipTrigger2286 <TooltipTrigger
...@@ -2296,11 +2299,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2296,11 +2299,9 @@ function ChatViewContent(props: ChatViewProps) {
2296 "Server update available"2299 "Server update available"
2297 ),2300 ),
2298 description:2301 description:
2299 updateInProgress || updateFailed ? (2302 !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed"
2300 <ServerUpdateProgress state={serverUpdateState} />2303 ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2301 ) : versionMismatchSelfUpdate === "desktop-managed" ? (2304 : undefined,
2302 serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2303 ) : null,
2304 // The desktop-managed guidance is already the description; the action2305 // The desktop-managed guidance is already the description; the action
2305 // slot would only repeat it.2306 // slot would only repeat it.
2306 actions:2307 actions:
...@@ -2315,11 +2316,15 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2315,11 +2316,15 @@ function ChatViewContent(props: ChatViewProps) {
2315 label={updateFailed ? "Retry" : "Update"}2316 label={updateFailed ? "Retry" : "Update"}
2316 />2317 />
2317 ),2318 ),
2318 ...(updateInProgress || updateFailed || !versionMismatchDismissKey2319 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)
2319 ? {}2320 ? {}
2320 : {2321 : {
2321 dismissLabel: "Dismiss update notice",2322 dismissLabel: "Dismiss update notice",
2322 onDismiss: () => {2323 onDismiss: () => {
2324 if (updateFailed) {
2325 dismissServerUpdateFailure(serverUpdateState);
2326 setDismissedServerUpdateState(serverUpdateState);
2327 }
2323 dismissVersionMismatch(versionMismatchDismissKey);2328 dismissVersionMismatch(versionMismatchDismissKey);
2324 setDismissedVersionMismatchKey(versionMismatchDismissKey);2329 setDismissedVersionMismatchKey(versionMismatchDismissKey);
2325 },2330 },
...@@ -2334,6 +2339,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2334,6 +2339,7 @@ function ChatViewContent(props: ChatViewProps) {
2334 navigate,2339 navigate,
2335 setDismissedVersionMismatchKey,2340 setDismissedVersionMismatchKey,
2336 showVersionMismatchBanner,2341 showVersionMismatchBanner,
2342 serverUpdateFailureDismissed,
2337 serverUpdateState,2343 serverUpdateState,
2338 versionMismatch,2344 versionMismatch,
2339 versionMismatchDismissKey,2345 versionMismatchDismissKey,
...@@ -2361,7 +2367,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2361,7 +2367,6 @@ function ChatViewContent(props: ChatViewProps) {
2361 [threadActivities],2367 [threadActivities],
2362 );2368 );
2363 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);2369 const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]);
2364 const turnPlans = useMemo(() => deriveTurnPlans(threadActivities), [threadActivities]);
2365 // Native subagent fold: memoized by activity-list identity, shared by the2370 // Native subagent fold: memoized by activity-list identity, shared by the
2366 // Agents surface, live strip, and workflow cards. v2Projection is null2371 // Agents surface, live strip, and workflow cards. v2Projection is null
2367 // until orchestration-v2 lands (source precedence lives in the derive).2372 // until orchestration-v2 lands (source precedence lives in the derive).
...@@ -2431,7 +2436,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2431,7 +2436,7 @@ function ChatViewContent(props: ChatViewProps) {
2431 // Current step for the in-chat working row: only for the running turn's own2436 // Current step for the in-chat working row: only for the running turn's own
2432 // plan (deriveActivePlanState falls back to older turns' plans, which must2437 // plan (deriveActivePlanState falls back to older turns' plans, which must
2433 // not label fresh work). Falls back to the first pending step so an2438 // not label fresh work). Falls back to the first pending step so an
2434 // all-pending freshly written plan labels the row, matching the chip and2439 // all-pending freshly written plan labels the row, matching the composer and
2435 // the server's planProgress.2440 // the server's planProgress.
2436 const workingStepLabel = useMemo(() => {2441 const workingStepLabel = useMemo(() => {
2437 if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) {2442 if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) {
...@@ -2828,13 +2833,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2828,13 +2833,8 @@ function ChatViewContent(props: ChatViewProps) {
2828 ]);2833 ]);
2829 const timelineEntries = useMemo(2834 const timelineEntries = useMemo(
2830 () =>2835 () =>
2831 deriveTimelineEntries(2836 deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries),
2832 timelineMessages,2837 [activeThread?.proposedPlans, timelineMessages, workLogEntries],
2833 activeThread?.proposedPlans ?? [],
2834 workLogEntries,
2835 turnPlans,
2836 ),
2837 [activeThread?.proposedPlans, timelineMessages, turnPlans, workLogEntries],
2838 );2838 );
2839 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);2839 const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState<string | null>(null);
2840 const draftHeroDockRequested =2840 const draftHeroDockRequested =
...@@ -4553,10 +4553,20 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4553,10 +4553,20 @@ function ChatViewContent(props: ChatViewProps) {
4553 // partition (same shell, same capability gate, same PR auto-settle input)4553 // partition (same shell, same capability gate, same PR auto-settle input)
4554 // so the banner and the sidebar row never disagree.4554 // so the banner and the sidebar row never disagree.
4555 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);4555 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
4556 const activeComposerTasksProgress =4556 const activeComposerTasksProgress = useMemo(() => {
4557 activeLatestTurn !== null && !latestTurnSettled4557 if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) {
4558 ? (activeThreadShell?.planProgress ?? null)4558 return null;
4559 : null;4559 }
4560 const currentStep =
4561 activePlan.steps.find((step) => step.status === "inProgress") ??
4562 activePlan.steps.find((step) => step.status === "pending");
4563 if (!currentStep) return null;
4564 return {
4565 step: currentStep.step,
4566 completedSteps: activePlan.steps.filter((step) => step.status === "completed").length,
4567 totalSteps: activePlan.steps.length,
4568 };
4569 }, [activeLatestTurn, activePlan, latestTurnSettled]);
4560 const activeComposerTaskSteps =4570 const activeComposerTaskSteps =
4561 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId4571 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
4562 ? activePlan.steps4572 ? activePlan.steps
...@@ -4571,9 +4581,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4571,9 +4581,8 @@ function ChatViewContent(props: ChatViewProps) {
4571 setComposerOverlayHeight((currentHeight) =>4581 setComposerOverlayHeight((currentHeight) =>
4572 currentHeight === nextHeight ? currentHeight : nextHeight,4582 currentHeight === nextHeight ? currentHeight : nextHeight,
4573 );4583 );
4574 const nextClearance = Math.max(0, nextHeight - shoulderTabReserve(composerOverlayElement));
4575 setScrollToEndClearance((currentClearance) =>4584 setScrollToEndClearance((currentClearance) =>
4576 currentClearance === nextClearance ? currentClearance : nextClearance,4585 currentClearance === nextHeight ? currentClearance : nextHeight,
4577 );4586 );
4578 };4587 };
45794588
...@@ -4582,11 +4591,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4582,11 +4591,8 @@ function ChatViewContent(props: ChatViewProps) {
45824591
4583 const resizeObserver = new ResizeObserver(updateHeight);4592 const resizeObserver = new ResizeObserver(updateHeight);
4584 resizeObserver.observe(composerOverlayElement);4593 resizeObserver.observe(composerOverlayElement);
4585 const tabObserver = new MutationObserver(updateHeight);
4586 tabObserver.observe(composerOverlayElement, { childList: true, subtree: true });
4587 return () => {4594 return () => {
4588 resizeObserver.disconnect();4595 resizeObserver.disconnect();
4589 tabObserver.disconnect();
4590 };4596 };
4591 }, [composerOverlayElement]);4597 }, [composerOverlayElement]);
45924598
...@@ -4914,6 +4920,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4914,6 +4920,7 @@ function ChatViewContent(props: ChatViewProps) {
4914 return {4920 return {
4915 id: `background-liveness:${activeThread.id}`,4921 id: `background-liveness:${activeThread.id}`,
4916 variant: "default",4922 variant: "default",
4923 priority: "activity",
4917 icon: (4924 icon: (
4918 <span4925 <span
4919 className={cn("size-1.5 rounded-full bg-foreground", working && "animate-status-pulse")}4926 className={cn("size-1.5 rounded-full bg-foreground", working && "animate-status-pulse")}
...@@ -4960,13 +4967,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4960,13 +4967,6 @@ function ChatViewContent(props: ChatViewProps) {
4960 onDismiss: acknowledgeActiveThreadWoke,4967 onDismiss: acknowledgeActiveThreadWoke,
4961 };4968 };
4962 }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);4969 }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);
4963 // The stack renders items[0] front-most and tucks the rest behind hover, so
4964 // ordering is priority: urgent system banners (error/warning variants plus
4965 // calm-styled live states flagged `urgent`, like update progress), then
4966 // background liveness — its Stop button is the only stop affordance for
4967 // settled turns, so a passive "update available" notice must not cover it —
4968 // then calm system banners, the woke and branch-mismatch notices, and the
4969 // informational parked-thread banner last — it must never cover another.
4970 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {4970 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
4971 if (!activeThreadSnoozed && !activeThreadSettled) {4971 if (!activeThreadSnoozed && !activeThreadSettled) {
4972 return null;4972 return null;
...@@ -5117,10 +5117,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5117,10 +5117,6 @@ function ChatViewContent(props: ChatViewProps) {
5117 void handleSwitchCheckoutToThread();5117 void handleSwitchCheckoutToThread();
5118 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);5118 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
5119 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {5119 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
5120 const isUrgentSystemItem = (item: ComposerBannerStackItem) =>
5121 item.urgent === true || item.variant === "error" || item.variant === "warning";
5122 const urgentSystemItems = systemComposerBannerItems.filter(isUrgentSystemItem);
5123 const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item));
5124 const backgroundLivenessItems =5120 const backgroundLivenessItems =
5125 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];5121 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
5126 const resumeCompactionItems =5122 const resumeCompactionItems =
...@@ -5129,18 +5125,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5129,18 +5125,16 @@ function ChatViewContent(props: ChatViewProps) {
5129 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];5125 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
5130 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {5126 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
5131 return [5127 return [
5132 ...urgentSystemItems,5128 ...systemComposerBannerItems,
5133 ...backgroundLivenessItems,5129 ...backgroundLivenessItems,
5134 ...calmSystemItems,
5135 ...resumeCompactionItems,5130 ...resumeCompactionItems,
5136 ...wokeThreadItems,5131 ...wokeThreadItems,
5137 ...parkedThreadItems,5132 ...parkedThreadItems,
5138 ];5133 ];
5139 }5134 }
5140 return [5135 return [
5141 ...urgentSystemItems,5136 ...systemComposerBannerItems,
5142 ...backgroundLivenessItems,5137 ...backgroundLivenessItems,
5143 ...calmSystemItems,
5144 ...resumeCompactionItems,5138 ...resumeCompactionItems,
5145 ...wokeThreadItems,5139 ...wokeThreadItems,
5146 {5140 {
...@@ -7147,8 +7141,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7147,8 +7141,6 @@ function ChatViewContent(props: ChatViewProps) {
7147 setDragActive: setIsWorkspaceFileDragActive,7141 setDragActive: setIsWorkspaceFileDragActive,
7148 addFiles: (files) => composerRef.current?.addDroppedFiles(files),7142 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
7149 });7143 });
7150 const externalComposerDrawerAttached =
7151 composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable);
71527144
7153 return (7145 return (
7154 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">7146 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
...@@ -7317,7 +7309,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7317,7 +7309,7 @@ function ChatViewContent(props: ChatViewProps) {
7317 {isDraftHeroState ? (7309 {isDraftHeroState ? (
7318 <div className="absolute inset-x-0 bottom-full z-0">7310 <div className="absolute inset-x-0 bottom-full z-0">
7319 <div7311 <div
7320 className="pb-8 group-has-[.chat-composer-shoulder-tab]/composer-stack:pb-4"7312 className="pb-8 group-has-data-[composer-shoulder-tab]/composer-stack:pb-4"
7321 style={7313 style={
7322 forceExpandedMobileComposer7314 forceExpandedMobileComposer
7323 ? {7315 ? {
...@@ -7331,13 +7323,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7331,13 +7323,7 @@ function ChatViewContent(props: ChatViewProps) {
7331 activeProjectTitle={activeProject?.title ?? null}7323 activeProjectTitle={activeProject?.title ?? null}
7332 />7324 />
7333 </div>7325 </div>
7334 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
7335 </div>7326 </div>
7336 ) : (
7337 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
7338 )}
7339 {threadSyncPhase && !activeEnvironmentUnavailable ? (
7340 <ThreadSyncStatusPill phase={threadSyncPhase} />
7341 ) : null}7327 ) : null}
7342 <div7328 <div
7343 className="relative"7329 className="relative"
...@@ -7347,14 +7333,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7347,14 +7333,8 @@ function ChatViewContent(props: ChatViewProps) {
7347 : undefined7333 : undefined
7348 }7334 }
7349 >7335 >
7350 <div7336 <ComposerSurface.Shell contextStrip={showComposerContextStrip}>
7351 className={cn(7337 <ComposerSurface.Host>
7352 "chat-composer-glass-shell relative mx-auto w-full max-w-3xl",
7353 externalComposerDrawerAttached && "chat-composer-glass-shell-attached",
7354 showComposerContextStrip && "chat-composer-glass-shell-with-context",
7355 )}
7356 >
7357 <div className="chat-composer-glass-host relative z-10 w-full rounded-[22px]">
7358 <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">7338 <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">
7359 <ChatComposer7339 <ChatComposer
7360 composerRef={composerRef}7340 composerRef={composerRef}
...@@ -7384,7 +7364,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7384,7 +7364,7 @@ function ChatViewContent(props: ChatViewProps) {
7384 : null7364 : null
7385 }7365 }
7386 isPreparingWorktree={isPreparingWorktree}7366 isPreparingWorktree={isPreparingWorktree}
7387 externalDrawerAttached={externalComposerDrawerAttached}7367 bannerItems={composerBannerItems}
7388 environmentUnavailable={activeEnvironmentUnavailableState}7368 environmentUnavailable={activeEnvironmentUnavailableState}
7389 activePendingApproval={activePendingApproval}7369 activePendingApproval={activePendingApproval}
7390 pendingApprovals={pendingApprovals}7370 pendingApprovals={pendingApprovals}
...@@ -7399,6 +7379,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7399,6 +7379,7 @@ function ChatViewContent(props: ChatViewProps) {
7399 activeProposedPlan={activeProposedPlan}7379 activeProposedPlan={activeProposedPlan}
7400 activeTasksProgress={activeComposerTasksProgress}7380 activeTasksProgress={activeComposerTasksProgress}
7401 activeTaskSteps={activeComposerTaskSteps}7381 activeTaskSteps={activeComposerTaskSteps}
7382 threadSyncPhase={activeEnvironmentUnavailable ? null : threadSyncPhase}
7402 runtimeMode={runtimeMode}7383 runtimeMode={runtimeMode}
7403 interactionMode={interactionMode}7384 interactionMode={interactionMode}
7404 lockedProvider={lockedProvider}7385 lockedProvider={lockedProvider}
...@@ -7447,7 +7428,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7447,7 +7428,7 @@ function ChatViewContent(props: ChatViewProps) {
7447 openingVideoAttachmentId={openingVideoAttachmentId}7428 openingVideoAttachmentId={openingVideoAttachmentId}
7448 />7429 />
7449 </div>7430 </div>
7450 </div>7431 </ComposerSurface.Host>
7451 <div className="min-h-0">7432 <div className="min-h-0">
7452 <div7433 <div
7453 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}7434 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}
...@@ -7485,7 +7466,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7485,7 +7466,7 @@ function ChatViewContent(props: ChatViewProps) {
7485 )}7466 )}
7486 </div>7467 </div>
7487 </div>7468 </div>
7488 </div>7469 </ComposerSurface.Shell>
7489 <div7470 <div
7490 aria-hidden7471 aria-hidden
7491 className="h-[calc(env(safe-area-inset-bottom)+1rem)] sm:h-[calc(env(safe-area-inset-bottom)+1.25rem)]"7472 className="h-[calc(env(safe-area-inset-bottom)+1rem)] sm:h-[calc(env(safe-area-inset-bottom)+1.25rem)]"
apps/web/src/components/chat/ChatComposer.tsx+213-204
...@@ -11,7 +11,6 @@ import type {...@@ -11,7 +11,6 @@ import type {
11 ScopedThreadRef,11 ScopedThreadRef,
12 ServerProvider,12 ServerProvider,
13 ThreadId,13 ThreadId,
14 TurnId,
15} from "@t3tools/contracts";14} from "@t3tools/contracts";
16import {15import {
17 ProviderDriverKind,16 ProviderDriverKind,
...@@ -75,10 +74,20 @@ import { ComposerStashBadge } from "./ComposerStashBadge";...@@ -75,10 +74,20 @@ import { ComposerStashBadge } from "./ComposerStashBadge";
75import { ComposerStashMenu } from "./ComposerStashMenu";74import { ComposerStashMenu } from "./ComposerStashMenu";
76import {75import {
77 ComposerTasksBadge,76 ComposerTasksBadge,
77 ComposerTasksContent,
78 ComposerTasksDrawer,78 ComposerTasksDrawer,
79 type ComposerTaskStep,79 type ComposerTaskStep,
80 type ComposerTasksProgress,80 type ComposerTasksProgress,
81} from "./ComposerTasksBadge";81} from "./ComposerTasksBadge";
82import { ComposerActivityRow } from "./ComposerActivityStatus";
83import type { ThreadSyncPhase } from "../../threadSync";
84import { ComposerBanner } from "./ComposerBanner";
85import { ComposerSurface } from "./ComposerSurface";
86import {
87 ComposerBannerStack,
88 type ComposerBannerStackContent,
89 type ComposerBannerStackItem,
90} from "./ComposerBannerStack";
82import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";91import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
83import {92import {
84 fileAttachmentTooLargeMessage,93 fileAttachmentTooLargeMessage,
...@@ -628,7 +637,7 @@ export interface ChatComposerProps {...@@ -628,7 +637,7 @@ export interface ChatComposerProps {
628 isSendBusy: boolean;637 isSendBusy: boolean;
629 sendDisabledReason: string | null;638 sendDisabledReason: string | null;
630 isPreparingWorktree: boolean;639 isPreparingWorktree: boolean;
631 externalDrawerAttached: boolean;640 bannerItems: readonly ComposerBannerStackItem[];
632 environmentUnavailable: {641 environmentUnavailable: {
633 readonly label: string;642 readonly label: string;
634 readonly connection: EnvironmentConnectionPresentation;643 readonly connection: EnvironmentConnectionPresentation;
...@@ -656,6 +665,7 @@ export interface ChatComposerProps {...@@ -656,6 +665,7 @@ export interface ChatComposerProps {
656 activeProposedPlan: Thread["proposedPlans"][number] | null;665 activeProposedPlan: Thread["proposedPlans"][number] | null;
657 activeTasksProgress: ComposerTasksProgress | null;666 activeTasksProgress: ComposerTasksProgress | null;
658 activeTaskSteps: readonly ComposerTaskStep[] | null;667 activeTaskSteps: readonly ComposerTaskStep[] | null;
668 threadSyncPhase: ThreadSyncPhase | null;
659669
660 // Mode670 // Mode
661 runtimeMode: RuntimeMode;671 runtimeMode: RuntimeMode;
...@@ -758,8 +768,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -758,8 +768,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
758 respondingRequestIds,768 respondingRequestIds,
759 showPlanFollowUpPrompt,769 showPlanFollowUpPrompt,
760 activeProposedPlan,770 activeProposedPlan,
761 activeTasksProgress,
762 activeTaskSteps,
763 runtimeMode,771 runtimeMode,
764 interactionMode,772 interactionMode,
765 lockedProvider,773 lockedProvider,
...@@ -800,6 +808,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -800,6 +808,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
800 onFileOpen,808 onFileOpen,
801 openingVideoAttachmentId,809 openingVideoAttachmentId,
802 } = props;810 } = props;
811 const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;
812 const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;
803 // ------------------------------------------------------------------813 // ------------------------------------------------------------------
804 // Store subscriptions (prompt / images / terminal contexts)814 // Store subscriptions (prompt / images / terminal contexts)
805 // ------------------------------------------------------------------815 // ------------------------------------------------------------------
...@@ -1207,7 +1217,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1207,7 +1217,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1207 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);1217 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
1208 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);1218 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
1209 const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);1219 const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);
1210 const [dismissedTasksTurnId, setDismissedTasksTurnId] = useState<TurnId | null>(null);
1211 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({1220 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
1212 key: 0,1221 key: 0,
1213 active: false,1222 active: false,
...@@ -2611,6 +2620,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2611,6 +2620,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2611 const deleteStashEntry = useCallback(2620 const deleteStashEntry = useCallback(
2612 (entry: PromptStashEntry) => {2621 (entry: PromptStashEntry) => {
2613 const { entry: removed, durable } = takeStashEntry(entry.id);2622 const { entry: removed, durable } = takeStashEntry(entry.id);
2623 if (!stashQueue.some((candidate) => candidate.id !== entry.id)) {
2624 setIsStashMenuOpen(false);
2625 }
2614 if (durable && removed) {2626 if (durable && removed) {
2615 for (const file of removed.files ?? []) {2627 for (const file of removed.files ?? []) {
2616 releasePersistedAttachmentUpload({2628 releasePersistedAttachmentUpload({
...@@ -2630,7 +2642,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2630,7 +2642,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2630 });2642 });
2631 }2643 }
2632 },2644 },
2633 [takeStashEntry],2645 [stashQueue, takeStashEntry],
2634 );2646 );
26352647
2636 const stashCurrentPrompt = useCallback(async () => {2648 const stashCurrentPrompt = useCallback(async () => {
...@@ -2830,81 +2842,70 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2830,81 +2842,70 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2830 ]);2842 ]);
28312843
2832 const toggleStashMenu = useCallback(() => {2844 const toggleStashMenu = useCallback(() => {
2833 setIsStashMenuOpen((open) => !open);
2834 }, []);
2835 const toggleInlineStashMenu = useCallback(() => {
2836 if (isComposerCollapsedMobile) {2845 if (isComposerCollapsedMobile) {
2837 expandMobileComposer();2846 expandMobileComposer();
2838 setIsStashMenuOpen(true);2847 setIsStashMenuOpen(true);
2839 return;2848 return;
2840 }2849 }
2841 toggleStashMenu();2850 setIsStashMenuOpen((open) => !open);
2842 }, [expandMobileComposer, isComposerCollapsedMobile, toggleStashMenu]);2851 }, [expandMobileComposer, isComposerCollapsedMobile]);
2843 const toggleTasksDrawer = useCallback(() => {2852 const toggleTasksDrawer = useCallback(() => {
2844 setIsTasksDrawerOpen((open) => !open);2853 setIsTasksDrawerOpen((open) => !open);
2845 }, []);2854 }, []);
2846 const activeTasksTurnId = activeThread?.latestTurn?.turnId ?? null;2855 const hasBannerItems = props.bannerItems.length > 0;
2847 const tasksDismissedForActiveTurn =
2848 activeTasksTurnId !== null && dismissedTasksTurnId === activeTasksTurnId;
2849 const visibleTasksProgress = tasksDismissedForActiveTurn ? null : activeTasksProgress;
2850 const visibleTaskSteps = tasksDismissedForActiveTurn ? null : activeTaskSteps;
2851 const hasBlockingComposerTopDrawer =2856 const hasBlockingComposerTopDrawer =
2852 activePendingApproval !== null || pendingUserInputs.length > 0;2857 activePendingApproval !== null || pendingUserInputs.length > 0;
2853 const dismissTasks = useCallback(() => {
2854 if (activeTasksTurnId !== null) {
2855 setDismissedTasksTurnId(activeTasksTurnId);
2856 }
2857 setIsTasksDrawerOpen(false);
2858 }, [activeTasksTurnId]);
2859 const showInlineStashBadge =
2860 stashQueue.length > 0 &&
2861 !isComposerApprovalState &&
2862 (props.externalDrawerAttached ||
2863 showComposerTopDrawer ||
2864 isTasksDrawerOpen ||
2865 isComposerCollapsedMobile);
2866 const inlineStashBadge = showInlineStashBadge ? (
2867 <ComposerStashBadge
2868 count={stashQueue.length}
2869 menuOpen={isStashMenuOpen}
2870 placement="inline"
2871 pulseKey={stashPulse.key}
2872 pulsing={stashPulse.active}
2873 onToggleMenu={toggleInlineStashMenu}
2874 />
2875 ) : null;
2876 const showInlineTasksBadge =2858 const showInlineTasksBadge =
2877 visibleTasksProgress !== null &&2859 activeTasksProgress !== null &&
2878 visibleTaskSteps !== null &&2860 activeTaskSteps !== null &&
2879 !isTasksDrawerOpen &&2861 !isTasksDrawerOpen &&
2880 !hasBlockingComposerTopDrawer &&2862 !hasBlockingComposerTopDrawer &&
2881 (props.externalDrawerAttached || showComposerTopDrawer || isComposerCollapsedMobile);2863 (hasBannerItems || showComposerTopDrawer || isComposerCollapsedMobile);
2882 const inlineTasksBadge = showInlineTasksBadge ? (2864 const inlineTasksBadge = showInlineTasksBadge ? (
2883 <ComposerTasksBadge2865 <ComposerTasksBadge
2884 expanded={false}2866 expanded={false}
2885 onDismiss={dismissTasks}
2886 onToggle={toggleTasksDrawer}2867 onToggle={toggleTasksDrawer}
2887 placement="inline"2868 placement="inline"
2888 progress={visibleTasksProgress}2869 progress={activeTasksProgress}
2889 steps={visibleTaskSteps}2870 steps={activeTaskSteps}
2890 />2871 />
2891 ) : null;2872 ) : null;
2892 const showShoulderTabs =2873 const showTasksTab =
2893 !props.externalDrawerAttached &&2874 !hasBannerItems &&
2894 !showComposerTopDrawer &&2875 !showComposerTopDrawer &&
2895 !isTasksDrawerOpen &&2876 !isTasksDrawerOpen &&
2896 !isComposerCollapsedMobile;2877 !isComposerCollapsedMobile &&
2897 const hasShoulderTab =2878 activeTasksProgress !== null &&
2898 showShoulderTabs &&2879 activeTaskSteps !== null &&
2899 (stashQueue.length > 0 ||2880 activeTasksProgress.totalSteps > 0;
2900 (visibleTasksProgress !== null &&2881 const activityStackContent = hasBannerItems ? (
2901 visibleTaskSteps !== null &&2882 props.threadSyncPhase ? (
2902 visibleTasksProgress.totalSteps > 0));2883 <ComposerActivityRow phase={props.threadSyncPhase} />
2884 ) : !hasBlockingComposerTopDrawer && activeTasksProgress && activeTaskSteps ? (
2885 <ComposerTasksContent
2886 expanded={isTasksDrawerOpen}
2887 onToggle={toggleTasksDrawer}
2888 progress={activeTasksProgress}
2889 steps={activeTaskSteps}
2890 />
2891 ) : null
2892 ) : null;
2893 const activityStackItem: ComposerBannerStackContent | null = activityStackContent
2894 ? {
2895 id: "composer-activity",
2896 variant: "default",
2897 priority: "activity",
2898 content: activityStackContent,
2899 }
2900 : null;
2901 const bannerStackItems = activityStackItem
2902 ? [...props.bannerItems, activityStackItem]
2903 : props.bannerItems;
2903 useEffect(() => {2904 useEffect(() => {
2904 if (visibleTasksProgress === null || visibleTaskSteps === null) {2905 if (activeTasksProgress === null || activeTaskSteps === null) {
2905 setIsTasksDrawerOpen(false);2906 setIsTasksDrawerOpen(false);
2906 }2907 }
2907 }, [visibleTaskSteps, visibleTasksProgress]);2908 }, [activeTaskSteps, activeTasksProgress]);
29082909
2909 useEffect(() => {2910 useEffect(() => {
2910 if (hasBlockingComposerTopDrawer) {2911 if (hasBlockingComposerTopDrawer) {
...@@ -3427,145 +3428,163 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3427,145 +3428,163 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3427 onDragOverCapture={composerMentionDragHandlers.onDragOver}3428 onDragOverCapture={composerMentionDragHandlers.onDragOver}
3428 onDragLeaveCapture={onComposerMentionDragLeaveCapture}3429 onDragLeaveCapture={onComposerMentionDragLeaveCapture}
3429 onDropCapture={composerMentionDragHandlers.onDrop}3430 onDropCapture={composerMentionDragHandlers.onDrop}
3430 className={cn("mx-auto w-full min-w-0 max-w-3xl", hasShoulderTab && "pt-7")}3431 className="mx-auto w-full min-w-0 max-w-3xl"
3431 data-chat-composer-form="true"3432 data-chat-composer-form="true"
3432 >3433 >
3433 {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (3434 <ComposerBanner.Dock>
3434 <div3435 <ComposerBanner.Column>
3435 className="chat-composer-top-drawer"3436 <ComposerBannerStack
3436 data-chat-composer-top-drawer="true"3437 key={activeThreadId}
3437 data-variant={activePendingApproval ? "warning" : "info"}3438 className="relative z-0"
3438 >3439 items={bannerStackItems}
3439 {!isComposerCollapsedMobile && activePendingApproval ? (3440 />
3440 <div className="flex min-w-0 flex-wrap items-center gap-1 px-3 py-1.5 sm:px-4">3441 {!activityStackItem && (props.threadSyncPhase || inlineTasksBadge) ? (
3441 <ComposerPendingApprovalPanel3442 <ComposerBanner.Attachment>
3442 approval={activePendingApproval}3443 <ComposerBanner.Root
3443 pendingCount={pendingApprovals.length}3444 width={
3444 />3445 props.threadSyncPhase && !showComposerTopDrawer && !isComposerCollapsedMobile
3445 <div className="flex min-w-0 flex-wrap items-center gap-0.5">3446 ? "content"
3446 <ComposerPendingApprovalActions3447 : "fill"
3447 requestId={activePendingApproval.requestId}3448 }
3448 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}3449 data-chat-composer-activity-strip="true"
3449 options={activePendingApproval.options}3450 >
3450 onRespondToApproval={onRespondToApproval}3451 {props.threadSyncPhase ? (
3451 />3452 <ComposerActivityRow phase={props.threadSyncPhase} />
3452 </div>3453 ) : (
3453 </div>3454 inlineTasksBadge
3454 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (3455 )}
3455 <ComposerPendingUserInputPanel3456 </ComposerBanner.Root>
3456 pendingUserInputs={pendingUserInputs}3457 </ComposerBanner.Attachment>
3457 respondingRequestIds={respondingRequestIds}3458 ) : null}
3458 answers={activePendingDraftAnswers}3459 {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
3459 questionIndex={activePendingQuestionIndex}3460 <ComposerBanner.Attachment>
3460 onToggleOption={onSelectActivePendingUserInputOption}3461 <ComposerBanner.Root
3461 onAdvance={onAdvanceActivePendingUserInput}3462 data-chat-composer-top-drawer="true"
3462 />3463 variant={activePendingApproval ? "warning" : "info"}
3463 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (3464 >
3464 <ComposerPlanFollowUpBanner3465 {activePendingApproval ? (
3465 key={activeProposedPlan.id}3466 <ComposerBanner.Row
3466 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}3467 layout="wrap-actions"
3467 />3468 data-chat-composer-collapsed-controls="true"
3468 ) : isComposerCollapsedMobile && activePendingApproval ? (
3469 <div data-chat-composer-collapsed-controls="true">
3470 <ComposerPendingApprovalPanel
3471 approval={activePendingApproval}
3472 pendingCount={pendingApprovals.length}
3473 className="px-3 pt-2 sm:px-4"
3474 />
3475 <div className="flex flex-wrap items-center justify-end gap-1 px-3 pt-2 pb-3 sm:px-4">
3476 <ComposerPendingApprovalActions
3477 requestId={activePendingApproval.requestId}
3478 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3479 options={activePendingApproval.options}
3480 onRespondToApproval={onRespondToApproval}
3481 />
3482 </div>
3483 </div>
3484 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3485 <div data-chat-composer-collapsed-controls="true">
3486 <ComposerPendingUserInputPanel
3487 pendingUserInputs={pendingUserInputs}
3488 respondingRequestIds={respondingRequestIds}
3489 answers={activePendingDraftAnswers}
3490 questionIndex={activePendingQuestionIndex}
3491 onToggleOption={onSelectActivePendingUserInputOption}
3492 onAdvance={onAdvanceActivePendingUserInput}
3493 />
3494 <div className="px-3 pb-3 sm:px-4">
3495 <div
3496 data-chat-composer-mobile-pending-compact="true"
3497 className={cn(
3498 "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80",
3499 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
3500 )}
3501 >
3502 <button
3503 type="button"
3504 className={cn(
3505 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
3506 activePendingProgress?.customAnswer ? "text-foreground" : "text-placeholder",
3507 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
3508 )}
3509 onPointerDown={(event) => event.preventDefault()}
3510 onClick={expandMobileComposer}
3511 aria-label="Write custom answer"
3512 >3469 >
3513 {activePendingProgress?.customAnswer || "Write custom answer"}3470 <ComposerBanner.Icon />
3514 </button>3471 <ComposerBanner.Content>
3515 {inlineTasksBadge}3472 <ComposerPendingApprovalPanel
3516 {inlineStashBadge}3473 approval={activePendingApproval}
3517 {activePendingProgress?.activeQuestion?.multiSelect ? (3474 pendingCount={pendingApprovals.length}
3518 <ComposerPrimaryActions3475 />
3519 compact3476 </ComposerBanner.Content>
3520 pendingAction={pendingPrimaryAction}3477 <ComposerBanner.Actions>
3521 isRunning={false}3478 <ComposerPendingApprovalActions
3522 showPlanFollowUpPrompt={false}3479 requestId={activePendingApproval.requestId}
3523 promptHasText={false}3480 isResponding={respondingRequestIds.includes(
3524 isSendBusy={isSendBusy}3481 activePendingApproval.requestId,
3525 sendDisabledReason={sendDisabledReason}3482 )}
3526 isConnecting={isConnecting}3483 options={activePendingApproval.options}
3527 isEnvironmentUnavailable={3484 onRespondToApproval={onRespondToApproval}
3528 environmentUnavailable !== null ||3485 />
3529 noProviderAvailable ||3486 </ComposerBanner.Actions>
3530 projectSelectionRequired3487 </ComposerBanner.Row>
3531 }3488 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3532 isPreparingWorktree={false}3489 <ComposerPendingUserInputPanel
3533 hasSendableContent={false}3490 pendingUserInputs={pendingUserInputs}
3534 preserveComposerFocusOnPointerDown3491 respondingRequestIds={respondingRequestIds}
3535 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}3492 answers={activePendingDraftAnswers}
3536 onInterrupt={handleInterruptPrimaryAction}3493 questionIndex={activePendingQuestionIndex}
3537 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}3494 onToggleOption={onSelectActivePendingUserInputOption}
3495 onAdvance={onAdvanceActivePendingUserInput}
3496 />
3497 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (
3498 <ComposerPlanFollowUpBanner
3499 key={activeProposedPlan.id}
3500 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
3501 />
3502 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3503 <div data-chat-composer-collapsed-controls="true">
3504 <ComposerPendingUserInputPanel
3505 pendingUserInputs={pendingUserInputs}
3506 respondingRequestIds={respondingRequestIds}
3507 answers={activePendingDraftAnswers}
3508 questionIndex={activePendingQuestionIndex}
3509 onToggleOption={onSelectActivePendingUserInputOption}
3510 onAdvance={onAdvanceActivePendingUserInput}
3538 />3511 />
3539 ) : null}3512 <ComposerBanner.Body>
3540 </div>3513 <div
3541 </div>3514 data-chat-composer-mobile-pending-compact="true"
3542 </div>3515 className={cn(
3516 "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80",
3517 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
3518 )}
3519 >
3520 <button
3521 type="button"
3522 className={cn(
3523 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
3524 activePendingProgress?.customAnswer
3525 ? "text-foreground"
3526 : "text-placeholder",
3527 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
3528 )}
3529 onPointerDown={(event) => event.preventDefault()}
3530 onClick={expandMobileComposer}
3531 aria-label="Write custom answer"
3532 >
3533 {activePendingProgress?.customAnswer || "Write custom answer"}
3534 </button>
3535 {activePendingProgress?.activeQuestion?.multiSelect ? (
3536 <ComposerPrimaryActions
3537 compact
3538 pendingAction={pendingPrimaryAction}
3539 isRunning={false}
3540 showPlanFollowUpPrompt={false}
3541 promptHasText={false}
3542 isSendBusy={isSendBusy}
3543 sendDisabledReason={sendDisabledReason}
3544 isConnecting={isConnecting}
3545 isEnvironmentUnavailable={
3546 environmentUnavailable !== null ||
3547 noProviderAvailable ||
3548 projectSelectionRequired
3549 }
3550 isPreparingWorktree={false}
3551 hasSendableContent={false}
3552 preserveComposerFocusOnPointerDown
3553 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3554 onInterrupt={handleInterruptPrimaryAction}
3555 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3556 />
3557 ) : null}
3558 </div>
3559 </ComposerBanner.Body>
3560 </div>
3561 ) : null}
3562 </ComposerBanner.Root>
3563 </ComposerBanner.Attachment>
3543 ) : null}3564 ) : null}
3544 </div>3565 {!activityStackItem &&
3545 ) : null}3566 isTasksDrawerOpen &&
3546 {isTasksDrawerOpen &&3567 !hasBlockingComposerTopDrawer &&
3547 !hasBlockingComposerTopDrawer &&3568 activeTasksProgress &&
3548 visibleTasksProgress &&3569 activeTaskSteps ? (
3549 visibleTaskSteps ? (3570 <ComposerTasksDrawer
3550 <ComposerTasksDrawer3571 onCollapse={toggleTasksDrawer}
3551 onDismiss={dismissTasks}3572 progress={activeTasksProgress}
3552 onCollapse={toggleTasksDrawer}3573 steps={activeTaskSteps}
3553 progress={visibleTasksProgress}3574 />
3554 steps={visibleTaskSteps}3575 ) : null}
3555 />3576 {showTasksTab ? (
3556 ) : null}3577 <ComposerBanner.Attachment>
3557 <div className="relative">3578 <ComposerTasksBadge
3558 {showShoulderTabs && visibleTasksProgress && visibleTaskSteps ? (3579 expanded={false}
3559 <ComposerTasksBadge3580 onToggle={toggleTasksDrawer}
3560 expanded={false}3581 progress={activeTasksProgress}
3561 hasTrailingShoulder={stashQueue.length > 0}3582 steps={activeTaskSteps}
3562 onDismiss={dismissTasks}3583 />
3563 onToggle={toggleTasksDrawer}3584 </ComposerBanner.Attachment>
3564 progress={visibleTasksProgress}3585 ) : null}
3565 steps={visibleTaskSteps}3586 </ComposerBanner.Column>
3566 />3587 {!isComposerApprovalState ? (
3567 ) : null}
3568 {showShoulderTabs ? (
3569 <ComposerStashBadge3588 <ComposerStashBadge
3570 count={stashQueue.length}3589 count={stashQueue.length}
3571 menuOpen={isStashMenuOpen}3590 menuOpen={isStashMenuOpen}
...@@ -3574,13 +3593,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3574,13 +3593,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3574 onToggleMenu={toggleStashMenu}3593 onToggleMenu={toggleStashMenu}
3575 />3594 />
3576 ) : null}3595 ) : null}
3577 <div3596 </ComposerBanner.Dock>
3578 data-chat-composer-main-surface="true"3597 <div className="relative">
3579 className={cn(3598 <ComposerSurface.Main className={composerProviderState.composerFrameClassName}>
3580 "group relative z-10 rounded-[22px] p-px transition-colors duration-200",
3581 composerProviderState.composerFrameClassName,
3582 )}
3583 >
3584 <div3599 <div
3585 ref={composerSurfaceRef}3600 ref={composerSurfaceRef}
3586 data-chat-composer-surface="true"3601 data-chat-composer-surface="true"
...@@ -3612,8 +3627,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3612,8 +3627,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3612 : prompt.trim() ||3627 : prompt.trim() ||
3613 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}3628 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
3614 </button>3629 </button>
3615 {inlineTasksBadge}
3616 {inlineStashBadge}
3617 <button3630 <button
3618 type="button"3631 type="button"
3619 className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"3632 className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
...@@ -4061,8 +4074,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4061,8 +4074,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4061 data-chat-composer-mobile-pending-actions="true"4074 data-chat-composer-mobile-pending-actions="true"
4062 className="absolute bottom-0 right-0 flex items-center justify-end gap-1"4075 className="absolute bottom-0 right-0 flex items-center justify-end gap-1"
4063 >4076 >
4064 {inlineTasksBadge}
4065 {inlineStashBadge}
4066 <ComposerPrimaryActions4077 <ComposerPrimaryActions
4067 compact4078 compact
4068 pendingAction={pendingPrimaryAction}4079 pendingAction={pendingPrimaryAction}
...@@ -4219,8 +4230,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4219,8 +4230,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4219 </Tooltip>4230 </Tooltip>
4220 </>4231 </>
4221 ) : null}4232 ) : null}
4222 {showMobilePendingAnswerActions ? null : inlineTasksBadge}
4223 {showMobilePendingAnswerActions ? null : inlineStashBadge}
4224 <ComposerFooterPrimaryActions4233 <ComposerFooterPrimaryActions
4225 compact={isComposerPrimaryActionsCompact}4234 compact={isComposerPrimaryActionsCompact}
4226 activeContextWindow={activeContextWindow}4235 activeContextWindow={activeContextWindow}
...@@ -4258,7 +4267,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4258,7 +4267,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4258 </div>4267 </div>
4259 )}4268 )}
4260 </div>4269 </div>
4261 </div>4270 </ComposerSurface.Main>
4262 </div>4271 </div>
4263 </form>4272 </form>
4264 );4273 );
apps/web/src/components/chat/ComposerActivityStatus.tsx created+22
...@@ -0,0 +1,22 @@
1import { LoaderCircleIcon } from "lucide-react";
2import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
3import { ComposerBanner } from "./ComposerBanner";
4
5export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase }) {
6 return (
7 <ComposerBanner.Row>
8 <ComposerBanner.Icon>
9 <LoaderCircleIcon className="motion-safe:animate-spin" />
10 </ComposerBanner.Icon>
11 <ComposerBanner.Content>
12 <span
13 className="shrink-0 whitespace-nowrap text-muted-foreground"
14 data-composer-sync-status={phase}
15 role="status"
16 >
17 {threadSyncLabel(phase)}
18 </span>
19 </ComposerBanner.Content>
20 </ComposerBanner.Row>
21 );
22}
apps/web/src/components/chat/ComposerBanner.tsx created+348
...@@ -0,0 +1,348 @@
1import { mergeProps } from "@base-ui/react/merge-props";
2import { useRender } from "@base-ui/react/use-render";
3import { ChevronDownIcon, XIcon } from "lucide-react";
4import type { ComponentProps } from "react";
5
6import { cn } from "~/lib/utils";
7import { Button, buttonVariants } from "../ui/button";
8import { ScrollArea } from "../ui/scroll-area";
9
10export type ComposerBannerVariant = "default" | "error" | "info" | "success" | "warning";
11
12const surfaceColors = cn(
13 "[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--card))]",
14 "dark:[--chat-composer-attached-surface:var(--chat-composer-glass-surface,color-mix(in_srgb,var(--background)_96%,var(--color-white)))]",
15 "[html[data-theme-id]_&]:[--chat-composer-attached-surface:var(--app-theme-surface-raised)]",
16);
17
18const neutralOutline = cn(
19 "[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--contrast-foreground)_8%,transparent))]",
20 "dark:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--color-white)_5%,transparent))]",
21 "[html[data-theme-id]_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,var(--app-theme-toolbar-border))]",
22 "dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--app-theme-input)_30%,var(--background)))]",
23 "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-attached-outline:#241e28]",
24);
25
26const variantColors: Record<ComposerBannerVariant, string> = {
27 default: neutralOutline,
28 error:
29 "[--chat-composer-attached-outline:color-mix(in_srgb,var(--error)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--error)_8%,transparent)]",
30 info: "[--chat-composer-attached-outline:color-mix(in_srgb,var(--info)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--info)_4%,transparent)]",
31 success:
32 "[--chat-composer-attached-outline:color-mix(in_srgb,var(--success)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--success)_4%,transparent)]",
33 warning:
34 "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]",
35};
36
37/** Shared glass and attachment seam, also used by the command menu without banner row padding. */
38function Surface({
39 placement = "attached",
40 variant = "default",
41 className,
42 ...props
43}: ComponentProps<"div"> & {
44 placement?: "attached" | "floating";
45 variant?: ComposerBannerVariant;
46}) {
47 return (
48 <div
49 data-composer-banner-surface={placement}
50 data-variant={variant}
51 className={cn(
52 surfaceColors,
53 "relative isolate border-0 bg-transparent shadow-none [--chat-composer-attached-tint:transparent]",
54 variantColors[variant],
55 placement === "attached"
56 ? "[--chat-composer-attachment-overlap:calc(1rem+1px)] before:rounded-t-[16px]"
57 : "[--chat-composer-attachment-overlap:0px] before:rounded-[1rem]",
58 "before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:border before:border-(--chat-composer-attached-outline)",
59 "before:bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint))] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)",
60 "before:mask-[linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),black_var(--chat-composer-attachment-overlap))] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]",
61 "dark:supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint)),linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),rgb(0_0_0/18%)_var(--chat-composer-attachment-overlap),transparent_calc(var(--chat-composer-attachment-overlap)+10px))]",
62 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-(--chat-composer-attached-surface)",
63 className,
64 )}
65 {...props}
66 />
67 );
68}
69
70// A peeking notice uses the first hidden notice's severity, never the attached row's.
71const peekBorder: Record<ComposerBannerVariant, string> = {
72 default: "border-(--chat-composer-attached-outline)",
73 error: "border-destructive/24",
74 info: "border-info/24",
75 success: "border-success/24",
76 warning: "border-warning/24",
77};
78
79function Peek({
80 className,
81 variant = "default",
82 ...props
83}: ComponentProps<"button"> & { variant?: ComposerBannerVariant }) {
84 return (
85 <button
86 type="button"
87 data-slot="composer-banner-peek"
88 className={cn(
89 surfaceColors,
90 neutralOutline,
91 "absolute inset-x-0 bottom-0 z-0 mx-auto h-3 w-[96%] cursor-pointer rounded-t-2xl border border-b-0 shadow-[0_6px_18px_rgb(0_0_0/6%)]",
92 "bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] backdrop-blur-(--glass-blur) backdrop-saturate-(--glass-saturation)",
93 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:bg-(--chat-composer-attached-surface)",
94 "transition-opacity duration-150 ease-out focus-visible:outline-2 focus-visible:outline-ring",
95 peekBorder[variant],
96 className,
97 )}
98 {...props}
99 />
100 );
101}
102
103function Attachment({ className, ...props }: ComponentProps<"div">) {
104 return (
105 <div
106 data-slot="composer-banner-attachment"
107 className={cn(
108 "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2.75rem)] max-w-[45.25rem]",
109 // Adjacent attachments share their outline, including notices outside the form.
110 "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0",
111 "[&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0",
112 className,
113 )}
114 {...props}
115 />
116 );
117}
118
119function Dock({ className, ...props }: ComponentProps<"div">) {
120 return (
121 <Attachment
122 className={cn(
123 "flex items-end gap-1 not-has-data-[composer-banner-surface=attached]:hidden",
124 className,
125 )}
126 {...props}
127 />
128 );
129}
130
131/** Attachments share a column while neighboring tabs keep their own surface. */
132function Column({ className, ...props }: ComponentProps<"div">) {
133 return (
134 <div
135 className={cn(
136 "flex min-w-0 flex-1 flex-col empty:hidden",
137 "[&>[data-slot=composer-banner-attachment]]:w-full [&>[data-slot=composer-banner-attachment]:last-child]:mb-0",
138 className,
139 )}
140 {...props}
141 />
142 );
143}
144
145function Root({
146 className,
147 placement = "attached",
148 variant = "default",
149 width = "fill",
150 ...props
151}: ComponentProps<"div"> & {
152 placement?: "attached" | "floating";
153 variant?: ComposerBannerVariant;
154 width?: "fill" | "content";
155}) {
156 return (
157 <Surface
158 className={cn(
159 "min-w-0 p-1 pb-[calc(var(--chat-composer-attachment-overlap)+(--spacing(1)))] text-xs/4 [--composer-banner-icon-column:--spacing(7)] sm:[--composer-banner-icon-column:--spacing(6)]",
160 width === "content" ? "w-fit max-w-full flex-none" : "@container",
161 className,
162 )}
163 data-slot="composer-banner"
164 placement={placement}
165 data-composer-banner-width={width}
166 variant={variant}
167 {...props}
168 />
169 );
170}
171
172/** The same row can be a status, a list item, or an entire disclosure button. */
173function Row({
174 className,
175 render,
176 layout = "inline",
177 ...props
178}: useRender.ComponentProps<"div"> & {
179 layout?: "inline" | "wrap-actions";
180}) {
181 const rowProps = {
182 className: cn(
183 "group/banner-row grid min-h-(--composer-banner-icon-column) w-full min-w-0 grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)_auto] items-center gap-x-1 text-start",
184 "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]",
185 "[&:is(button)]:cursor-pointer [&:is(button)]:rounded-[0.5rem] [&:is(button)]:focus-visible:outline-2 [&:is(button)]:focus-visible:-outline-offset-2 [&:is(button)]:focus-visible:outline-ring",
186 layout === "wrap-actions" &&
187 "@max-[400px]:flex @max-[400px]:flex-wrap @max-[400px]:gap-y-1 @max-[400px]:*:data-[slot=composer-banner-actions]:ms-auto @max-[400px]:*:data-[slot=composer-banner-actions]:max-w-full @max-[400px]:has-[>[data-slot=composer-banner-icon]]:*:data-[slot=composer-banner-actions]:max-w-[calc(100%-var(--composer-banner-icon-column)-(--spacing(1)))] @max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column) @max-[400px]:*:data-[slot=composer-banner-content]:flex-[1_1_10rem]",
188 className,
189 ),
190 "data-composer-banner-row": "true",
191 "data-composer-banner-layout": layout,
192 };
193 return useRender({
194 defaultTagName: "div",
195 render,
196 props: mergeProps<"div">(rowProps, props),
197 });
198}
199
200function Icon({ className, ...props }: ComponentProps<"span">) {
201 return (
202 <span
203 aria-hidden
204 data-slot="composer-banner-icon"
205 className={cn(
206 "col-start-1 row-start-1 flex w-(--composer-banner-icon-column) min-w-0 flex-none items-center justify-center text-muted-foreground [&>svg]:size-3",
207 className,
208 )}
209 {...props}
210 />
211 );
212}
213
214function Content({ className, ...props }: ComponentProps<"span">) {
215 return (
216 <span
217 data-slot="composer-banner-content"
218 className={cn(
219 "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0",
220 "group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:col-[1/3] group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-2 sm:group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-1.5",
221 "group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-2 sm:group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-1.5",
222 className,
223 )}
224 {...props}
225 />
226 );
227}
228
229function Separator() {
230 return (
231 <span
232 aria-hidden
233 data-slot="composer-banner-separator"
234 className="mx-1 inline-block flex-none text-muted-foreground/40"
235 >
236 ·
237 </span>
238 );
239}
240
241function Actions({ className, ...props }: ComponentProps<"span">) {
242 return (
243 <span
244 data-slot="composer-banner-actions"
245 className={cn(
246 "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1",
247 className,
248 )}
249 {...props}
250 />
251 );
252}
253
254/** Child rows keep their parent's columns and begin immediately after its header. */
255function Children({ className, render, ...props }: useRender.ComponentProps<"div">) {
256 return useRender({
257 defaultTagName: "div",
258 render,
259 props: mergeProps<"div">(
260 { className: cn("grid gap-px [&_[data-composer-banner-row]]:min-h-5", className) },
261 props,
262 ),
263 });
264}
265
266/** Bounded banner content uses the app's scroll area and fades only overflowing edges. */
267function Scroll({ className, ...props }: ComponentProps<typeof ScrollArea>) {
268 return (
269 <ScrollArea
270 scrollFade
271 className={cn(
272 "h-auto max-h-[min(24rem,40dvh)] rounded-none [&>[data-slot=scroll-area-viewport][data-has-overflow-y]]:pe-2",
273 className,
274 )}
275 {...props}
276 />
277 );
278}
279
280function Count({ className, ...props }: ComponentProps<"span">) {
281 return (
282 <span
283 className={cn(
284 "inline-flex min-w-(--composer-banner-icon-column,1em) flex-none justify-center font-medium text-muted-foreground tabular-nums",
285 className,
286 )}
287 {...props}
288 />
289 );
290}
291
292function Body({ className, ...props }: ComponentProps<"div">) {
293 return (
294 <div
295 className={cn(
296 "min-w-0 ps-[calc(var(--composer-banner-icon-column)+(--spacing(1)))]",
297 className,
298 )}
299 {...props}
300 />
301 );
302}
303
304function Dot({ className, ...props }: ComponentProps<"span">) {
305 return (
306 <span className={cn("size-1.5 flex-none rounded-full bg-current", className)} {...props} />
307 );
308}
309
310function ToggleIcon({ expanded, className }: { expanded: boolean; className?: string }) {
311 return (
312 <span
313 aria-hidden
314 className={cn(buttonVariants({ size: "icon-xs", variant: "ghost" }), className)}
315 >
316 <ChevronDownIcon className={cn("size-3.5", !expanded && "rotate-180")} />
317 </span>
318 );
319}
320
321function Dismiss({ className, children, ...props }: ComponentProps<typeof Button>) {
322 return (
323 <Button size="icon-xs" variant="ghost" className={className} {...props}>
324 {children ?? <XIcon className="size-3.5" />}
325 </Button>
326 );
327}
328
329export const ComposerBanner = {
330 Surface,
331 Peek,
332 Attachment,
333 Dock,
334 Column,
335 Root,
336 Row,
337 Icon,
338 Content,
339 Separator,
340 Actions,
341 Children,
342 Scroll,
343 Count,
344 Body,
345 Dot,
346 ToggleIcon,
347 Dismiss,
348};
apps/web/src/components/chat/ComposerBannerStack.test.tsx deleted-108
...@@ -1,108 +0,0 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
5
6const banner = (
7 id: string,
8 variant: ComposerBannerStackItem["variant"] = "warning",
9): ComposerBannerStackItem => ({
10 id,
11 variant,
12 icon: <span aria-hidden="true">!</span>,
13 title: `${id} warning`,
14});
15
16describe("ComposerBannerStack", () => {
17 it("keeps expanded banners in layout flow so surrounding content moves out of their way", () => {
18 const markup = renderToStaticMarkup(
19 <ComposerBannerStack items={[banner("front"), banner("stacked")]} />,
20 );
21
22 const expandedItems = markup.match(
23 /<div data-composer-banner-stack-expanded-items="true" class="([^"]+)">/,
24 );
25
26 expect(expandedItems?.[1]).toContain("grid-rows-[0fr]");
27 expect(expandedItems?.[1]).toContain("group-hover/banner-stack:grid-rows-[1fr]");
28 expect(expandedItems?.[1]).toContain("z-20");
29 expect(expandedItems?.[1]).not.toContain("absolute");
30 expect(markup.indexOf("front warning")).toBeLessThan(markup.indexOf("stacked warning"));
31 expect(markup).toContain("invisible pointer-events-none");
32 expect(markup).toContain("group-focus-within/banner-stack:visible");
33 });
34
35 it("colors the collapsed stack cap by the hidden banner's variant, not a fixed warning", () => {
36 const neutralBehind = renderToStaticMarkup(
37 <ComposerBannerStack items={[banner("front", "default"), banner("stacked", "default")]} />,
38 );
39 expect(neutralBehind).toContain("chat-composer-banner-stack-cap");
40 expect(neutralBehind).toContain("border-[var(--chat-composer-attached-outline)]");
41 expect(neutralBehind).not.toContain("border-border");
42 expect(neutralBehind).not.toContain("border-warning/24");
43
44 const warningBehind = renderToStaticMarkup(
45 <ComposerBannerStack items={[banner("front", "default"), banner("stacked", "warning")]} />,
46 );
47 expect(warningBehind).toContain("border-warning/24");
48 });
49
50 it("does not render an expandable region for a single banner", () => {
51 const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />);
52
53 expect(markup).not.toContain("data-composer-banner-stack-expanded-items");
54 expect(markup).toContain("chat-composer-drawer-surface");
55 expect(markup).toContain("chat-composer-drawer-attached");
56 expect(markup).not.toContain("before:mask-none");
57 expect(markup).toContain("text-xs");
58 expect(markup).toContain('data-composer-banner-drawer="true"');
59 expect(markup).toContain('data-variant="warning"');
60 expect(markup).toContain("transform:none");
61 expect(markup).not.toContain("will-change:transform");
62 });
63 it("applies item-specific surface and action layout classes", () => {
64 const markup = renderToStaticMarkup(
65 <ComposerBannerStack
66 items={[
67 {
68 ...banner("branch"),
69 className: "branch-surface",
70 actionClassName: "branch-actions",
71 actions: <button type="button">Repair</button>,
72 },
73 ]}
74 />,
75 );
76
77 expect(markup).toContain("branch-surface");
78 expect(markup).toContain("branch-actions");
79 });
80
81 it("renders a disabled compaction action on the shared accessible banner surface", () => {
82 const markup = renderToStaticMarkup(
83 <ComposerBannerStack
84 items={[
85 {
86 id: "resume-compaction",
87 variant: "info",
88 icon: <span aria-hidden="true">!</span>,
89 title: "Resume with less context",
90 description: "250k tokens from an older session",
91 actions: (
92 <button type="button" disabled>
93 Compact
94 </button>
95 ),
96 dismissLabel: "Keep full history",
97 onDismiss: () => {},
98 },
99 ]}
100 />,
101 );
102
103 expect(markup).toContain('role="alert"');
104 expect(markup).toContain("chat-composer-drawer-attached");
105 expect(markup).toContain('disabled=""');
106 expect(markup).toContain('aria-label="Keep full history"');
107 });
108});
apps/web/src/components/chat/ComposerBannerStack.tsx+173-133
...@@ -1,60 +1,52 @@...@@ -1,60 +1,52 @@
1import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";1import { useEffect, useId, useRef, useState, type ReactNode } from "react";
2import { XIcon } from "lucide-react";
32
4import { cn } from "~/lib/utils";3import { cn } from "~/lib/utils";
5import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert";4import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
6import { Button } from "../ui/button";
75
6// Match the duration-220 exit transition before removing a dismissed notice.
8const DISMISS_TRANSITION_MS = 220;7const DISMISS_TRANSITION_MS = 220;
9const frontExitStyle = {
10 opacity: 0,
11 transform: "translate3d(0, 4rem, 0)",
12} satisfies CSSProperties;
13const stackedExitStyle = {
14 opacity: 0,
15 transform: "translate3d(0, 7rem, 0)",
16} satisfies CSSProperties;
17const restingStyle = {
18 opacity: 1,
19 transform: "none",
20} satisfies CSSProperties;
21const exitTransitionStyle = {
22 transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`,
23} satisfies CSSProperties;
24
25// The collapsed cap peeking above the front banner is the only hint that more
26// banners are stacked behind it, so its border must match the severity of the
27// first hidden banner — a neutral banner must not masquerade as a warning.
28const stackCapBorderClass: Record<ComposerBannerStackItem["variant"], string> = {
29 default: "border-[var(--chat-composer-attached-outline)]",
30 error: "border-destructive/24",
31 info: "border-info/24",
32 success: "border-success/24",
33 warning: "border-warning/24",
34};
358
36export interface ComposerBannerStackItem {9export interface ComposerBannerStackItem {
37 readonly id: string;10 readonly id: string;
38 readonly variant: "default" | "error" | "info" | "success" | "warning";11 readonly variant: ComposerBannerVariant;
39 // Ordering hint for stack assemblers: front this banner even though its12 readonly priority?: "urgent" | "activity" | "notice";
40 // variant is calm (e.g. live update progress). The stack itself ignores it.
41 readonly urgent?: boolean;
42 readonly icon: ReactNode;13 readonly icon: ReactNode;
43 readonly title: ReactNode;14 readonly title: ReactNode;
44 readonly description?: ReactNode;15 readonly description?: ReactNode;
16 readonly children?: ReactNode;
45 readonly actions?: ReactNode;17 readonly actions?: ReactNode;
46 readonly className?: string;18 readonly className?: string;
47 readonly actionClassName?: string;
48 readonly dismissLabel?: string;19 readonly dismissLabel?: string;
49 readonly onDismiss?: () => void;20 readonly onDismiss?: () => void;
50}21}
5122
23export type ComposerBannerStackContent = Pick<
24 ComposerBannerStackItem,
25 "id" | "variant" | "priority" | "className"
26> & { readonly content: ReactNode };
27
28type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent;
29
30function bannerPriority(item: ComposerBannerStackEntry) {
31 if (item.priority === "activity") {
32 return 0;
33 }
34 if (item.priority === "urgent" || item.variant === "error" || item.variant === "warning") {
35 return 1;
36 }
37 return 2;
38}
39
52interface ComposerBannerStackProps {40interface ComposerBannerStackProps {
53 readonly className?: string;41 readonly className?: string;
54 readonly items: ReadonlyArray<ComposerBannerStackItem>;42 readonly items: ReadonlyArray<ComposerBannerStackEntry>;
55}43}
5644
57export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) {45export 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();
58 const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null);50 const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null);
59 const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);51 const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60 const exitingItemId =52 const exitingItemId =
...@@ -70,21 +62,27 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -70,21 +62,27 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
70 };62 };
71 }, []);63 }, []);
7264
65 useEffect(() => {
66 if (items.length < 2) setStackExpanded(false);
67 }, [items.length]);
68
73 if (items.length === 0) {69 if (items.length === 0) {
74 return null;70 return null;
75 }71 }
7672
77 const frontItem = items[0];73 // Activity stays attached. Urgency and severity only order the notices behind it.
74 const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b));
75 const frontItem = orderedItems[0];
78 if (!frontItem) {76 if (!frontItem) {
79 return null;77 return null;
80 }78 }
81 const stackedItems = items.slice(1);79 const stackedItems = orderedItems.slice(1);
82 const hasStack = stackedItems.length > 0;80 const hasStack = stackedItems.length > 0;
83 const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;81 const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;
84 const firstStackedItem = stackedItems[0];82 const firstStackedItem = stackedItems[0];
8583
86 const requestDismiss = (item: ComposerBannerStackItem) => {84 const requestDismiss = (item: ComposerBannerStackEntry) => {
87 if (!item.onDismiss || exitingItemId) {85 if (!("onDismiss" in item) || !item.onDismiss || exitingItemId) {
88 return;86 return;
89 }87 }
90 setExitingItemId(item.id);88 setExitingItemId(item.id);
...@@ -98,37 +96,28 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -98,37 +96,28 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
98 };96 };
9997
100 return (98 return (
101 <div99 <ComposerBanner.Attachment
102 className={cn("group/banner-stack chat-composer-drawer-slot", className)}100 className={className}
103 data-composer-banner-drawer="true"101 data-composer-banner-drawer="true"
102 data-chat-composer-collapsed-controls="true"
104 >103 >
105 <div104 <div className={cn("relative flex flex-col-reverse", hasStack && stackExpanded && "z-50")}>
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}
124 <div105 <div
125 className={cn(106 className={cn(
126 "relative z-10",107 "relative z-10 transition-[translate,opacity] duration-220 ease-in",
127 exitingItemId === frontItem.id ? "pointer-events-none" : null,108 exitingItemId === frontItem.id
109 ? "pointer-events-none translate-y-16 opacity-0"
110 : "opacity-100",
128 )}111 )}
129 style={{112 onPointerDownCapture={() => {
130 ...exitTransitionStyle,113 setStackExpanded(false);
131 ...(exitingItemId === frontItem.id ? frontExitStyle : restingStyle),114 const activeElement = document.activeElement;
115 if (
116 activeElement instanceof HTMLElement &&
117 noticesRef.current?.contains(activeElement)
118 ) {
119 activeElement.blur();
120 }
132 }}121 }}
133 >122 >
134 <ComposerBannerStackAlert123 <ComposerBannerStackAlert
...@@ -140,44 +129,86 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -140,44 +129,86 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
140 </div>129 </div>
141 {hasStack ? (130 {hasStack ? (
142 <div131 <div
143 data-composer-banner-stack-expanded-items="true"132 ref={noticesRef}
144 className={cn(133 className="relative z-20"
145 "relative z-20 grid grid-rows-[0fr] transition-[grid-template-rows] duration-150 ease-out",134 onPointerEnter={(event) => {
146 "group-hover/banner-stack:grid-rows-[1fr] group-focus-within/banner-stack:grid-rows-[1fr]",135 if (event.pointerType !== "touch") setStackExpanded(true);
147 )}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 }}
148 >155 >
149 <div className="min-h-0 overflow-hidden">156 {showCollapsedStackCap && firstStackedItem ? (
150 <div157 <ComposerBanner.Peek
151 className={cn(158 ref={peekRef}
152 "invisible pointer-events-none space-y-2 pb-2 opacity-0",159 variant={firstStackedItem.variant}
153 "translate-y-1 transform-gpu transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]",160 aria-label="Show other notices"
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",161 aria-expanded={stackExpanded}
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",162 aria-controls={expandedItemsId}
156 )}163 onClick={(event) => {
157 >164 event.currentTarget.focus({ preventScroll: true });
158 {stackedItems.map((item) => (165 setStackExpanded(true);
159 <div166 }}
160 key={item.id}167 className={cn(stackExpanded && "opacity-0")}
161 className={cn(exitingItemId === item.id ? "pointer-events-none" : null)}168 />
162 style={{169 ) : null}
163 ...exitTransitionStyle,170 <div
164 ...(exitingItemId === item.id ? stackedExitStyle : restingStyle),171 id={expandedItemsId}
165 }}172 data-composer-banner-stack-expanded-items="true"
166 >173 className={cn(
167 <ComposerBannerStackAlert174 "grid transition-[grid-template-rows] duration-150 ease-out",
168 item={item}175 stackExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
169 attached={false}176 )}
170 exiting={exitingItemId === item.id}177 >
171 onDismissRequest={() => requestDismiss(item)}178 <div className="min-h-0 overflow-hidden">
172 />179 <div
173 </div>180 className={cn(
174 ))}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>
175 </div>206 </div>
176 </div>207 </div>
177 </div>208 </div>
178 ) : null}209 ) : null}
179 </div>210 </div>
180 </div>211 </ComposerBanner.Attachment>
181 );212 );
182}213}
183214
...@@ -187,50 +218,59 @@ function ComposerBannerStackAlert({...@@ -187,50 +218,59 @@ function ComposerBannerStackAlert({
187 exiting,218 exiting,
188 onDismissRequest,219 onDismissRequest,
189}: {220}: {
190 readonly item: ComposerBannerStackItem;221 readonly item: ComposerBannerStackEntry;
191 readonly attached: boolean;222 readonly attached: boolean;
192 readonly exiting: boolean;223 readonly exiting: boolean;
193 readonly onDismissRequest: () => void;224 readonly onDismissRequest: () => void;
194}) {225}) {
195 const dismissOnly = item.onDismiss && !item.actions;226 if ("content" in item) {
227 return (
228 <ComposerBanner.Root
229 placement={attached ? "attached" : "floating"}
230 variant={item.variant}
231 className={item.className}
232 >
233 {item.content}
234 </ComposerBanner.Root>
235 );
236 }
196237
197 return (238 return (
198 <Alert239 <ComposerBanner.Root
240 role="alert"
241 placement={attached ? "attached" : "floating"}
199 variant={item.variant}242 variant={item.variant}
200 className={cn(243 className={item.className}
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}
207 >244 >
208 {item.icon}245 <ComposerBanner.Row layout="wrap-actions">
209 <AlertTitle>{item.title}</AlertTitle>246 <ComposerBanner.Icon>{item.icon}</ComposerBanner.Icon>
210 {item.description ? <AlertDescription>{item.description}</AlertDescription> : null}247 <ComposerBanner.Content className="font-medium">{item.title}</ComposerBanner.Content>
211 {item.actions || item.onDismiss ? (248 {item.actions || item.onDismiss ? (
212 <AlertAction249 <ComposerBanner.Actions>
213 className={cn(250 {item.actions}
214 item.actionClassName,251 {item.onDismiss ? (
215 dismissOnly252 <ComposerBanner.Dismiss
216 ? "max-sm:col-start-3 max-sm:row-start-1 max-sm:mt-0 max-sm:self-start"253 aria-label={item.dismissLabel ?? "Dismiss warning"}
217 : undefined,254 disabled={exiting}
218 )}255 onClick={onDismissRequest}
219 >256 />
220 {item.actions}257 ) : null}
221 {item.onDismiss ? (258 </ComposerBanner.Actions>
222 <Button259 ) : null}
223 size="icon-xs"260 </ComposerBanner.Row>
224 variant="ghost"261 {item.description || item.children ? (
225 aria-label={item.dismissLabel ?? "Dismiss warning"}262 <ComposerBanner.Children>
226 disabled={exiting}263 {item.description ? (
227 onClick={onDismissRequest}264 <ComposerBanner.Row>
228 >265 <ComposerBanner.Icon />
229 <XIcon className="size-3.5" />266 <ComposerBanner.Content className="text-muted-foreground">
230 </Button>267 {item.description}
268 </ComposerBanner.Content>
269 </ComposerBanner.Row>
231 ) : null}270 ) : null}
232 </AlertAction>271 {item.children}
272 </ComposerBanner.Children>
233 ) : null}273 ) : null}
234 </Alert>274 </ComposerBanner.Root>
235 );275 );
236}276}
apps/web/src/components/chat/ComposerCommandMenu.test.tsx-2
...@@ -19,8 +19,6 @@ describe("ComposerCommandMenu", () => {...@@ -19,8 +19,6 @@ describe("ComposerCommandMenu", () => {
19 );19 );
2020
21 expect(markup).toContain('data-composer-command-drawer="true"');21 expect(markup).toContain('data-composer-command-drawer="true"');
22 expect(markup).toContain("chat-composer-drawer-surface");
23 expect(markup).toContain("chat-composer-drawer-attached");
24 expect(markup).not.toContain("dropdown-glass");22 expect(markup).not.toContain("dropdown-glass");
25 });23 });
2624
apps/web/src/components/chat/ComposerCommandMenu.tsx+4-3
...@@ -24,6 +24,7 @@ import { cn } from "~/lib/utils";...@@ -24,6 +24,7 @@ import { cn } from "~/lib/utils";
24import { Badge } from "../ui/badge";24import { Badge } from "../ui/badge";
25import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";25import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
26import { PierreEntryIcon } from "./PierreEntryIcon";26import { PierreEntryIcon } from "./PierreEntryIcon";
27import { ComposerBanner } from "./ComposerBanner";
2728
28export type ComposerCommandItem =29export type ComposerCommandItem =
29 | {30 | {
...@@ -88,9 +89,9 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -88,9 +89,9 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
88 );89 );
89 }}90 }}
90 >91 >
91 <div92 <ComposerBanner.Surface
92 ref={listRef}93 ref={listRef}
93 className="chat-composer-drawer-surface chat-composer-drawer-attached relative w-full overflow-hidden **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"94 className="w-full overflow-hidden pb-(--chat-composer-attachment-overlap) **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"
94 data-composer-command-drawer="true"95 data-composer-command-drawer="true"
95 >96 >
96 {props.items.length > 0 ? (97 {props.items.length > 0 ? (
...@@ -125,7 +126,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -125,7 +126,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
125 </p>126 </p>
126 </div>127 </div>
127 )}128 )}
128 </div>129 </ComposerBanner.Surface>
129 </Command>130 </Command>
130 );131 );
131});132});
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+2-2
...@@ -31,7 +31,7 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova...@@ -31,7 +31,7 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
31 : "File change";31 : "File change";
3232
33 return (33 return (
34 <div34 <span
35 aria-label={fallbackLabel}35 aria-label={fallbackLabel}
36 className={cn("flex min-w-0 flex-1 items-center gap-2", className)}36 className={cn("flex min-w-0 flex-1 items-center gap-2", className)}
37 role="group"37 role="group"
...@@ -54,6 +54,6 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova...@@ -54,6 +54,6 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
54 1/{pendingCount}54 1/{pendingCount}
55 </span>55 </span>
56 ) : null}56 ) : null}
57 </div>57 </span>
58 );58 );
59});59});
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+25-42
...@@ -5,9 +5,10 @@ import {...@@ -5,9 +5,10 @@ import {
5 derivePendingUserInputProgress,5 derivePendingUserInputProgress,
6 type PendingUserInputDraftAnswer,6 type PendingUserInputDraftAnswer,
7} from "../../pendingUserInput";7} from "../../pendingUserInput";
8import { CheckIcon, ChevronDownIcon } from "lucide-react";8import { CheckIcon } from "lucide-react";
9import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";9import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
10import { cn } from "~/lib/utils";10import { cn } from "~/lib/utils";
11import { ComposerBanner } from "./ComposerBanner";
1112
12interface PendingUserInputPanelProps {13interface PendingUserInputPanelProps {
13 pendingUserInputs: PendingUserInput[];14 pendingUserInputs: PendingUserInput[];
...@@ -167,58 +168,40 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -167,58 +168,40 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
167168
168 return (169 return (
169 <Collapsible170 <Collapsible
170 className="py-2"
171 open={!isCollapsed}171 open={!isCollapsed}
172 onOpenChange={(open) => {172 onOpenChange={(open) => {
173 setCollapsedQuestionId(open ? null : activeQuestion.id);173 setCollapsedQuestionId(open ? null : activeQuestion.id);
174 }}174 }}
175 >175 >
176 {/* The trigger's wrapper is inset less than the card's text column, and176 <CollapsibleTrigger
177 the trigger pays the difference back as padding: the hover background177 render={<ComposerBanner.Row render={<button type="button" />} />}
178 and focus ring bleed 10px past that column on both sides, while the178 title={
179 header label and the chevron still line up with the left and right179 isCollapsed ? "Show the question and its options" : "Hide the question and its options"
180 edges of the question text below. The negative block margin keeps the180 }
181 taller hit area from pushing the panel down. */}181 data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}
182 <div className="flex items-center gap-1 px-1 sm:px-2">182 >
183 <CollapsibleTrigger183 <ComposerBanner.Icon />
184 title={184 <ComposerBanner.Content>
185 isCollapsed ? "Show the question and its options" : "Hide the question and its options"185 <span className="shrink-0 font-medium text-muted-foreground">
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">
191 {activeQuestion.header}186 {activeQuestion.header}
192 </span>187 </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>
193 {prompt.questions.length > 1 ? (195 {prompt.questions.length > 1 ? (
194 <span className="text-[10px] font-medium text-muted-foreground tabular-nums">196 <span className="text-[10px] font-medium text-muted-foreground tabular-nums">
195 {questionIndex + 1}/{prompt.questions.length}197 {questionIndex + 1}/{prompt.questions.length}
196 </span>198 </span>
197 ) : null}199 ) : null}
198 {/* Collapsed, the header is otherwise just a section label and a200 <ComposerBanner.ToggleIcon expanded={!isCollapsed} />
199 counter, so the question itself is echoed here as a one-line201 </ComposerBanner.Actions>
200 reminder of what is being asked. */}202 </CollapsibleTrigger>
201 {isCollapsed ? (203 <CollapsiblePanel>
202 <span className="min-w-0 flex-1 truncate text-secondary-label text-xs">204 <ComposerBanner.Body>
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">
222 <p className="text-sm text-foreground/85">{activeQuestion.question}</p>205 <p className="text-sm text-foreground/85">{activeQuestion.question}</p>
223 {activeQuestion.multiSelect ? (206 {activeQuestion.multiSelect ? (
224 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>207 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
...@@ -276,7 +259,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -276,7 +259,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
276 );259 );
277 })}260 })}
278 </div>261 </div>
279 </div>262 </ComposerBanner.Body>
280 </CollapsiblePanel>263 </CollapsiblePanel>
281 </Collapsible>264 </Collapsible>
282 );265 );
apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx+8-9
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { memo } from "react";1import { memo } from "react";
2import { ComposerBanner } from "./ComposerBanner";
23
3export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({4export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({
4 planTitle,5 planTitle,
...@@ -6,16 +7,14 @@ export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBann...@@ -6,16 +7,14 @@ export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBann
6 planTitle: string | null;7 planTitle: string | null;
7}) {8}) {
8 return (9 return (
9 <div className="px-3 py-2 sm:px-4">10 <ComposerBanner.Row>
10 <div className="flex flex-wrap items-center gap-2">11 <ComposerBanner.Icon />
11 <span className="text-xs font-medium text-muted-foreground">Plan ready</span>12 <ComposerBanner.Content>
13 <span className="shrink-0 font-medium text-muted-foreground">Plan ready</span>
12 {planTitle ? (14 {planTitle ? (
13 <span className="min-w-0 flex-1 truncate text-xs text-foreground/85">{planTitle}</span>15 <span className="min-w-0 flex-1 truncate text-foreground/85">{planTitle}</span>
14 ) : null}16 ) : null}
15 </div>17 </ComposerBanner.Content>
16 {/* <div className="mt-2 text-xs text-muted-foreground">18 </ComposerBanner.Row>
17 Review the plan
18 </div> */}
19 </div>
20 );19 );
21});20});
apps/web/src/components/chat/ComposerServerUpdateStatus.tsx created+49
...@@ -0,0 +1,49 @@
1import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
2import { useId, useState } from "react";
3
4import { serverUpdateStageLabel } from "../ServerUpdateAction";
5import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6import { ComposerBanner } from "./ComposerBanner";
7
8/** One text line, clipped at the end so the error detail never squeezes its title. */
9export function ComposerServerUpdateStatus({
10 state,
11 serverLabel = "server",
12}: {
13 readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
14 readonly serverLabel?: string;
15}) {
16 const [detailsOpen, setDetailsOpen] = useState(false);
17 const triggerId = useId();
18 const title = `${state.status === "failed" ? "Could not update" : "Updating"} ${serverLabel}`;
19 const detail = state.status === "failed" ? state.message : serverUpdateStageLabel(state.stage);
20 return (
21 <span
22 role={state.status === "failed" ? "alert" : "status"}
23 className="min-w-0"
24 data-composer-server-update-status={state.status}
25 >
26 <Tooltip open={detailsOpen} onOpenChange={setDetailsOpen} triggerId={triggerId}>
27 <TooltipTrigger
28 id={triggerId}
29 closeOnClick={false}
30 render={
31 <button
32 type="button"
33 aria-label={`${title}: ${detail}`}
34 className="block max-w-full cursor-help truncate rounded-sm text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
35 onClick={() => setDetailsOpen(true)}
36 >
37 {title}
38 <ComposerBanner.Separator />
39 <span className="font-normal text-muted-foreground">{detail}</span>
40 </button>
41 }
42 />
43 <TooltipPopup side="top" className="max-w-80">
44 {title}: {detail}
45 </TooltipPopup>
46 </Tooltip>
47 </span>
48 );
49}
apps/web/src/components/chat/ComposerStashBadge.test.tsx deleted-68
...@@ -1,68 +0,0 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerStashBadge } from "./ComposerStashBadge";
5
6describe("ComposerStashBadge", () => {
7 it("renders as an attached composer tab instead of a floating pill", () => {
8 const markup = renderToStaticMarkup(
9 <ComposerStashBadge
10 count={3}
11 menuOpen={false}
12 pulseKey={0}
13 pulsing={false}
14 onToggleMenu={() => {}}
15 />,
16 );
17
18 expect(markup).toContain("chat-composer-stash-tab");
19 expect(markup).toContain("rounded-t-xl");
20 expect(markup).toContain("border-b-0");
21 expect(markup).toContain("items-center");
22 expect(markup).not.toContain("items-start");
23 expect(markup).not.toContain("rounded-full");
24 expect(markup).not.toContain("opacity-70");
25 expect(markup).not.toContain("border-border");
26 expect(markup).toContain('aria-expanded="false"');
27 });
28
29 it("reports when the stash drawer is open", () => {
30 const markup = renderToStaticMarkup(
31 <ComposerStashBadge
32 count={3}
33 menuOpen
34 pulseKey={0}
35 pulsing={false}
36 onToggleMenu={() => {}}
37 />,
38 );
39
40 expect(markup).toContain('aria-expanded="true"');
41 expect(markup).toContain("text-foreground");
42 expect(markup).toContain("pointer-events-none");
43 expect(markup).not.toContain("invisible");
44 });
45
46 it("keeps a compact inline entry point when a drawer occupies the tab", () => {
47 const markup = renderToStaticMarkup(
48 <ComposerStashBadge
49 count={3}
50 menuOpen
51 placement="inline"
52 pulseKey={0}
53 pulsing={false}
54 onToggleMenu={() => {}}
55 />,
56 );
57
58 expect(markup).toContain('data-slot="button"');
59 expect(markup).toContain("rounded-sm");
60 expect(markup).toContain("focus-visible:ring-2");
61 expect(markup).toContain("pointer-coarse:after:min-h-11");
62 expect(markup).toContain("Stashed prompts: 3. Open stash.");
63 expect(markup).toContain('aria-expanded="true"');
64 expect(markup).not.toContain("chat-composer-stash-tab");
65 expect(markup).not.toContain("rounded-t-xl");
66 expect(markup).not.toContain(" pointer-events-none ");
67 });
68});
apps/web/src/components/chat/ComposerStashBadge.tsx+21-47
...@@ -2,12 +2,11 @@ import { BookmarkIcon } from "lucide-react";...@@ -2,12 +2,11 @@ import { BookmarkIcon } from "lucide-react";
2import { memo } from "react";2import { memo } from "react";
33
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { Button } from "../ui/button";5import { ComposerBanner } from "./ComposerBanner";
66
7/**7/**
8 * Bookmark control that shows the stash count and opens the stash menu. It8 * Bookmark tab that shows the stash count beside the composer's other attachments
9 * sits behind the composer's shoulder unless another drawer needs that space,9 * and opens the stash menu.
10 * then moves into the composer's existing controls.
11 *10 *
12 * On save the badge gives one quiet acknowledgement: it lifts to full11 * On save the badge gives one quiet acknowledgement: it lifts to full
13 * opacity and the count ticks over. `pulseKey` changes per stash, remounting12 * opacity and the count ticks over. `pulseKey` changes per stash, remounting
...@@ -16,72 +15,47 @@ import { Button } from "../ui/button";...@@ -16,72 +15,47 @@ import { Button } from "../ui/button";
16export const ComposerStashBadge = memo(function ComposerStashBadge(props: {15export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
17 count: number;16 count: number;
18 menuOpen: boolean;17 menuOpen: boolean;
19 placement?: "inline" | "tab";
20 pulseKey: number;18 pulseKey: number;
21 pulsing: boolean;19 pulsing: boolean;
22 onToggleMenu: () => void;20 onToggleMenu: () => void;
23}) {21}) {
24 if (props.count === 0) return null;22 if (props.count === 0) return null;
25 const inline = props.placement === "inline";
26 const count = (23 const count = (
27 <span24 <ComposerBanner.Count
28 key={props.pulseKey}25 key={props.pulseKey}
29 className={cn(26 className={cn(
30 "text-[10px] font-medium leading-none tabular-nums",
31 props.pulsing27 props.pulsing
32 ? "animate-[prompt-stash-count-enter_180ms_ease-out_both] text-primary motion-reduce:animate-none"28 ? "animate-[prompt-stash-count-enter_180ms_ease-out_both] text-primary motion-reduce:animate-none"
33 : "text-muted-foreground",29 : "text-muted-foreground",
34 )}30 )}
35 >31 >
36 {props.count}32 {props.count}
37 </span>33 </ComposerBanner.Count>
38 );34 );
3935
40 if (inline) {36 return (
41 return (37 <ComposerBanner.Root width="content" data-composer-shoulder-tab className="ml-auto">
42 <Button38 <ComposerBanner.Row
43 size="micro"39 render={<button type="button" />}
44 variant="ghost-muted"
45 data-prompt-stash-badge="true"40 data-prompt-stash-badge="true"
46 aria-label={`Stashed prompts: ${props.count}. Open stash.`}41 aria-label={`Stashed prompts: ${props.count}. Open stash.`}
47 aria-expanded={props.menuOpen}42 aria-expanded={props.menuOpen}
48 className={cn(43 className={cn(
49 "shrink-0 gap-1 px-1.5",44 "transition-colors duration-200",
50 (props.menuOpen || props.pulsing) &&45 props.menuOpen && "pointer-events-none",
51 "[--control-icon-color:currentColor] text-foreground",46 props.menuOpen || props.pulsing
47 ? "text-foreground"
48 : "text-muted-foreground hover:text-foreground",
52 )}49 )}
53 onPointerDown={(event) => event.preventDefault()}50 onPointerDown={(event) => event.preventDefault()}
54 onClick={props.onToggleMenu}51 onClick={props.onToggleMenu}
55 >52 >
56 <BookmarkIcon className="size-3 shrink-0" aria-hidden="true" />53 <ComposerBanner.Icon>
57 {count}54 <BookmarkIcon />
58 </Button>55 </ComposerBanner.Icon>
59 );56 <ComposerBanner.Content>Stash</ComposerBanner.Content>
60 }57 <ComposerBanner.Actions>{count}</ComposerBanner.Actions>
6158 </ComposerBanner.Row>
62 return (59 </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>
86 );60 );
87});61});
apps/web/src/components/chat/ComposerStashMenu.test.tsx-44
...@@ -5,41 +5,6 @@ import { describe, expect, it } from "vite-plus/test";...@@ -5,41 +5,6 @@ import { describe, expect, it } from "vite-plus/test";
5import { ComposerStashMenu } from "./ComposerStashMenu";5import { ComposerStashMenu } from "./ComposerStashMenu";
66
7describe("ComposerStashMenu", () => {7describe("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
43 it("shows saved image thumbnails and incomplete image states", () => {8 it("shows saved image thumbnails and incomplete image states", () => {
44 const markup = renderToStaticMarkup(9 const markup = renderToStaticMarkup(
45 <ComposerStashMenu10 <ComposerStashMenu
...@@ -81,14 +46,6 @@ describe("ComposerStashMenu", () => {...@@ -81,14 +46,6 @@ describe("ComposerStashMenu", () => {
81 expect(markup).toContain('src="data:image/png;base64,AA=="');46 expect(markup).toContain('src="data:image/png;base64,AA=="');
82 expect(markup).toContain("1 image dropped");47 expect(markup).toContain("1 image dropped");
83 expect(markup).toContain("saving 1 image");48 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");
92 });49 });
9350
94 it("labels mixed file and image stashes without treating images as files", () => {51 it("labels mixed file and image stashes without treating images as files", () => {
...@@ -129,7 +86,6 @@ describe("ComposerStashMenu", () => {...@@ -129,7 +86,6 @@ describe("ComposerStashMenu", () => {
129 );86 );
13087
131 expect(markup).toContain("(2 attachments)");88 expect(markup).toContain("(2 attachments)");
132 expect(markup).toContain("size-3.5 text-secondary-label");
133 expect(markup).not.toContain("(2 files)");89 expect(markup).not.toContain("(2 files)");
134 });90 });
135});91});
apps/web/src/components/chat/ComposerStashMenu.tsx+88-80
...@@ -1,11 +1,10 @@...@@ -1,11 +1,10 @@
1import { FileIcon, XIcon } from "lucide-react";1import { BookmarkIcon, FileIcon, FileTextIcon } from "lucide-react";
2import { memo, useEffect, useRef, useState } from "react";2import { memo, useEffect, useRef, useState } from "react";
33
4import { formatRelativeTimeLabel } from "../../timestampFormat";4import { formatRelativeTimeLabel } from "../../timestampFormat";
5import { cn } from "~/lib/utils";5import { cn } from "~/lib/utils";
6import { type PromptStashEntry } from "../../promptStashStore";6import { type PromptStashEntry } from "../../promptStashStore";
7import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";7import { ComposerBanner } from "./ComposerBanner";
8import { Button } from "../ui/button";
98
10const SNIPPET_MAX_CHARS = 90;9const SNIPPET_MAX_CHARS = 90;
1110
...@@ -30,7 +29,7 @@ function stashEntrySnippet(entry: PromptStashEntry): string {...@@ -30,7 +29,7 @@ function stashEntrySnippet(entry: PromptStashEntry): string {
30}29}
3130
32/**31/**
33 * Popover listing the stashed prompts. Keyboard-first: opened by ⌘S on an32 * Attached banner listing the stashed prompts. Keyboard-first: opened by ⌘S on an
34 * empty composer, navigated with arrows, restored with Enter, dismissed33 * empty composer, navigated with arrows, restored with Enter, dismissed
35 * with Escape. The listener runs capture-phase on window so it wins over34 * with Escape. The listener runs capture-phase on window so it wins over
36 * the Lexical editor's handlers while the menu is open.35 * the Lexical editor's handlers while the menu is open.
...@@ -64,13 +63,6 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -64,13 +63,6 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
64 return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true);63 return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true);
65 }, [onClose]);64 }, [onClose]);
6665
67 useEffect(() => {
68 if (entries.length === 0) return;
69 if (!entries.some((entry) => entry.id === highlightedId)) {
70 setHighlightedId(entries[0]?.id ?? null);
71 }
72 }, [entries, highlightedId]);
73
74 useEffect(() => {66 useEffect(() => {
75 const handler = (event: KeyboardEvent) => {67 const handler = (event: KeyboardEvent) => {
76 if (event.key === "Escape") {68 if (event.key === "Escape") {
...@@ -83,11 +75,17 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -83,11 +75,17 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
83 if (entries.length === 0) return;75 if (entries.length === 0) return;
84 event.preventDefault();76 event.preventDefault();
85 event.stopPropagation();77 event.stopPropagation();
86 const currentIndex = entries.findIndex((entry) => entry.id === highlightedId);78 const currentIndex = entries.findIndex((entry) => entry.id === highlightedEntry?.id);
87 const offset = event.key === "ArrowDown" ? 1 : -1;79 const offset = event.key === "ArrowDown" ? 1 : -1;
88 const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0;80 const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0;
89 const nextIndex = (normalizedIndex + offset + entries.length) % entries.length;81 const nextIndex = (normalizedIndex + offset + entries.length) % entries.length;
90 setHighlightedId(entries[nextIndex]?.id ?? null);82 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 }
91 return;89 return;
92 }90 }
93 if (event.key === "Enter") {91 if (event.key === "Enter") {
...@@ -111,64 +109,77 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -111,64 +109,77 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
111 };109 };
112 window.addEventListener("keydown", handler, true);110 window.addEventListener("keydown", handler, true);
113 return () => window.removeEventListener("keydown", handler, true);111 return () => window.removeEventListener("keydown", handler, true);
114 }, [entries, highlightedEntry, highlightedId, onClose, onDelete, onRestore]);112 }, [entries, highlightedEntry, onClose, onDelete, onRestore]);
115113
116 return (114 return (
117 <Command autoHighlight={false} mode="none">115 <ComposerBanner.Root ref={drawerRef} data-composer-stash-drawer="true">
118 <div116 <ComposerBanner.Row
119 ref={drawerRef}117 render={<button type="button" />}
120 className="chat-composer-drawer-surface chat-composer-drawer-attached relative w-full overflow-hidden"118 aria-label="Close stash"
121 data-composer-stash-drawer="true"119 aria-expanded="true"
120 onPointerDown={(event) => event.preventDefault()}
121 onClick={onClose}
122 >122 >
123 <div className="flex h-7 items-center justify-end px-2 pt-1">123 <ComposerBanner.Icon>
124 <Button124 <BookmarkIcon />
125 variant="ghost-muted"125 </ComposerBanner.Icon>
126 size="icon-micro"126 <ComposerBanner.Content className="text-muted-foreground">Stash</ComposerBanner.Content>
127 aria-label="Close stash"127 <ComposerBanner.Actions>
128 onPointerDown={(event) => event.preventDefault()}128 <ComposerBanner.Count>{entries.length}</ComposerBanner.Count>
129 onClick={onClose}129 <ComposerBanner.ToggleIcon expanded />
130 >130 </ComposerBanner.Actions>
131 <XIcon className="size-3" />131 </ComposerBanner.Row>
132 </Button>132 <ComposerBanner.Scroll>
133 </div>133 <ComposerBanner.Children render={<ul />} aria-label="Stashed prompts">
134 <CommandList className="max-h-64 scroll-pb-6">134 {entries.length === 0 ? (
135 <CommandGroup>135 <ComposerBanner.Row render={<li />}>
136 {entries.length === 0 ? (136 <ComposerBanner.Icon />
137 <p className="px-3 py-1.5 text-secondary-label text-xs">137 <ComposerBanner.Content className="text-muted-foreground">
138 Nothing stashed yet.138 Nothing stashed yet.
139 {stashShortcutLabel139 {stashShortcutLabel
140 ? ` Press ${stashShortcutLabel} with a prompt in the composer to stash it.`140 ? ` Press ${stashShortcutLabel} with a prompt in the composer to stash it.`
141 : null}141 : null}
142 </p>142 </ComposerBanner.Content>
143 ) : (143 </ComposerBanner.Row>
144 entries.map((entry) => (144 ) : (
145 <CommandItem145 entries.map((entry) => (
146 key={entry.id}146 <ComposerBanner.Row
147 value={entry.id}147 render={<li />}
148 className={cn(148 key={entry.id}
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",149 data-stash-entry={entry.id}
150 highlightedId === entry.id && "bg-accent! text-accent-foreground!",150 data-highlighted={highlightedEntry?.id === entry.id || undefined}
151 )}151 className={cn(
152 onMouseMove={() => {152 "relative rounded-sm",
153 if (highlightedId !== entry.id) setHighlightedId(entry.id);153 highlightedEntry?.id === entry.id && "bg-accent text-accent-foreground",
154 }}154 )}
155 onMouseDown={(event) => {155 onMouseMove={() => {
156 event.preventDefault();156 if (highlightedId !== entry.id) setHighlightedId(entry.id);
157 }}157 }}
158 onClick={() => {158 onFocus={() => setHighlightedId(entry.id)}
159 onRestore(entry);159 >
160 }}160 <ComposerBanner.Icon>
161 >161 <FileTextIcon />
162 <span className="min-w-0 flex-1 truncate text-xs">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 >
163 {stashEntrySnippet(entry)}172 {stashEntrySnippet(entry)}
164 </span>173 </button>
174 </ComposerBanner.Content>
175 <ComposerBanner.Actions>
165 {entry.pendingImageCount ? (176 {entry.pendingImageCount ? (
166 <span className="shrink-0 text-[10px] text-secondary-label">177 <span className="shrink-0 text-muted-foreground">
167 saving {entry.pendingImageCount} image178 saving {entry.pendingImageCount} image
168 {entry.pendingImageCount === 1 ? "" : "s"}…179 {entry.pendingImageCount === 1 ? "" : "s"}…
169 </span>180 </span>
170 ) : missingImageCount(entry) > 0 ? (181 ) : missingImageCount(entry) > 0 ? (
171 <span className="shrink-0 text-[10px] text-warning-foreground">182 <span className="shrink-0 text-warning-foreground">
172 {missingImageCount(entry)} image183 {missingImageCount(entry)} image
173 {missingImageCount(entry) === 1 ? "" : "s"} dropped184 {missingImageCount(entry) === 1 ? "" : "s"} dropped
174 </span>185 </span>
...@@ -181,38 +192,35 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -181,38 +192,35 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
181 src={attachment.dataUrl}192 src={attachment.dataUrl}
182 alt=""193 alt=""
183 aria-hidden="true"194 aria-hidden="true"
184 className="size-5 rounded border border-border/70 object-cover"195 className="size-4 rounded border border-border/70 object-cover"
185 />196 />
186 ))}197 ))}
187 </span>198 </span>
188 ) : null}199 ) : null}
189 {(entry.files?.length ?? 0) > 0 ? (200 {(entry.files?.length ?? 0) > 0 ? (
190 <span className="flex shrink-0 items-center gap-1 text-secondary-label text-xs">201 <span className="flex shrink-0 items-center gap-1 text-muted-foreground">
191 <FileIcon className="size-3.5 text-secondary-label" />202 <FileIcon className="size-3" aria-hidden />
192 {entry.files!.length}203 {entry.files!.length}
193 </span>204 </span>
194 ) : null}205 ) : null}
195 <span className="shrink-0 text-secondary-label text-xs max-sm:hidden">206 <time
207 dateTime={entry.createdAt}
208 className="shrink-0 text-muted-foreground tabular-nums max-sm:hidden"
209 >
196 {formatRelativeTimeLabel(entry.createdAt)}210 {formatRelativeTimeLabel(entry.createdAt)}
197 </span>211 </time>
198 <Button212 <ComposerBanner.Dismiss
199 variant="ghost"213 className="z-10"
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"
202 aria-label="Delete stashed prompt"214 aria-label="Delete stashed prompt"
203 onClick={(event) => {215 onPointerDown={(event) => event.preventDefault()}
204 event.stopPropagation();216 onClick={() => onDelete(entry)}
205 onDelete(entry);217 />
206 }}218 </ComposerBanner.Actions>
207 >219 </ComposerBanner.Row>
208 <XIcon className="size-3.5 stroke-2" />220 ))
209 </Button>221 )}
210 </CommandItem>222 </ComposerBanner.Children>
211 ))223 </ComposerBanner.Scroll>
212 )}224 </ComposerBanner.Root>
213 </CommandGroup>
214 </CommandList>
215 </div>
216 </Command>
217 );225 );
218});226});
apps/web/src/components/chat/ComposerSurface.tsx created+99
...@@ -0,0 +1,99 @@
1import type { ComponentProps } from "react";
2
3import { cn } from "~/lib/utils";
4
5/** One glass backdrop until a top attachment needs the composer to cover its overlap. */
6function Shell({
7 contextStrip = false,
8 className,
9 ...props
10}: ComponentProps<"div"> & { contextStrip?: boolean }) {
11 return (
12 <div
13 data-slot="composer-shell"
14 data-with-context={contextStrip || undefined}
15 className={cn(
16 "group/composer-surface relative isolate mx-auto w-full max-w-3xl",
17 "[--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]",
18 "dark:[--chat-composer-glass-surface:color-mix(in_srgb,var(--background)_96%,var(--color-white))] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]",
19 "[html[data-theme-id]_&]:[--chat-composer-glass-surface:var(--app-theme-surface-raised)] [html[data-theme-id]_&]:[--chat-composer-outline:var(--app-theme-toolbar-border)]",
20 "dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-highlight:color-mix(in_srgb,var(--app-theme-input)_12%,transparent)] dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-outline:color-mix(in_srgb,var(--app-theme-input)_30%,var(--background))]",
21 "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-highlight:color-mix(in_srgb,#432d48_12%,transparent)] dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-outline:#241e28]",
22 "before:pointer-events-none before:absolute before:inset-0 before:z-0 before:rounded-[22px] before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)",
23 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-(--chat-composer-glass-surface)",
24 "has-data-[composer-banner-surface=attached]:before:hidden",
25 contextStrip && [
26 "[--chat-composer-context-extension:2.25rem] sm:[--chat-composer-context-extension:2rem]",
27 // Keep one continuous backdrop around the fixed-pixel corners and rem-sized strip inset.
28 "supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:rounded-none",
29 "before:[clip-path:shape(from_0_22px,curve_to_22px_0_with_0_9.85px/9.85px_0,line_to_calc(100%-22px)_0,curve_to_100%_22px_with_calc(100%-9.85px)_0/100%_9.85px,line_to_100%_calc(100%-var(--chat-composer-context-extension)-1.375rem),curve_to_calc(100%-1.375rem)_calc(100%-var(--chat-composer-context-extension))_with_100%_calc(100%-var(--chat-composer-context-extension)-0.6156rem)/calc(100%-0.6156rem)_calc(100%-var(--chat-composer-context-extension)),line_to_calc(100%-1.375rem)_calc(100%-16px),curve_to_calc(100%-1.375rem-16px)_100%_with_calc(100%-1.375rem)_calc(100%-7.16px)/calc(100%-1.375rem-7.16px)_100%,line_to_calc(1.375rem+16px)_100%,curve_to_1.375rem_calc(100%-16px)_with_calc(1.375rem+7.16px)_100%/1.375rem_calc(100%-7.16px),line_to_1.375rem_calc(100%-var(--chat-composer-context-extension)),curve_to_0_calc(100%-var(--chat-composer-context-extension)-1.375rem)_with_0.6156rem_calc(100%-var(--chat-composer-context-extension))/0_calc(100%-var(--chat-composer-context-extension)-0.6156rem),line_to_0_22px,close)]",
30 "not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:bottom-(--chat-composer-context-extension)",
31 ],
32 className,
33 )}
34 {...props}
35 />
36 );
37}
38
39const outlineClasses =
40 "after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:border after:border-(--chat-composer-outline) dark:after:shadow-[inset_0_1px_var(--chat-composer-highlight)]";
41
42// The bottom strip continues the outline, so leave the seam between its corners open.
43const contextSeamClasses =
44 "group-data-with-context/composer-surface:after:[clip-path:polygon(0_0,100%_0,100%_100%,calc(100%-22px)_100%,calc(100%-22px)_calc(100%-2px),22px_calc(100%-2px),22px_100%,0_100%)]";
45
46function Host({ className, ...props }: ComponentProps<"div">) {
47 return (
48 <div
49 data-slot="composer-host"
50 className={cn(
51 "relative z-10 w-full rounded-[22px] shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] after:z-1 dark:shadow-none",
52 outlineClasses,
53 contextSeamClasses,
54 "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none group-has-data-[composer-banner-surface=attached]/composer-surface:after:hidden",
55 className,
56 )}
57 {...props}
58 />
59 );
60}
61
62function Main({ className, ...props }: ComponentProps<"div">) {
63 return (
64 <div
65 data-chat-composer-main-surface="true"
66 className={cn(
67 "group relative z-10 rounded-[22px] p-px transition-colors duration-200",
68 outlineClasses,
69 contextSeamClasses,
70 "after:z-20 after:hidden group-has-data-[composer-banner-surface=attached]/composer-surface:after:block",
71 "group-has-data-[composer-banner-surface=attached]/composer-surface:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] group-has-data-[composer-banner-surface=attached]/composer-surface:backdrop-blur-(--glass-blur) group-has-data-[composer-banner-surface=attached]/composer-surface:backdrop-saturate-(--glass-saturation)",
72 "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none",
73 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:group-has-data-[composer-banner-surface=attached]/composer-surface:bg-(--chat-composer-glass-surface)",
74 "group-has-data-[composer-banner-surface=attached]/composer-surface:**:data-[chat-composer-mobile-collapsed=true]:min-h-[calc(1rem+1px)]",
75 className,
76 )}
77 {...props}
78 />
79 );
80}
81
82function ContextStrip({ className, ...props }: ComponentProps<"div">) {
83 return (
84 <div
85 data-slot="composer-context-strip"
86 className={cn(
87 "group/composer-context relative isolate mx-auto -mt-4 flex w-[calc(100%-2.75rem)] max-w-181 items-center gap-2 overflow-x-clip overflow-y-visible ps-1 pe-2 pt-5 pb-1",
88 "before:absolute before:inset-0 before:-z-1 before:rounded-b-[16px] before:border before:border-(--chat-composer-outline) before:mask-[linear-gradient(to_bottom,transparent_0_1rem,black_1rem)] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)]",
89 "dark:before:border-white/7 dark:before:bg-[linear-gradient(to_bottom,transparent_0_1rem,rgb(0_0_0/18%)_1rem,transparent_calc(1rem+10px)),linear-gradient(rgb(255_255_255/2%),rgb(255_255_255/2%))] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]",
90 "group-has-data-[composer-banner-surface=attached]/composer-surface:before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] group-has-data-[composer-banner-surface=attached]/composer-surface:before:backdrop-blur-(--glass-blur) group-has-data-[composer-banner-surface=attached]/composer-surface:before:backdrop-saturate-(--glass-saturation)",
91 "not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:backdrop-blur-(--glass-blur) not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:backdrop-saturate-(--glass-saturation)",
92 className,
93 )}
94 {...props}
95 />
96 );
97}
98
99export const ComposerSurface = { Shell, Host, Main, ContextStrip };
apps/web/src/components/chat/ComposerTasksBadge.test.tsx deleted-190
...@@ -1,190 +0,0 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerTasksBadge, ComposerTasksDrawer } from "./ComposerTasksBadge";
5
6const progress = {
7 step: "Attach task progress",
8 completedSteps: 1,
9 totalSteps: 3,
10};
11const steps = [
12 { durationMs: 4_000, step: "Inspect the composer", status: "completed" as const },
13 { step: "Attach task progress", status: "inProgress" as const },
14 { step: "Verify the result", status: "pending" as const },
15];
16
17describe("ComposerTasksBadge", () => {
18 it("renders active progress as an attached composer tab", () => {
19 const markup = renderToStaticMarkup(
20 <ComposerTasksBadge
21 expanded={false}
22 onDismiss={() => undefined}
23 onToggle={() => undefined}
24 progress={progress}
25 steps={steps}
26 />,
27 );
28
29 expect(markup).toContain('data-composer-tasks-badge="true"');
30 expect(markup).toContain('aria-expanded="false"');
31 expect(markup).toContain("chat-composer-shoulder-tab");
32 expect(markup).toContain("chat-composer-tasks-tab");
33 expect(markup).toContain("rounded-t-xl");
34 expect(markup).toContain("border-b-0");
35 expect(markup).toContain("left-5.5");
36 expect(markup).toContain("right-5.5");
37 expect(markup).toContain('data-composer-task-current="true"');
38 expect(markup).toContain("min-w-0 flex-1 truncate");
39 expect(markup).toContain("w-20");
40 expect(markup).toContain("Tasks");
41 expect(markup).toContain("Attach task progress");
42 expect(markup).not.toContain("·");
43 expect(markup).toContain("1/3");
44 expect(markup).toContain("Current task: Attach task progress");
45 expect(markup).toContain("lucide-list-todo");
46 expect(markup).toContain('aria-label="Dismiss tasks for this turn"');
47 expect(markup).toContain("lucide-x");
48 expect(markup).not.toContain("lucide-chevron");
49 expect(markup).toContain("bg-success");
50 expect(markup).toContain("bg-primary");
51 expect(markup).toContain("bg-muted-foreground/25");
52 });
53
54 it("leaves room for the stash tab when both shoulders are present", () => {
55 const markup = renderToStaticMarkup(
56 <ComposerTasksBadge
57 expanded={false}
58 hasTrailingShoulder
59 onDismiss={() => undefined}
60 onToggle={() => undefined}
61 progress={progress}
62 steps={steps}
63 />,
64 );
65
66 expect(markup).toContain("right-30");
67 expect(markup).not.toContain("right-5.5");
68 });
69
70 it("has a compact inline fallback for occupied composer shoulders", () => {
71 const markup = renderToStaticMarkup(
72 <ComposerTasksBadge
73 expanded={false}
74 onDismiss={() => undefined}
75 onToggle={() => undefined}
76 placement="inline"
77 progress={progress}
78 steps={steps}
79 />,
80 );
81
82 expect(markup).toContain("rounded-sm");
83 expect(markup).toContain("1/3");
84 expect(markup).not.toContain("chat-composer-shoulder-tab");
85 expect(markup).not.toContain("rounded-t-xl");
86 });
87
88 it("expands into a read-only attached task list", () => {
89 const markup = renderToStaticMarkup(
90 <ComposerTasksDrawer
91 onCollapse={() => undefined}
92 onDismiss={() => undefined}
93 progress={progress}
94 steps={steps}
95 />,
96 );
97
98 expect(markup).toContain('data-chat-composer-tasks-drawer="true"');
99 expect(markup).toContain('data-chat-composer-collapsed-controls="true"');
100 expect(markup).not.toContain("data-variant");
101 expect(markup).toContain('aria-expanded="true"');
102 expect(markup).toContain('aria-label="Collapse tasks. 1 of 3 complete."');
103 expect(markup).toContain('aria-label="Task list. 1 of 3 complete."');
104 expect(markup).toContain('role="list"');
105 expect(markup).toContain('data-composer-tasks-list="true"');
106 expect(markup).toContain('tabindex="0"');
107 expect(markup).toContain("max-h-[min(24rem,40dvh)]");
108 expect(markup).toContain("overflow-y-auto");
109 expect(markup).toContain("overscroll-contain");
110 expect(markup).toContain("focus-visible:ring-2");
111 expect(markup).toContain("focus-visible:ring-inset");
112 expect(markup).toContain("Inspect the composer");
113 expect(markup).toContain('data-composer-task-duration="true"');
114 expect(markup).toContain("ml-auto w-10");
115 expect(markup).toContain("4.0s");
116 expect(markup).toContain("now");
117 expect(markup).toContain("Attach task progress");
118 expect(markup).toContain("Verify the result");
119 expect(markup).toContain("lucide-list-todo");
120 expect(markup).toContain("lucide-chevron-down");
121 expect(markup).toContain('aria-label="Dismiss tasks for this turn"');
122 expect(markup).not.toContain("bg-success");
123 expect(markup).not.toContain("bg-primary");
124 expect(markup).not.toContain("bg-muted-foreground/25");
125 });
126
127 it("drops the step segments when they would render as a blank gap", () => {
128 const manySteps = Array.from({ length: 24 }, (_, index) => ({
129 step: `Step ${index + 1}`,
130 status: "pending" as const,
131 }));
132 const tab = renderToStaticMarkup(
133 <ComposerTasksBadge
134 expanded={false}
135 onDismiss={() => undefined}
136 onToggle={() => undefined}
137 progress={{ ...progress, totalSteps: manySteps.length }}
138 steps={manySteps}
139 />,
140 );
141 const inline = renderToStaticMarkup(
142 <ComposerTasksBadge
143 expanded={false}
144 onDismiss={() => undefined}
145 onToggle={() => undefined}
146 placement="inline"
147 progress={{ ...progress, totalSteps: manySteps.length }}
148 steps={manySteps}
149 />,
150 );
151
152 expect(tab).not.toContain("w-20");
153 expect(tab).toContain("1/24");
154 expect(inline).not.toContain("w-10");
155 expect(inline).toContain("1/24");
156 });
157
158 it("keeps every long-list task inside the bounded scroll region", () => {
159 const longSteps = Array.from({ length: 20 }, (_, index) => ({
160 step: `Task ${index + 1}`,
161 status: index === 0 ? ("inProgress" as const) : ("pending" as const),
162 }));
163 const markup = renderToStaticMarkup(
164 <ComposerTasksDrawer
165 onCollapse={() => undefined}
166 onDismiss={() => undefined}
167 progress={{ step: "Task 1", completedSteps: 0, totalSteps: longSteps.length }}
168 steps={longSteps}
169 />,
170 );
171
172 const listStart = markup.indexOf('data-composer-tasks-list="true"');
173 expect(listStart).toBeGreaterThan(markup.indexOf('aria-label="Collapse tasks.'));
174 expect(markup.slice(listStart)).toContain("Task 20");
175 });
176
177 it("does not render an empty task count", () => {
178 const markup = renderToStaticMarkup(
179 <ComposerTasksBadge
180 expanded={false}
181 onDismiss={() => undefined}
182 onToggle={() => undefined}
183 progress={{ ...progress, totalSteps: 0 }}
184 steps={steps}
185 />,
186 );
187
188 expect(markup).toBe("");
189 });
190});
apps/web/src/components/chat/ComposerTasksBadge.tsx+131-172
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1import { ChevronDownIcon, ListTodoIcon, XIcon } from "lucide-react";1import { ListTodoIcon } from "lucide-react";
2import { memo } from "react";2import { memo, type ComponentProps } from "react";
33
4import { formatDuration } from "../../session-logic";4import { formatDuration } from "../../session-logic";
5import { cn } from "~/lib/utils";5import { cn } from "~/lib/utils";
6import { Button } from "../ui/button";6import { ComposerBanner } from "./ComposerBanner";
77
8export interface ComposerTasksProgress {8export interface ComposerTasksProgress {
9 readonly step: string;9 readonly step: string;
...@@ -56,205 +56,164 @@ function TaskSegments({...@@ -56,205 +56,164 @@ function TaskSegments({
56 );56 );
57}57}
5858
59export const ComposerTasksBadge = memo(function ComposerTasksBadge({59function TaskSummary({
60 expanded,60 expanded,
61 hasTrailingShoulder = false,
62 onDismiss,
63 onToggle,
64 placement = "tab",
65 progress,61 progress,
66 steps,62 steps,
67}: {63}: {
68 readonly expanded: boolean;64 readonly expanded: boolean;
69 readonly hasTrailingShoulder?: boolean;
70 readonly onDismiss: () => void;
71 readonly onToggle: () => void;
72 readonly placement?: "inline" | "tab";
73 readonly progress: ComposerTasksProgress;65 readonly progress: ComposerTasksProgress;
74 readonly steps: readonly ComposerTaskStep[];66 readonly steps: readonly ComposerTaskStep[];
75}) {67}) {
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
118 return (68 return (
119 <div69 <>
120 className={cn(70 <ComposerBanner.Icon>
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",71 <ListTodoIcon />
122 hasTrailingShoulder ? "right-30" : "right-5.5",72 </ComposerBanner.Icon>
123 allDone && "text-foreground",73 <ComposerBanner.Content>
124 )}74 <span className="shrink-0 text-muted-foreground">Tasks</span>
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>
137 <span75 <span
138 className="min-w-0 flex-1 truncate text-left font-medium text-foreground/80"76 className="min-w-0 flex-1 truncate text-left font-medium text-foreground/80"
139 data-composer-task-current="true"77 data-composer-task-current="true"
140 >78 >
141 {progress.step}79 {progress.step}
142 </span>80 </span>
143 <span81 </ComposerBanner.Content>
144 className={cn(82 <ComposerBanner.Actions>
145 "shrink-0 font-medium tabular-nums",83 <ComposerBanner.Count
146 allDone ? "text-success" : "text-muted-foreground",84 className={progress.completedSteps >= progress.totalSteps ? "text-success" : undefined}
147 )}85 data-composer-task-progress="true"
148 >86 >
149 {progress.completedSteps}/{progress.totalSteps}87 {progress.completedSteps}/{progress.totalSteps}
150 </span>88 </ComposerBanner.Count>
151 <TaskSegments className="w-20" steps={steps} />89 <TaskSegments className="hidden w-20 sm:flex" steps={steps} />
152 </button>90 <ComposerBanner.ToggleIcon expanded={expanded} />
153 <Button91 </ComposerBanner.Actions>
154 size="icon-micro"92 </>
155 variant="ghost-muted"93 );
156 aria-label="Dismiss tasks for this turn"94}
157 className="shrink-0"95
158 onClick={onDismiss}96export const ComposerTasksBadge = memo(function ComposerTasksBadge({
159 onPointerDown={(event) => event.preventDefault()}97 expanded,
160 >98 onToggle,
161 <XIcon aria-hidden className="size-3" />99 placement = "tab",
162 </Button>100 progress,
163 </div>101 steps,
102}: {
103 readonly expanded: boolean;
104 readonly onToggle: () => void;
105 readonly placement?: "inline" | "tab";
106 readonly progress: ComposerTasksProgress;
107 readonly steps: readonly ComposerTaskStep[];
108}) {
109 if (progress.totalSteps <= 0) return null;
110
111 const row = (
112 <ComposerBanner.Row
113 render={<button type="button" />}
114 aria-expanded={expanded}
115 aria-label={`${expanded ? "Collapse tasks" : "Tasks"}: ${progress.completedSteps} of ${progress.totalSteps} complete. Current task: ${progress.step}`}
116 data-composer-tasks-badge="true"
117 onClick={onToggle}
118 onPointerDown={(event) => event.preventDefault()}
119 >
120 <TaskSummary expanded={expanded} progress={progress} steps={steps} />
121 </ComposerBanner.Row>
122 );
123 return placement === "inline" ? (
124 row
125 ) : (
126 <ComposerBanner.Root data-composer-shoulder-tab>{row}</ComposerBanner.Root>
164 );127 );
165});128});
166129
167export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({130export const ComposerTasksContent = memo(function ComposerTasksContent({
168 onDismiss,131 expanded,
169 onCollapse,132 onToggle,
170 progress,133 progress,
171 steps,134 steps,
172}: {135}: {
173 readonly onDismiss: () => void;136 readonly expanded: boolean;
174 readonly onCollapse: () => void;137 readonly onToggle: () => void;
175 readonly progress: ComposerTasksProgress;138 readonly progress: ComposerTasksProgress;
176 readonly steps: readonly ComposerTaskStep[];139 readonly steps: readonly ComposerTaskStep[];
177}) {140}) {
178 return (141 return (
179 <div142 <div
180 className="chat-composer-top-drawer"
181 data-chat-composer-collapsed-controls="true"143 data-chat-composer-collapsed-controls="true"
182 data-chat-composer-tasks-drawer="true"144 data-chat-composer-tasks-drawer={expanded ? "true" : undefined}
183 >145 >
184 <div className="flex items-center gap-1 px-3 py-1.5 sm:px-4">146 <ComposerTasksBadge
185 <button147 expanded={expanded}
186 type="button"148 onToggle={onToggle}
187 aria-expanded="true"149 placement="inline"
188 aria-label={`Collapse tasks. ${progress.completedSteps} of ${progress.totalSteps} complete.`}150 progress={progress}
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"151 steps={steps}
190 onClick={onCollapse}152 />
191 onPointerDown={(event) => event.preventDefault()}153 {expanded ? (
192 >154 <ComposerBanner.Scroll data-composer-tasks-scroll="true">
193 <ListTodoIcon aria-hidden className="size-3.5 shrink-0" />155 <ComposerBanner.Children
194 <span className="font-medium text-foreground">Tasks</span>156 render={<ul />}
195 <span className="tabular-nums">157 aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`}
196 {progress.completedSteps}/{progress.totalSteps}158 data-composer-tasks-list="true"
197 </span>159 >
198 <ChevronDownIcon aria-hidden className="ml-auto size-3.5 shrink-0" />160 {keyedTaskSteps(steps).map(({ key, step }) => (
199 </button>161 <ComposerBanner.Row key={key} render={<li />}>
200 <Button162 <ComposerBanner.Icon
201 size="icon-micro"163 className={cn(
202 variant="ghost-muted"164 "font-mono text-[10px]",
203 aria-label="Dismiss tasks for this turn"165 step.status === "completed"
204 className="shrink-0"166 ? "text-success"
205 onClick={onDismiss}167 : step.status === "inProgress"
206 onPointerDown={(event) => event.preventDefault()}168 ? "text-primary"
207 >169 : "text-muted-foreground/40",
208 <XIcon aria-hidden className="size-3" />170 )}
209 </Button>171 >
210 </div>172 {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}
211 <div173 </ComposerBanner.Icon>
212 aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`}174 <ComposerBanner.Content
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"175 className={cn(
214 data-composer-tasks-list="true"176 step.status === "completed"
215 role="list"177 ? "text-muted-foreground/55"
216 tabIndex={0}178 : step.status === "inProgress"
217 >179 ? "text-foreground/90"
218 {keyedTaskSteps(steps).map(({ key, step }) => (180 : "text-muted-foreground/70",
219 <div key={key} className="flex items-baseline gap-2 text-xs leading-5" role="listitem">181 )}
220 <span182 >
221 aria-hidden183 {step.step}
222 className={cn(184 </ComposerBanner.Content>
223 "w-3 shrink-0 text-center font-mono text-[10px]",185 <ComposerBanner.Actions>
224 step.status === "completed"186 <span
225 ? "text-success"187 className="w-10 text-right text-[10px] text-muted-foreground/45 tabular-nums"
226 : step.status === "inProgress"188 data-composer-task-duration="true"
227 ? "text-primary"189 >
228 : "text-muted-foreground/40",190 {step.durationMs !== undefined
229 )}191 ? formatDuration(step.durationMs)
230 >192 : step.status === "inProgress"
231 {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}193 ? "now"
232 </span>194 : null}
233 <span195 </span>
234 className={cn(196 </ComposerBanner.Actions>
235 "min-w-0 flex-1",197 </ComposerBanner.Row>
236 step.status === "completed"198 ))}
237 ? "text-muted-foreground/55"199 </ComposerBanner.Children>
238 : step.status === "inProgress"200 </ComposerBanner.Scroll>
239 ? "text-foreground/90"201 ) : null}
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>
258 </div>202 </div>
259 );203 );
260});204});
205
206export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
207 onCollapse,
208 ...props
209}: Omit<ComponentProps<typeof ComposerTasksContent>, "expanded" | "onToggle"> & {
210 readonly onCollapse: () => void;
211}) {
212 return (
213 <ComposerBanner.Attachment>
214 <ComposerBanner.Root>
215 <ComposerTasksContent {...props} expanded onToggle={onCollapse} />
216 </ComposerBanner.Root>
217 </ComposerBanner.Attachment>
218 );
219});
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+18-8
...@@ -1377,7 +1377,10 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1377,7 +1377,10 @@ describe("deriveMessagesTimelineRows", () => {
1377 expect(assistantRow?.showAssistantCopyButton).toBe(false);1377 expect(assistantRow?.showAssistantCopyButton).toBe(false);
1378 });1378 });
13791379
1380 it("models work log overflow expansion as inserted list rows", () => {1380 it.each([
1381 ["tools", "tool", "Used 3 tools"],
1382 ["tools and status updates", "info", "Used 2 tools and received 1 update"],
1383 ] as const)("expands %s through the same activity group", (_, middleTone, summary) => {
1381 const timelineEntries = [1384 const timelineEntries = [
1382 {1385 {
1383 id: "work-entry-1",1386 id: "work-entry-1",
...@@ -1398,9 +1401,9 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1398,9 +1401,9 @@ describe("deriveMessagesTimelineRows", () => {
1398 entry: {1401 entry: {
1399 id: "work-2",1402 id: "work-2",
1400 createdAt: "2026-01-01T00:00:02Z",1403 createdAt: "2026-01-01T00:00:02Z",
1401 label: "edit",1404 label: "Status updated",
1402 detail: "Editing MessagesTimeline.tsx",1405 detail: "Editing MessagesTimeline.tsx",
1403 tone: "tool" as const,1406 tone: middleTone,
1404 },1407 },
1405 },1408 },
1406 {1409 {
...@@ -1435,8 +1438,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1435,8 +1438,7 @@ describe("deriveMessagesTimelineRows", () => {
1435 groupId: "work-group:work-entry-1",1438 groupId: "work-group:work-entry-1",
1436 hiddenCount: 3,1439 hiddenCount: 3,
1437 expanded: false,1440 expanded: false,
1438 onlyToolEntries: true,1441 summary,
1439 summary: "Used 3 tools",
1440 });1442 });
1441 expect(expandedRows.map((row) => row.id)).toEqual([1443 expect(expandedRows.map((row) => row.id)).toEqual([
1442 "work-toggle:work-entry-1",1444 "work-toggle:work-entry-1",
...@@ -1488,7 +1490,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1488,7 +1490,7 @@ describe("deriveMessagesTimelineRows", () => {
1488 ["an error-toned entry recovers", ["error", "info", "completed"], false],1490 ["an error-toned entry recovers", ["error", "info", "completed"], false],
1489 ["the final failure is hidden", ["completed", "failed", "info"], true],1491 ["the final failure is hidden", ["completed", "failed", "info"], true],
1490 ["the final failure is visible", ["failed", "info", "failed"], true],1492 ["the final failure is visible", ["failed", "info", "failed"], true],
1491 ["the only failure is visible", ["completed", "info", "failed"], false],1493 ["the only failure is visible", ["completed", "info", "failed"], true],
1492 ] as const)(1494 ] as const)(
1493 "uses the final tool call for mixed work groups when %s",1495 "uses the final tool call for mixed work groups when %s",
1494 (_, statuses, hasFailure) => {1496 (_, statuses, hasFailure) => {
...@@ -1524,10 +1526,18 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1524,10 +1526,18 @@ describe("deriveMessagesTimelineRows", () => {
1524 });1526 });
15251527
1526 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({1528 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
1527 hiddenCount: 2,1529 hiddenCount: statuses.some((status) => status === "error") ? 2 : 3,
1528 summary: null,1530 summary: statuses.some((status) => status === "error")
1531 ? "Received 1 update and used 1 tool"
1532 : "Used 2 tools and received 1 update",
1529 hasFailure,1533 hasFailure,
1530 });1534 });
1535 if (statuses.some((status) => status === "error")) {
1536 expect(rows[0]).toMatchObject({
1537 kind: "work",
1538 groupedEntries: [{ tone: "error", label: "Command failed" }],
1539 });
1540 }
1531 },1541 },
1532 );1542 );
1533});1543});
apps/web/src/components/chat/MessagesTimeline.logic.ts+33-109
...@@ -6,13 +6,11 @@ import {...@@ -6,13 +6,11 @@ import {
6 workEntryIndicatesToolNeutralStatus,6 workEntryIndicatesToolNeutralStatus,
7 workLogEntryIsToolLike,7 workLogEntryIsToolLike,
8 type TimelineEntry,8 type TimelineEntry,
9 type TurnPlanEntry,
10 type WorkLogEntry,9 type WorkLogEntry,
11} from "../../session-logic";10} from "../../session-logic";
12import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";11import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
13import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";12import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
1413
15export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
16export const TIMELINE_MINIMAP_ITEM_SPACING = 8;14export const TIMELINE_MINIMAP_ITEM_SPACING = 8;
17export const TIMELINE_MINIMAP_MIN_ITEMS = 2;15export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
18export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";16export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
...@@ -203,9 +201,8 @@ export type MessagesTimelineRow =...@@ -203,9 +201,8 @@ export type MessagesTimelineRow =
203 groupId: string;201 groupId: string;
204 hiddenCount: number;202 hiddenCount: number;
205 expanded: boolean;203 expanded: boolean;
206 onlyToolEntries: boolean;204 summary: string;
207 summary: string | null;205 summaryKind: ToolGroupSummaryKind;
208 summaryKind: ToolGroupSummaryKind | null;
209 hasFailure: boolean;206 hasFailure: boolean;
210 }207 }
211 | {208 | {
...@@ -234,12 +231,6 @@ export type MessagesTimelineRow =...@@ -234,12 +231,6 @@ export type MessagesTimelineRow =
234 createdAt: string;231 createdAt: string;
235 proposedPlan: ProposedPlan;232 proposedPlan: ProposedPlan;
236 }233 }
237 | {
238 kind: "turn-plan";
239 id: string;
240 createdAt: string;
241 turnPlan: TurnPlanEntry;
242 }
243 | {234 | {
244 kind: "working";235 kind: "working";
245 id: string;236 id: string;
...@@ -275,7 +266,7 @@ export function normalizeCompactToolLabel(value: string): string {...@@ -275,7 +266,7 @@ export function normalizeCompactToolLabel(value: string): string {
275 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();266 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
276}267}
277268
278type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other";269type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update";
279type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed";270type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed";
280271
281export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {272export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {
...@@ -305,7 +296,7 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {...@@ -305,7 +296,7 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
305 }296 }
306 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";297 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
307 if (entry.itemType === "web_search") return "search";298 if (entry.itemType === "web_search") return "search";
308 return "other";299 return workLogEntryIsToolLike(entry) ? "other" : "update";
309}300}
310301
311function toolGroupActionCount(302function toolGroupActionCount(
...@@ -340,6 +331,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {...@@ -340,6 +331,8 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
340 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;331 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
341 case "other":332 case "other":
342 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;333 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
334 case "update":
335 return `Received ${count} ${count === 1 ? "update" : "updates"}`;
343 }336 }
344}337}
345338
...@@ -513,9 +506,6 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {...@@ -513,9 +506,6 @@ function timelineEntryTurnId(entry: TimelineEntry): TurnId | null {
513 if (entry.kind === "message") {506 if (entry.kind === "message") {
514 return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null;507 return entry.message.role === "assistant" ? (entry.message.turnId ?? null) : null;
515 }508 }
516 if (entry.kind === "turn-plan") {
517 return entry.turnPlan.turnId;
518 }
519 if (entry.kind === "proposed-plan") {509 if (entry.kind === "proposed-plan") {
520 return entry.proposedPlan.turnId;510 return entry.proposedPlan.turnId;
521 }511 }
...@@ -712,8 +702,6 @@ export function deriveMessagesTimelineRows(input: {...@@ -712,8 +702,6 @@ export function deriveMessagesTimelineRows(input: {
712 unsettledTurnId !== null &&702 unsettledTurnId !== null &&
713 entry.toolLifecycleStatus === "inProgress" &&703 entry.toolLifecycleStatus === "inProgress" &&
714 entry.turnId === unsettledTurnId;704 entry.turnId === unsettledTurnId;
715 const isVisibleActiveToolEntry = (entry: WorkLogEntry) =>
716 workLogEntryIsToolLike(entry) && workEntryIsVisibleInGroup(entry, true);
717 const activeEntries = input.isWorking705 const activeEntries = input.isWorking
718 ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))706 ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
719 : [];707 : [];
...@@ -728,7 +716,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -728,7 +716,7 @@ export function deriveMessagesTimelineRows(input: {
728 entry.entry.toolLifecycleStatus === "inProgress"716 entry.entry.toolLifecycleStatus === "inProgress"
729 );717 );
730 }718 }
731 if (entry.kind === "proposed-plan" || entry.kind === "turn-plan") return true;719 if (entry.kind === "proposed-plan") return true;
732 return false;720 return false;
733 });721 });
734722
...@@ -739,8 +727,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -739,8 +727,7 @@ export function deriveMessagesTimelineRows(input: {
739 !entryBelongsToActiveTurn(entry, index) ||727 !entryBelongsToActiveTurn(entry, index) ||
740 entry.kind !== "work" ||728 entry.kind !== "work" ||
741 entry.entry.agentSpawn !== undefined ||729 entry.entry.agentSpawn !== undefined ||
742 entry.entry.tone === "error" ||730 entry.entry.tone === "error"
743 !workLogEntryIsToolLike(entry.entry)
744 ) {731 ) {
745 break;732 break;
746 }733 }
...@@ -748,7 +735,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -748,7 +735,7 @@ export function deriveMessagesTimelineRows(input: {
748 }735 }
749 const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));736 const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
750 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(737 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
751 activeToolEntries.filter((entry) => isVisibleActiveToolEntry(entry.entry)),738 activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),
752 (entry) => entry.entry,739 (entry) => entry.entry,
753 );740 );
754 const activeWorkAnchor = activeToolEntries[0];741 const activeWorkAnchor = activeToolEntries[0];
...@@ -828,6 +815,17 @@ export function deriveMessagesTimelineRows(input: {...@@ -828,6 +815,17 @@ export function deriveMessagesTimelineRows(input: {
828 }815 }
829816
830 if (timelineEntry.kind === "work") {817 if (timelineEntry.kind === "work") {
818 if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") {
819 nextRows.push({
820 kind: "work",
821 id: timelineEntry.id,
822 createdAt: timelineEntry.createdAt,
823 groupedEntries: [timelineEntry.entry],
824 isExpandedToolGroupEntry: false,
825 isLastExpandedToolGroupEntry: false,
826 });
827 continue;
828 }
831 const groupedEntries = [timelineEntry.entry];829 const groupedEntries = [timelineEntry.entry];
832 let cursor = index + 1;830 let cursor = index + 1;
833 while (cursor < input.timelineEntries.length) {831 while (cursor < input.timelineEntries.length) {
...@@ -835,6 +833,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -835,6 +833,8 @@ export function deriveMessagesTimelineRows(input: {
835 if (833 if (
836 !nextEntry ||834 !nextEntry ||
837 nextEntry.kind !== "work" ||835 nextEntry.kind !== "work" ||
836 nextEntry.entry.agentSpawn !== undefined ||
837 nextEntry.entry.tone === "error" ||
838 activeWorkEntryIds.has(nextEntry.id) ||838 activeWorkEntryIds.has(nextEntry.id) ||
839 collapsedEntryIds.has(nextEntry.id) ||839 collapsedEntryIds.has(nextEntry.id) ||
840 foldsByAnchorEntryId.has(nextEntry.id)840 foldsByAnchorEntryId.has(nextEntry.id)
...@@ -851,14 +851,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -851,14 +851,8 @@ export function deriveMessagesTimelineRows(input: {
851 (entry) => entry,851 (entry) => entry,
852 );852 );
853 if (visibleGroupedEntries.length > 0) {853 if (visibleGroupedEntries.length > 0) {
854 const onlyToolEntries = visibleGroupedEntries.every(
855 (entry) =>
856 workLogEntryIsToolLike(entry) &&
857 entry.agentSpawn === undefined &&
858 entry.tone !== "error",
859 );
860 const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);854 const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);
861 if (onlyToolEntries && activeInProgressToolEntries.length > 0) {855 if (activeInProgressToolEntries.length > 0) {
862 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);856 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
863 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;857 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
864 const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;858 const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;
...@@ -883,10 +877,11 @@ export function deriveMessagesTimelineRows(input: {...@@ -883,10 +877,11 @@ export function deriveMessagesTimelineRows(input: {
883 });877 });
884 }878 }
885 }879 }
886 } else if (onlyToolEntries) {880 } else {
887 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);881 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
888 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;882 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
889 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);883 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
884 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
890 nextRows.push({885 nextRows.push({
891 kind: "work-toggle",886 kind: "work-toggle",
892 id: `work-toggle:${timelineEntry.id}`,887 id: `work-toggle:${timelineEntry.id}`,
...@@ -894,10 +889,15 @@ export function deriveMessagesTimelineRows(input: {...@@ -894,10 +889,15 @@ export function deriveMessagesTimelineRows(input: {
894 groupId,889 groupId,
895 hiddenCount: visibleGroupedEntries.length,890 hiddenCount: visibleGroupedEntries.length,
896 expanded,891 expanded,
897 onlyToolEntries: true,892 summary:
898 summary: summarizeToolGroup(visibleGroupedEntries),893 visibleGroupedEntries.length === 1 &&
894 !workLogEntryIsToolLike(visibleGroupedEntries[0]!)
895 ? visibleGroupedEntries[0]!.label
896 : summarizeToolGroup(visibleGroupedEntries),
899 summaryKind,897 summaryKind,
900 hasFailure: workEntryDisplayIndicatesToolFailure(visibleGroupedEntries.at(-1)!),898 hasFailure:
899 latestToolEntry !== undefined &&
900 workEntryDisplayIndicatesToolFailure(latestToolEntry),
901 });901 });
902 if (expanded) {902 if (expanded) {
903 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {903 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
...@@ -911,64 +911,6 @@ export function deriveMessagesTimelineRows(input: {...@@ -911,64 +911,6 @@ export function deriveMessagesTimelineRows(input: {
911 });911 });
912 }912 }
913 }913 }
914 } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
915 nextRows.push({
916 kind: "work",
917 id: timelineEntry.id,
918 createdAt: timelineEntry.createdAt,
919 groupedEntries: visibleGroupedEntries,
920 isExpandedToolGroupEntry: false,
921 isLastExpandedToolGroupEntry: false,
922 });
923 } else {
924 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
925 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
926 // Agent-spawn CTA rows are always visible: a running fleet must
927 // never hide behind a "+N tool calls" toggle. Selection is by
928 // membership (spawn OR recent-tail), preserving the group's
929 // chronological order in both collapsed and expanded states
930 // (review finding: concatenating two filtered lists moved a
931 // mid-group spawn row above earlier tool rows).
932 const overflowCandidates = visibleGroupedEntries.filter(
933 (entry) => entry.agentSpawn === undefined,
934 );
935 const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);
936 const hiddenIds = new Set(hiddenEntries.map((entry) => entry.id));
937 const visibleEntries = visibleGroupedEntries.filter(
938 (entry) => entry.agentSpawn !== undefined || !hiddenIds.has(entry.id),
939 );
940 const renderedEntries = expanded ? visibleGroupedEntries : visibleEntries;
941
942 for (const workEntry of renderedEntries) {
943 nextRows.push({
944 kind: "work",
945 id: workEntry.id,
946 createdAt: workEntry.createdAt,
947 groupedEntries: [workEntry],
948 isExpandedToolGroupEntry: false,
949 isLastExpandedToolGroupEntry: false,
950 });
951 }
952
953 if (hiddenEntries.length > 0) {
954 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
955
956 nextRows.push({
957 kind: "work-toggle",
958 id: `work-toggle:${timelineEntry.id}`,
959 createdAt: timelineEntry.createdAt,
960 groupId,
961 hiddenCount: hiddenEntries.length,
962 expanded,
963 onlyToolEntries: hiddenEntries.every(workLogEntryIsToolLike),
964 summary: null,
965 summaryKind: null,
966 hasFailure:
967 latestToolEntry !== undefined &&
968 workEntryDisplayIndicatesToolFailure(latestToolEntry) &&
969 hiddenEntries.some(workEntryDisplayIndicatesToolFailure),
970 });
971 }
972 }914 }
973 }915 }
974 index = cursor - 1;916 index = cursor - 1;
...@@ -985,16 +927,6 @@ export function deriveMessagesTimelineRows(input: {...@@ -985,16 +927,6 @@ export function deriveMessagesTimelineRows(input: {
985 continue;927 continue;
986 }928 }
987929
988 if (timelineEntry.kind === "turn-plan") {
989 nextRows.push({
990 kind: "turn-plan",
991 id: timelineEntry.id,
992 createdAt: timelineEntry.createdAt,
993 turnPlan: timelineEntry.turnPlan,
994 });
995 continue;
996 }
997
998 const assistantTurnStillInProgress =930 const assistantTurnStillInProgress =
999 timelineEntry.message.role === "assistant" &&931 timelineEntry.message.role === "assistant" &&
1000 unsettledTurnId !== null &&932 unsettledTurnId !== null &&
...@@ -1076,13 +1008,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -1076,13 +1008,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1076 case "proposed-plan":1008 case "proposed-plan":
1077 return a.proposedPlan === (b as typeof a).proposedPlan;1009 return a.proposedPlan === (b as typeof a).proposedPlan;
10781010
1079 case "turn-plan": {
1080 const bp = b as typeof a;
1081 // Plans rewrite in place: compare the snapshot's identity fields so an
1082 // unchanged plan keeps its row reference (virtualization stability).
1083 return a.createdAt === bp.createdAt && a.turnPlan.plan === bp.turnPlan.plan;
1084 }
1085
1086 case "work": {1011 case "work": {
1087 const bw = b as typeof a;1012 const bw = b as typeof a;
1088 return (1013 return (
...@@ -1110,7 +1035,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -1110,7 +1035,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1110 a.groupId === bw.groupId &&1035 a.groupId === bw.groupId &&
1111 a.hiddenCount === bw.hiddenCount &&1036 a.hiddenCount === bw.hiddenCount &&
1112 a.expanded === bw.expanded &&1037 a.expanded === bw.expanded &&
1113 a.onlyToolEntries === bw.onlyToolEntries &&
1114 a.summary === bw.summary &&1038 a.summary === bw.summary &&
1115 a.summaryKind === bw.summaryKind &&1039 a.summaryKind === bw.summaryKind &&
1116 a.hasFailure === bw.hasFailure1040 a.hasFailure === bw.hasFailure
apps/web/src/components/chat/MessagesTimeline.test.tsx+3-20
...@@ -1053,7 +1053,6 @@ describe("MessagesTimeline", () => {...@@ -1053,7 +1053,6 @@ describe("MessagesTimeline", () => {
1053 );1053 );
10541054
1055 expect(markup).toContain("Context compacted");1055 expect(markup).toContain("Context compacted");
1056 expect(markup).toContain("Work Log");
1057 });1056 });
10581057
1059 it("summarizes changed files in one line", () => {1058 it("summarizes changed files in one line", () => {
...@@ -1210,7 +1209,7 @@ describe("MessagesTimeline", () => {...@@ -1210,7 +1209,7 @@ describe("MessagesTimeline", () => {
1210 />,1209 />,
1211 );1210 );
12121211
1213 expect(markup).toContain("+2 previous log entries");1212 expect(markup).toContain("Ran 2 commands and received 1 update");
1214 expect(markup).not.toContain('aria-label="Hidden work includes a failure"');1213 expect(markup).not.toContain('aria-label="Hidden work includes a failure"');
1215 });1214 });
12161215
...@@ -1348,21 +1347,6 @@ describe("MessagesTimeline", () => {...@@ -1348,21 +1347,6 @@ describe("MessagesTimeline", () => {
1348 expect(markup).toContain("tool call failed");1347 expect(markup).toContain("tool call failed");
1349 });1348 });
13501349
1351 it("aligns the iconless Thinking row with the working timer", () => {
1352 const markup = renderToStaticMarkup(
1353 <MessagesTimeline
1354 {...buildProps()}
1355 isWorking
1356 activeTurnStartedAt={MESSAGE_CREATED_AT}
1357 timelineEntries={[]}
1358 />,
1359 );
1360
1361 expect(markup).toContain("Working for");
1362 expect(markup).toContain("Thinking");
1363 expect(markup).toContain("gap-1.5 py-0.5 px-1");
1364 });
1365
1366 it("renders review comment contexts as structured cards instead of raw tags", () => {1350 it("renders review comment contexts as structured cards instead of raw tags", () => {
1367 const markup = renderToStaticMarkup(1351 const markup = renderToStaticMarkup(
1368 <MessagesTimeline1352 <MessagesTimeline
...@@ -1440,7 +1424,7 @@ describe("MessagesTimeline", () => {...@@ -1440,7 +1424,7 @@ describe("MessagesTimeline", () => {
1440 expect(markup).not.toContain('data-testid="file-diff"');1424 expect(markup).not.toContain('data-testid="file-diff"');
1441 });1425 });
14421426
1443 it("renders a muted failure marker for failed tool lifecycle entries", () => {1427 it("keeps failed lifecycle entries discoverable in mixed activity summaries", () => {
1444 const markup = renderToStaticMarkup(1428 const markup = renderToStaticMarkup(
1445 <MessagesTimeline1429 <MessagesTimeline
1446 {...buildProps()}1430 {...buildProps()}
...@@ -1473,8 +1457,7 @@ describe("MessagesTimeline", () => {...@@ -1473,8 +1457,7 @@ describe("MessagesTimeline", () => {
1473 />,1457 />,
1474 );1458 );
14751459
1476 expect(markup).toContain("lucide-circle-alert");1460 expect(markup).toContain('aria-label="Received 1 update and used 1 tool, tool call failed"');
1477 expect(markup).toContain('aria-label="Tool call failed"');
1478 // Ordinary tool failures render muted, not red.1461 // Ordinary tool failures render muted, not red.
1479 expect(markup).not.toContain("text-destructive");1462 expect(markup).not.toContain("text-destructive");
1480 });1463 });
apps/web/src/components/chat/MessagesTimeline.tsx+23-171
...@@ -963,8 +963,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -963,8 +963,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
963 const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;963 const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;
964 const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;964 const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
965 const isExpandedToolGroupHeader =965 const isExpandedToolGroupHeader =
966 (row.kind === "work-toggle" && row.summary !== null && row.onlyToolEntries && row.expanded) ||966 (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);
967 (row.kind === "work-live" && row.expanded);
968967
969 return (968 return (
970 <div969 <div
...@@ -984,8 +983,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -984,8 +983,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
984 !row.showAssistantMeta) ||983 !row.showAssistantMeta) ||
985 row.kind === "work" ||984 row.kind === "work" ||
986 row.kind === "work-live" ||985 row.kind === "work-live" ||
987 row.kind === "work-toggle" ||986 row.kind === "work-toggle"
988 row.kind === "turn-plan"
989 ? "pb-2"987 ? "pb-2"
990 : "pb-4",988 : "pb-4",
991 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,989 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
...@@ -1009,7 +1007,6 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -1009,7 +1007,6 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
1009 <AssistantTimelineRow row={row} />1007 <AssistantTimelineRow row={row} />
1010 ) : null}1008 ) : null}
1011 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}1009 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
1012 {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null}
1013 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}1010 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
1014 </div>1011 </div>
1015 );1012 );
...@@ -1325,121 +1322,25 @@ function ProposedPlanTimelineRow({...@@ -1325,121 +1322,25 @@ function ProposedPlanTimelineRow({
1325 );1322 );
1326}1323}
13271324
1328/**
1329 * Inline folded plan chip: one row per turn that produced plan/todo steps.
1330 * Collapsed by default — a segment bar plus the in-progress step label —
1331 * and expands in place to the full step list. Replaces the old plan sidebar.
1332 */
1333const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
1334 row,
1335}: {
1336 row: Extract<TimelineRow, { kind: "turn-plan" }>;
1337}) {
1338 const [expanded, setExpanded] = useState(false);
1339 const { steps } = row.turnPlan.plan;
1340 const completedCount = steps.filter((step) => step.status === "completed").length;
1341 const allDone = completedCount === steps.length;
1342 // Label priority: the in-progress step, else the next pending step (plan
1343 // just created), else the last step (plan finished, rendered muted).
1344 const label =
1345 steps.find((step) => step.status === "inProgress")?.step ??
1346 steps.find((step) => step.status === "pending")?.step ??
1347 steps.at(-1)?.step ??
1348 "Plan";
1349 const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon;
1350
1351 return (
1352 <div className="min-w-0 px-1 py-0.5">
1353 <button
1354 type="button"
1355 className="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1356 aria-expanded={expanded}
1357 onClick={() => setExpanded((value) => !value)}
1358 >
1359 <Chevron className="size-3.5 shrink-0 text-muted-foreground/65" />
1360 {steps.length > 1 ? (
1361 <span aria-hidden className="flex shrink-0 items-center gap-0.5">
1362 {steps.map((step) => (
1363 <span
1364 key={step.step}
1365 className={cn(
1366 "h-[3px] w-2.5 rounded-full",
1367 step.status === "completed"
1368 ? "bg-success"
1369 : step.status === "inProgress"
1370 ? "bg-primary"
1371 : "bg-muted-foreground/25",
1372 )}
1373 />
1374 ))}
1375 </span>
1376 ) : null}
1377 <span
1378 className={cn(
1379 "min-w-0 truncate",
1380 allDone ? "text-muted-foreground/65" : "font-medium text-foreground/85",
1381 )}
1382 >
1383 {label}
1384 </span>
1385 {steps.length > 1 ? (
1386 <span className="shrink-0 text-muted-foreground/50 tabular-nums">
1387 {completedCount}/{steps.length}
1388 </span>
1389 ) : null}
1390 </button>
1391 {expanded ? (
1392 <div className="mt-0.5 space-y-px pl-6">
1393 {steps.map((step) => (
1394 <div key={step.step} className="flex items-baseline gap-2 text-[12px] leading-5">
1395 <span
1396 className={cn(
1397 "w-3 shrink-0 text-center font-mono text-[10px]",
1398 step.status === "completed"
1399 ? "text-success"
1400 : step.status === "inProgress"
1401 ? "text-primary"
1402 : "text-muted-foreground/40",
1403 )}
1404 aria-hidden
1405 >
1406 {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}
1407 </span>
1408 <span
1409 className={cn(
1410 "min-w-0",
1411 step.status === "completed"
1412 ? "text-muted-foreground/55"
1413 : step.status === "inProgress"
1414 ? "text-foreground/90"
1415 : "text-muted-foreground/70",
1416 )}
1417 >
1418 {step.step}
1419 </span>
1420 </div>
1421 ))}
1422 </div>
1423 ) : null}
1424 </div>
1425 );
1426});
1427
1428function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {1325function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1429 const { workingStepLabel } = use(TimelineRowActivityCtx);1326 const { workingStepLabel } = use(TimelineRowActivityCtx);
1430 return (1327 return (
1431 <div>1328 <div>
1432 <div className="border-b border-border/60 pb-2 pt-1">1329 <div className="border-b border-border/60 pb-2 pt-1">
1433 <div className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">1330 <div className="flex min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
1434 {row.createdAt ? (1331 <span className="shrink-0 whitespace-nowrap">
1435 <>1332 {row.createdAt ? (
1436 Working for <WorkingTimer createdAt={row.createdAt} />1333 <>
1437 </>1334 Working for <WorkingTimer createdAt={row.createdAt} />
1438 ) : (1335 </>
1439 "Working..."1336 ) : (
1440 )}1337 "Working..."
1338 )}
1339 </span>
1441 {workingStepLabel ? (1340 {workingStepLabel ? (
1442 <span className="ml-2 text-muted-foreground/55">· {workingStepLabel}</span>1341 <span className="ml-2 min-w-0 truncate text-muted-foreground/55">
1342 · {workingStepLabel}
1343 </span>
1443 ) : null}1344 ) : null}
1444 </div>1345 </div>
1445 </div>1346 </div>
...@@ -1499,12 +1400,6 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1499,12 +1400,6 @@ const WorkGroupSection = memo(function WorkGroupSection({
1499 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),1400 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),
1500 [groupedEntries, isExpandedToolGroupEntry],1401 [groupedEntries, isExpandedToolGroupEntry],
1501 );1402 );
1502 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));
1503 const groupLabel = onlyToolEntries
1504 ? nonEmptyEntries.length === 1
1505 ? "1 tool call"
1506 : `${nonEmptyEntries.length} tool calls`
1507 : "Work Log";
1508 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";1403 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
15091404
1510 if (nonEmptyEntries.length === 0) return null;1405 if (nonEmptyEntries.length === 0) return null;
...@@ -1512,11 +1407,8 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1512,11 +1407,8 @@ const WorkGroupSection = memo(function WorkGroupSection({
1512 return (1407 return (
1513 <GroupContainer1408 <GroupContainer
1514 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}1409 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
1515 aria-label={isExpandedToolGroupEntry ? undefined : groupLabel}1410 aria-label={isExpandedToolGroupEntry ? undefined : "Activity"}
1516 >1411 >
1517 {!onlyToolEntries && (
1518 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
1519 )}
1520 <div className="space-y-px">1412 <div className="space-y-px">
1521 {nonEmptyEntries.map((workEntry) => (1413 {nonEmptyEntries.map((workEntry) => (
1522 <SimpleWorkEntryRow1414 <SimpleWorkEntryRow
...@@ -1649,8 +1541,8 @@ function toolGroupSummaryIconName(...@@ -1649,8 +1541,8 @@ function toolGroupSummaryIconName(
1649 return "bot";1541 return "bot";
1650 case "tone-tool":1542 case "tone-tool":
1651 return "zap";1543 return "zap";
1544 case "update":
1652 case "mixed":1545 case "mixed":
1653 case null:
1654 return "hammer";1546 return "hammer";
1655 }1547 }
1656}1548}
...@@ -1661,61 +1553,21 @@ function WorkGroupToggleTimelineRow({...@@ -1661,61 +1553,21 @@ function WorkGroupToggleTimelineRow({
1661 row: Extract<TimelineRow, { kind: "work-toggle" }>;1553 row: Extract<TimelineRow, { kind: "work-toggle" }>;
1662}) {1554}) {
1663 const ctx = use(TimelineRowCtx);1555 const ctx = use(TimelineRowCtx);
1664 if (row.onlyToolEntries && row.summary) {
1665 return (
1666 <button
1667 type="button"
1668 className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1669 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
1670 aria-expanded={row.expanded}
1671 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1672 >
1673 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1674 <WorkEntryIconSvg
1675 name={toolGroupSummaryIconName(row.summaryKind)}
1676 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1677 />
1678 </span>
1679 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
1680 </button>
1681 );
1682 }
1683 const labelNoun = row.onlyToolEntries
1684 ? row.hiddenCount === 1
1685 ? "tool call"
1686 : "tool calls"
1687 : row.hiddenCount === 1
1688 ? "log entry"
1689 : "log entries";
1690 return (1556 return (
1691 <button1557 <button
1692 type="button"1558 type="button"
1693 className="flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"1559 className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1694 aria-label={1560 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
1695 row.hasFailure && !row.expanded
1696 ? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
1697 : undefined
1698 }
1699 aria-expanded={row.expanded}1561 aria-expanded={row.expanded}
1700 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}1562 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1701 >1563 >
1702 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">1564 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1703 <ChevronDownIcon1565 <WorkEntryIconSvg
1704 className={cn(1566 name={toolGroupSummaryIconName(row.summaryKind)}
1705 "size-4 shrink-0 opacity-70 transition-transform duration-200",1567 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1706 row.expanded && "rotate-180",
1707 )}
1708 />1568 />
1709 </span>1569 </span>
1710 {row.expanded ? (1570 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
1711 <span className="font-medium text-foreground">
1712 Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"}
1713 </span>
1714 ) : (
1715 <span className="font-medium text-foreground">
1716 +{row.hiddenCount} previous {labelNoun}
1717 </span>
1718 )}
1719 </button>1571 </button>
1720 );1572 );
1721}1573}
apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx deleted-22
...@@ -1,22 +0,0 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ThreadSyncStatusPill } from "./ThreadSyncStatusPill";
5
6describe("ThreadSyncStatusPill", () => {
7 it.each([
8 ["loading", "Loading messages..."],
9 ["syncing", "Syncing messages..."],
10 ] as const)("renders the %s message sync phase", (phase, label) => {
11 const markup = renderToStaticMarkup(<ThreadSyncStatusPill phase={phase} />);
12
13 expect(markup).toContain('role="status"');
14 expect(markup).toContain('data-thread-sync-drawer="true"');
15 expect(markup).toContain("chat-composer-drawer-surface");
16 expect(markup).toContain("chat-composer-drawer-attached");
17 expect(markup).toContain("chat-composer-drawer-slot");
18 expect(markup).toContain("pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)]");
19 expect(markup).toContain(label);
20 expect(markup).not.toContain("animate-");
21 });
22});
apps/web/src/components/chat/ThreadSyncStatusPill.tsx deleted-19
...@@ -1,19 +0,0 @@
1import { LoaderCircleIcon } from "lucide-react";
2
3import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
4
5export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhase }) {
6 const label = threadSyncLabel(phase);
7
8 return (
9 <div
10 aria-label={label}
11 className="chat-composer-drawer-surface chat-composer-drawer-attached chat-composer-drawer-slot pointer-events-none flex items-center gap-2 px-3 pt-2 pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)] text-foreground text-xs font-medium sm:px-4"
12 data-thread-sync-drawer="true"
13 role="status"
14 >
15 <LoaderCircleIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
16 <span className="truncate">{label}</span>
17 </div>
18 );
19}
apps/web/src/index.css-524
...@@ -759,472 +759,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -759,472 +759,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
759 outline: none;759 outline: none;
760 }760 }
761761
762 .chat-composer-shoulder-tab {
763 border-color: var(
764 --chat-composer-outline,
765 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
766 );
767 background: color-mix(
768 in srgb,
769 var(--chat-composer-glass-surface, var(--card)) var(--glass-opacity),
770 transparent
771 );
772 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
773 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
774 box-shadow: 0 -8px 24px -18px rgb(0 0 0 / 45%);
775
776 @variant dark {
777 box-shadow: 0 -10px 28px -18px rgb(0 0 0 / 80%);
778 }
779
780 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
781 background: var(--chat-composer-glass-surface, var(--card));
782 }
783 }
784
785 .chat-composer-banner-stack-cap {
786 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
787 --chat-composer-attached-outline: var(
788 --chat-composer-outline,
789 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
790 );
791 background: color-mix(
792 in srgb,
793 var(--chat-composer-attached-surface) var(--glass-opacity),
794 transparent
795 );
796 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
797 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
798
799 @variant dark {
800 --chat-composer-attached-surface: var(
801 --chat-composer-glass-surface,
802 color-mix(in srgb, var(--background) 96%, var(--color-white))
803 );
804 --chat-composer-attached-outline: var(
805 --chat-composer-outline,
806 color-mix(in srgb, var(--color-white) 5%, transparent)
807 );
808 }
809
810 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
811 background: var(--chat-composer-attached-surface);
812 }
813 }
814
815 /* Mirror the run-context strip above the composer. These surfaces deliberately
816 own no card fill or border themselves: a masked pseudo-element draws the
817 same inset glass, 16px outer corners, quiet outline, and open attachment
818 seam as the strip below. */
819 .chat-composer-drawer-surface,
820 .chat-composer-top-drawer {
821 --chat-composer-attachment-overlap: calc(1rem + 1px);
822 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
823 --chat-composer-attached-outline: var(
824 --chat-composer-outline,
825 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
826 );
827 --chat-composer-attached-tint: transparent;
828 position: relative;
829 isolation: isolate;
830 border: 0 !important;
831 background: transparent !important;
832 box-shadow: none;
833
834 @variant dark {
835 --chat-composer-attached-surface: var(
836 --chat-composer-glass-surface,
837 color-mix(in srgb, var(--background) 96%, var(--color-white))
838 );
839 --chat-composer-attached-outline: var(
840 --chat-composer-outline,
841 color-mix(in srgb, var(--color-white) 5%, transparent)
842 );
843
844 &::before {
845 background:
846 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
847 linear-gradient(
848 to top,
849 transparent 0 var(--chat-composer-attachment-overlap),
850 rgb(0 0 0 / 18%) var(--chat-composer-attachment-overlap),
851 transparent calc(var(--chat-composer-attachment-overlap) + 10px)
852 ),
853 color-mix(
854 in srgb,
855 var(--chat-composer-attached-surface) var(--glass-opacity),
856 transparent
857 );
858 box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
859 }
860 }
861 }
862
863 .chat-composer-drawer-attached,
864 .chat-composer-top-drawer {
865 padding-bottom: var(--chat-composer-attachment-overlap);
866 }
867
868 [data-chat-composer-form="true"],
869 .chat-composer-drawer-slot,
870 .chat-composer-top-drawer {
871 --chat-composer-drawer-inset: 1.375rem;
872 }
873
874 .chat-composer-drawer-slot,
875 .chat-composer-top-drawer {
876 --chat-composer-attachment-overlap: calc(1rem + 1px);
877 width: calc(100% - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
878 max-width: calc(48rem - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
879 margin-inline: auto;
880 margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap));
881 }
882
883 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)::before {
884 pointer-events: none;
885 position: absolute;
886 z-index: -1;
887 inset: 0;
888 border: 1px solid var(--chat-composer-attached-outline);
889 border-radius: 16px 16px 0 0;
890 background:
891 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
892 color-mix(in srgb, var(--chat-composer-attached-surface) var(--glass-opacity), transparent);
893 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
894 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
895 -webkit-mask-image: linear-gradient(
896 to top,
897 transparent 0 var(--chat-composer-attachment-overlap),
898 black var(--chat-composer-attachment-overlap)
899 );
900 mask-image: linear-gradient(
901 to top,
902 transparent 0 var(--chat-composer-attachment-overlap),
903 black var(--chat-composer-attachment-overlap)
904 );
905 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
906 content: "";
907
908 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
909 background:
910 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
911 var(--chat-composer-attached-surface) !important;
912 }
913 }
914
915 .chat-composer-drawer-slot + .chat-composer-drawer-slot::before,
916 .chat-composer-drawer-slot + :has(.chat-composer-top-drawer) .chat-composer-top-drawer::before {
917 border-top: 0;
918 border-radius: 0;
919 }
920
921 .chat-composer-top-drawer {
922 z-index: 0;
923 }
924
925 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="error"] {
926 --chat-composer-attached-outline: color-mix(in srgb, var(--error) 32%, transparent);
927 --chat-composer-attached-tint: color-mix(in srgb, var(--error) 8%, transparent);
928 }
929
930 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="info"] {
931 --chat-composer-attached-outline: color-mix(in srgb, var(--info) 32%, transparent);
932 --chat-composer-attached-tint: color-mix(in srgb, var(--info) 4%, transparent);
933 }
934
935 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="success"] {
936 --chat-composer-attached-outline: color-mix(in srgb, var(--success) 32%, transparent);
937 --chat-composer-attached-tint: color-mix(in srgb, var(--success) 4%, transparent);
938 }
939
940 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="warning"] {
941 --chat-composer-attached-outline: color-mix(in srgb, var(--warning) 28%, transparent);
942 --chat-composer-attached-tint: color-mix(in srgb, var(--warning) 8%, transparent);
943 }
944
945 .chat-composer-glass-shell {
946 --chat-composer-attachment-overlap: calc(1rem + 1px);
947 --chat-composer-glass-surface: var(--card);
948 --chat-composer-outline: rgb(0 0 0 / 8%);
949
950 isolation: isolate;
951
952 @variant dark {
953 --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white));
954 --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent);
955 --chat-composer-highlight: rgb(255 255 255 / 3%);
956 }
957 }
958
959 .chat-composer-glass-shell::before {
960 pointer-events: none;
961 position: absolute;
962 z-index: 0;
963 inset: 0;
964 border-radius: 22px;
965 background: color-mix(
966 in srgb,
967 var(--chat-composer-glass-surface) var(--glass-opacity),
968 transparent
969 );
970 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
971 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
972 content: "";
973 }
974
975 .chat-composer-glass-shell-with-context {
976 --chat-composer-context-extension: 2.25rem;
977 }
978
979 .chat-composer-glass-shell-with-context::before {
980 border-radius: 0;
981 /*
982 * One continuous glass layer: a 22px composer joined to a 16px strip. The
983 * strip is inset 1.375rem per side, so the step-in positions and their
984 * curve controls stay in rem to keep tracking it at non-default interface
985 * font sizes; the composer's 22px top radius and the strip's 16px bottom
986 * radius are px by design.
987 */
988 clip-path: shape(
989 from 0 22px,
990 curve to 22px 0 with 0 9.85px / 9.85px 0,
991 line to calc(100% - 22px) 0,
992 curve to 100% 22px with calc(100% - 9.85px) 0 / 100% 9.85px,
993 line to 100% calc(100% - var(--chat-composer-context-extension) - 1.375rem),
994 curve to calc(100% - 1.375rem) calc(100% - var(--chat-composer-context-extension)) with 100%
995 calc(100% - var(--chat-composer-context-extension) - 0.6156rem) / calc(100% - 0.6156rem)
996 calc(100% - var(--chat-composer-context-extension)),
997 line to calc(100% - 1.375rem) calc(100% - 16px),
998 curve to calc(100% - 1.375rem - 16px) 100% with calc(100% - 1.375rem) calc(100% - 7.16px) /
999 calc(100% - 1.375rem - 7.16px) 100%,
1000 line to calc(1.375rem + 16px) 100%,
1001 curve to 1.375rem calc(100% - 16px) with calc(1.375rem + 7.16px) 100% / 1.375rem
1002 calc(100% - 7.16px),
1003 line to 1.375rem calc(100% - var(--chat-composer-context-extension)),
1004 curve to 0 calc(100% - var(--chat-composer-context-extension) - 1.375rem) with 0.6156rem
1005 calc(100% - var(--chat-composer-context-extension)) / 0
1006 calc(100% - var(--chat-composer-context-extension) - 0.6156rem),
1007 line to 0 22px,
1008 close
1009 );
1010 }
1011
1012 @media (min-width: 40rem) {
1013 .chat-composer-glass-shell-with-context {
1014 /* The xs toolbar controls shrink by 4px at Tailwind's sm breakpoint. */
1015 --chat-composer-context-extension: 2rem;
1016 }
1017 }
1018
1019 .chat-composer-glass-host {
1020 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1021
1022 @variant dark {
1023 box-shadow: none;
1024
1025 &::after {
1026 box-shadow: inset 0 1px var(--chat-composer-highlight);
1027 }
1028 }
1029 }
1030
1031 .chat-composer-glass-host::after {
1032 pointer-events: none;
1033 position: absolute;
1034 z-index: 1;
1035 inset: 0;
1036 border: 1px solid var(--chat-composer-outline);
1037 border-radius: inherit;
1038 content: "";
1039 }
1040
1041 /* A top attachment cannot share the shell's single clipped backdrop: it
1042 needs the composer surface and outline to paint in front of its overlap.
1043 Split the glass into exact surfaces while a drawer or tab is present; the
1044 default composer keeps the cheaper continuous layer. */
1045 .chat-composer-glass-shell:is(
1046 .chat-composer-glass-shell-attached,
1047 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1048 )::before,
1049 .chat-composer-glass-shell:is(
1050 .chat-composer-glass-shell-attached,
1051 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1052 )
1053 .chat-composer-glass-host::after {
1054 display: none;
1055 }
1056
1057 .chat-composer-glass-shell:is(
1058 .chat-composer-glass-shell-attached,
1059 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1060 )
1061 .chat-composer-glass-host {
1062 box-shadow: none;
1063 }
1064
1065 .chat-composer-glass-shell:is(
1066 .chat-composer-glass-shell-attached,
1067 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1068 )
1069 .chat-composer-glass-host
1070 [data-chat-composer-main-surface="true"] {
1071 background: color-mix(
1072 in srgb,
1073 var(--chat-composer-glass-surface) var(--glass-opacity),
1074 transparent
1075 );
1076 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1077 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1078 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1079
1080 &::after {
1081 pointer-events: none;
1082 position: absolute;
1083 z-index: 20;
1084 inset: 0;
1085 border: 1px solid var(--chat-composer-outline);
1086 border-radius: inherit;
1087 content: "";
1088 }
1089
1090 @variant dark {
1091 box-shadow: none;
1092
1093 &::after {
1094 box-shadow: inset 0 1px var(--chat-composer-highlight);
1095 }
1096 }
1097
1098 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
1099 background: var(--chat-composer-glass-surface);
1100 }
1101 }
1102
1103 .chat-composer-glass-shell:is(
1104 .chat-composer-glass-shell-attached,
1105 :has(.chat-composer-top-drawer)
1106 )
1107 .chat-composer-glass-host
1108 [data-chat-composer-mobile-collapsed="true"] {
1109 min-height: var(--chat-composer-attachment-overlap);
1110 }
1111
1112 .chat-composer-glass-shell-with-context .chat-composer-glass-host::after,
1113 .chat-composer-glass-shell-with-context:is(
1114 .chat-composer-glass-shell-attached,
1115 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1116 )
1117 [data-chat-composer-main-surface="true"]::after {
1118 /* Keep the attachment seam open; the strip continues the outer outline below. */
1119 clip-path: polygon(
1120 0 0,
1121 100% 0,
1122 100% 100%,
1123 calc(100% - 22px) 100%,
1124 calc(100% - 22px) calc(100% - 2px),
1125 22px calc(100% - 2px),
1126 22px 100%,
1127 0 100%
1128 );
1129 }
1130
1131 .chat-composer-context-strip {
1132 position: relative;
1133 isolation: isolate;
1134
1135 @variant dark {
1136 &::before {
1137 border-color: rgb(255 255 255 / 7%);
1138 background:
1139 linear-gradient(
1140 to bottom,
1141 transparent 0 1rem,
1142 rgb(0 0 0 / 18%) 1rem,
1143 transparent calc(1rem + 10px)
1144 ),
1145 rgb(255 255 255 / 2%);
1146 box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
1147 }
1148 }
1149 }
1150
1151 .chat-composer-context-strip::before {
1152 position: absolute;
1153 z-index: -1;
1154 inset: 0;
1155 border: 1px solid var(--chat-composer-outline);
1156 border-radius: 0 0 16px 16px;
1157 /* Start the strip outline exactly where the composer's 22px curve reaches its tangent. */
1158 -webkit-mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem);
1159 mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem);
1160 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1161 content: "";
1162 }
1163
1164 .chat-composer-glass-shell:is(
1165 .chat-composer-glass-shell-attached,
1166 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1167 )
1168 .chat-composer-context-strip {
1169 &::before {
1170 background: color-mix(
1171 in srgb,
1172 var(--chat-composer-glass-surface) var(--glass-opacity),
1173 transparent
1174 );
1175 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1176 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1177 }
1178
1179 @variant dark {
1180 &::before {
1181 background:
1182 linear-gradient(
1183 to bottom,
1184 transparent 0 1rem,
1185 rgb(0 0 0 / 18%) 1rem,
1186 transparent calc(1rem + 10px)
1187 ),
1188 linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)),
1189 color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent);
1190 }
1191 }
1192 }
1193
1194 @supports not (clip-path: shape(from 0 0, line to 1px 1px)) {
1195 .chat-composer-glass-shell-with-context::before {
1196 inset-block-end: var(--chat-composer-context-extension);
1197 border-radius: 22px;
1198 clip-path: none;
1199 }
1200
1201 .chat-composer-context-strip::before {
1202 background: color-mix(
1203 in srgb,
1204 var(--chat-composer-glass-surface) var(--glass-opacity),
1205 transparent
1206 );
1207 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1208 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1209 }
1210
1211 .chat-composer-context-strip {
1212 @variant dark {
1213 &::before {
1214 background:
1215 linear-gradient(
1216 to bottom,
1217 transparent 0 1rem,
1218 rgb(0 0 0 / 18%) 1rem,
1219 transparent calc(1rem + 10px)
1220 ),
1221 linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)),
1222 color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent);
1223 }
1224 }
1225 }
1226 }
1227
1228 .settings-slider {762 .settings-slider {
1229 --settings-slider-progress: 0%;763 --settings-slider-progress: 0%;
1230 --settings-slider-fill-offset: 0.5rem;764 --settings-slider-fill-offset: 0.5rem;
...@@ -1339,12 +873,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -1339,12 +873,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
1339 all: revert;873 all: revert;
1340 }874 }
1341 }875 }
1342
1343 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
1344 .chat-composer-glass-shell::before {
1345 background: var(--chat-composer-glass-surface);
1346 }
1347 }
1348}876}
1349877
1350/* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering.878/* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering.
...@@ -1611,58 +1139,6 @@ html[data-theme-id]:not([data-theme-id=""]) {...@@ -1611,58 +1139,6 @@ html[data-theme-id]:not([data-theme-id=""]) {
1611 --terminal-selection-background: var(--app-theme-terminal-selection-background);1139 --terminal-selection-background: var(--app-theme-terminal-selection-background);
1612}1140}
16131141
1614/* T3 Chat's composer is a translucent lift over --chat-background. Route its
1615 measured flattened color through the raised-surface role instead of deriving
1616 another tint from the canvas, which made the dark composer too red. */
1617html[data-theme-id] .chat-composer-glass-shell {
1618 --chat-composer-glass-surface: var(--app-theme-surface-raised);
1619 --chat-composer-outline: var(--app-theme-toolbar-border);
1620
1621 @variant dark {
1622 --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background));
1623 --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent);
1624 }
1625}
1626
1627html[data-theme-id]
1628 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer) {
1629 --chat-composer-attached-surface: var(--app-theme-surface-raised);
1630}
1631
1632html[data-theme-id]
1633 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
1634 :not([data-variant]),
1635 [data-variant="default"]
1636 ) {
1637 --chat-composer-attached-outline: var(--chat-composer-outline, var(--app-theme-toolbar-border));
1638
1639 @variant dark {
1640 --chat-composer-attached-outline: var(
1641 --chat-composer-outline,
1642 color-mix(in srgb, var(--app-theme-input) 30%, var(--background))
1643 );
1644 }
1645}
1646
1647html[data-theme-id="t3-chat"] .chat-composer-glass-shell {
1648 /* T3 Chat's visible composer edge is a dark plum, not the stock translucent
1649 white outline. Its highlight is derived from --chat-input-gradient. */
1650 @variant dark {
1651 --chat-composer-outline: #241e28;
1652 --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent);
1653 }
1654}
1655
1656html[data-theme-id="t3-chat"]
1657 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
1658 :not([data-variant]),
1659 [data-variant="default"]
1660 ) {
1661 @variant dark {
1662 --chat-composer-attached-outline: #241e28;
1663 }
1664}
1665
1666/* Theme-token dependency probes are restored synchronously, before paint. Keep1142/* Theme-token dependency probes are restored synchronously, before paint. Keep
1667 transitions from observing the temporary sentinel color in between. */1143 transitions from observing the temporary sentinel color in between. */
1668html[data-theme-token-probe],1144html[data-theme-token-probe],
apps/web/src/session-logic.test.ts+31-72
...@@ -11,7 +11,6 @@ import { describe, expect, it } from "vite-plus/test";...@@ -11,7 +11,6 @@ import { describe, expect, it } from "vite-plus/test";
11import {11import {
12 deriveActiveWorkStartedAt,12 deriveActiveWorkStartedAt,
13 deriveActivePlanState,13 deriveActivePlanState,
14 deriveTurnPlans,
15 derivePendingApprovals,14 derivePendingApprovals,
16 derivePendingUserInputs,15 derivePendingUserInputs,
17 deriveTimelineEntries,16 deriveTimelineEntries,
...@@ -495,73 +494,6 @@ describe("deriveActivePlanState", () => {...@@ -495,73 +494,6 @@ describe("deriveActivePlanState", () => {
495 { durationMs: 3_000, step: "Check", status: "completed" },494 { durationMs: 3_000, step: "Check", status: "completed" },
496 ]);495 ]);
497 });496 });
498});
499
500describe("deriveTurnPlans", () => {
501 it("keeps one entry per turn, anchored at the first snapshot with the latest steps", () => {
502 const activities: OrchestrationThreadActivity[] = [
503 makeActivity({
504 id: "plan-1a",
505 createdAt: "2026-02-23T00:00:01.000Z",
506 kind: "turn.plan.updated",
507 summary: "Plan updated",
508 tone: "info",
509 turnId: "turn-1",
510 payload: {
511 plan: [{ step: "Inspect code", status: "inProgress" }],
512 },
513 }),
514 makeActivity({
515 id: "plan-1b",
516 createdAt: "2026-02-23T00:00:05.000Z",
517 kind: "turn.plan.updated",
518 summary: "Plan updated",
519 tone: "info",
520 turnId: "turn-1",
521 payload: {
522 plan: [{ step: "Inspect code", status: "completed" }],
523 },
524 }),
525 makeActivity({
526 id: "plan-2a",
527 createdAt: "2026-02-23T00:01:00.000Z",
528 kind: "turn.plan.updated",
529 summary: "Plan updated",
530 tone: "info",
531 turnId: "turn-2",
532 payload: {
533 plan: [{ step: "Ship it", status: "pending" }],
534 },
535 }),
536 ];
537
538 const turnPlans = deriveTurnPlans(activities);
539 expect(turnPlans).toHaveLength(2);
540 expect(turnPlans[0]).toMatchObject({
541 id: "turn-plan:turn-1",
542 createdAt: "2026-02-23T00:00:01.000Z",
543 turnId: "turn-1",
544 });
545 expect(turnPlans[0]?.plan.steps).toEqual([
546 { durationMs: 4_000, step: "Inspect code", status: "completed" },
547 ]);
548 expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]);
549 });
550
551 it("skips activities without parseable steps", () => {
552 const activities: OrchestrationThreadActivity[] = [
553 makeActivity({
554 id: "plan-bad",
555 createdAt: "2026-02-23T00:00:01.000Z",
556 kind: "turn.plan.updated",
557 summary: "Plan updated",
558 tone: "info",
559 turnId: "turn-1",
560 payload: { plan: [] },
561 }),
562 ];
563 expect(deriveTurnPlans(activities)).toEqual([]);
564 });
565497
566 it("tracks repeated step labels independently", () => {498 it("tracks repeated step labels independently", () => {
567 const activities: OrchestrationThreadActivity[] = [499 const activities: OrchestrationThreadActivity[] = [
...@@ -609,7 +541,7 @@ describe("deriveTurnPlans", () => {...@@ -609,7 +541,7 @@ describe("deriveTurnPlans", () => {
609 }),541 }),
610 ];542 ];
611543
612 expect(deriveTurnPlans(activities)[0]?.plan.steps).toEqual([544 expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([
613 { durationMs: 4_000, step: "Check", status: "completed" },545 { durationMs: 4_000, step: "Check", status: "completed" },
614 { durationMs: 6_000, step: "Check", status: "completed" },546 { durationMs: 6_000, step: "Check", status: "completed" },
615 ]);547 ]);
...@@ -661,13 +593,13 @@ describe("deriveTurnPlans", () => {...@@ -661,13 +593,13 @@ describe("deriveTurnPlans", () => {
661 }),593 }),
662 ];594 ];
663595
664 expect(deriveTurnPlans(activities)[0]?.plan.steps).toEqual([596 expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([
665 { durationMs: 5_000, step: "First", status: "completed" },597 { durationMs: 5_000, step: "First", status: "completed" },
666 { durationMs: 5_000, step: "Second", status: "completed" },598 { durationMs: 5_000, step: "Second", status: "completed" },
667 ]);599 ]);
668 });600 });
669601
670 it("drops a turn's chip when a later snapshot clears the plan", () => {602 it("clears the active plan when a later snapshot has no steps", () => {
671 const activities: OrchestrationThreadActivity[] = [603 const activities: OrchestrationThreadActivity[] = [
672 makeActivity({604 makeActivity({
673 id: "plan-set",605 id: "plan-set",
...@@ -688,7 +620,7 @@ describe("deriveTurnPlans", () => {...@@ -688,7 +620,7 @@ describe("deriveTurnPlans", () => {
688 payload: { plan: [] },620 payload: { plan: [] },
689 }),621 }),
690 ];622 ];
691 expect(deriveTurnPlans(activities)).toEqual([]);623 expect(deriveActivePlanState(activities, TurnId.make("turn-1"))).toBeNull();
692 });624 });
693});625});
694626
...@@ -915,6 +847,33 @@ describe("workEntryIndicatesToolFailure", () => {...@@ -915,6 +847,33 @@ describe("workEntryIndicatesToolFailure", () => {
915});847});
916848
917describe("deriveWorkLogEntries", () => {849describe("deriveWorkLogEntries", () => {
850 it("keeps the latest task progress without emitting plan-update log entries", () => {
851 const activities = [
852 makeActivity({ id: "before", kind: "tool.completed", summary: "Read files", sequence: 0 }),
853 makeActivity({
854 id: "plan-1",
855 kind: "turn.plan.updated",
856 summary: "Plan updated",
857 turnId: "turn-1",
858 sequence: 1,
859 payload: { plan: [{ step: "Verify the composer", status: "inProgress" }] },
860 }),
861 makeActivity({
862 id: "plan-2",
863 kind: "turn.plan.updated",
864 summary: "Plan updated",
865 turnId: "turn-1",
866 sequence: 2,
867 payload: { plan: [{ step: "Verify the composer", status: "completed" }] },
868 }),
869 makeActivity({ id: "after", kind: "tool.completed", summary: "Ran tests", sequence: 3 }),
870 ];
871 expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual(["before", "after"]);
872 expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toMatchObject([
873 { step: "Verify the composer", status: "completed" },
874 ]);
875 });
876
918 it("omits tool started entries and keeps completed entries", () => {877 it("omits tool started entries and keeps completed entries", () => {
919 const activities: OrchestrationThreadActivity[] = [878 const activities: OrchestrationThreadActivity[] = [
920 makeActivity({879 makeActivity({
apps/web/src/session-logic.ts+2-70
...@@ -169,12 +169,6 @@ export type TimelineEntry =...@@ -169,12 +169,6 @@ export type TimelineEntry =
169 createdAt: string;169 createdAt: string;
170 proposedPlan: ProposedPlan;170 proposedPlan: ProposedPlan;
171 }171 }
172 | {
173 id: string;
174 kind: "turn-plan";
175 createdAt: string;
176 turnPlan: TurnPlanEntry;
177 }
178 | {172 | {
179 id: string;173 id: string;
180 kind: "work";174 kind: "work";
...@@ -712,62 +706,6 @@ export function deriveActivePlanState(...@@ -712,62 +706,6 @@ export function deriveActivePlanState(
712 return addPlanStepDurations(plan, matchingActivities.slice(latestClearIndex + 1));706 return addPlanStepDurations(plan, matchingActivities.slice(latestClearIndex + 1));
713}707}
714708
715export interface TurnPlanEntry {
716 /** Stable per-turn row id (plans rewrite constantly; the row must not churn). */
717 id: string;
718 /** Anchor timestamp: the turn's FIRST plan activity, so the chip renders where planning began. */
719 createdAt: string;
720 turnId: TurnId | null;
721 plan: ActivePlanState;
722}
723
724/**
725 * One inline plan chip per turn that produced plan/todo steps: the latest
726 * snapshot for the turn, anchored at the first snapshot's timestamp. Turn-less
727 * plan activities collapse into a single chip keyed by thread order.
728 */
729export function deriveTurnPlans(
730 activities: ReadonlyArray<OrchestrationThreadActivity>,
731): TurnPlanEntry[] {
732 const ordered = [...activities].toSorted(compareActivitiesByOrder);
733 const byTurn = new Map<
734 string,
735 { activities: OrchestrationThreadActivity[]; entry: TurnPlanEntry }
736 >();
737 for (const activity of ordered) {
738 if (activity.kind !== "turn.plan.updated") {
739 continue;
740 }
741 const plan = planStateFromActivity(activity);
742 const key = activity.turnId ?? "no-turn";
743 if (!plan) {
744 // A later snapshot with no steps clears the turn's plan; keeping the
745 // stale entry would freeze the chip on a withdrawn plan.
746 byTurn.delete(key);
747 continue;
748 }
749 const existing = byTurn.get(key);
750 if (existing) {
751 existing.entry.plan = plan;
752 existing.activities.push(activity);
753 } else {
754 byTurn.set(key, {
755 activities: [activity],
756 entry: {
757 id: `turn-plan:${key}`,
758 createdAt: activity.createdAt,
759 turnId: activity.turnId,
760 plan,
761 },
762 });
763 }
764 }
765 return [...byTurn.values()].map(({ activities: planActivities, entry }) => ({
766 ...entry,
767 plan: addPlanStepDurations(entry.plan, planActivities),
768 }));
769}
770
771export function findLatestProposedPlan(709export function findLatestProposedPlan(
772 proposedPlans: ReadonlyArray<ProposedPlan>,710 proposedPlans: ReadonlyArray<ProposedPlan>,
773 latestTurnId: TurnId | string | null | undefined,711 latestTurnId: TurnId | string | null | undefined,
...@@ -880,6 +818,7 @@ export function deriveWorkLogEntries(...@@ -880,6 +818,7 @@ export function deriveWorkLogEntries(
880 if (activity.kind === "task.updated") continue;818 if (activity.kind === "task.updated") continue;
881 if (activity.kind === "tool.progress") continue;819 if (activity.kind === "tool.progress") continue;
882 if (activity.kind === "context-window.updated") continue;820 if (activity.kind === "context-window.updated") continue;
821 if (activity.kind === "turn.plan.updated") continue;
883 if (activity.summary === "Checkpoint captured") continue;822 if (activity.summary === "Checkpoint captured") continue;
884 if (isNoContentRuntimeWarning(activity)) continue;823 if (isNoContentRuntimeWarning(activity)) continue;
885 if (isPlanBoundaryToolActivity(activity)) continue;824 if (isPlanBoundaryToolActivity(activity)) continue;
...@@ -1847,7 +1786,6 @@ export function deriveTimelineEntries(...@@ -1847,7 +1786,6 @@ export function deriveTimelineEntries(
1847 messages: ReadonlyArray<ChatMessage>,1786 messages: ReadonlyArray<ChatMessage>,
1848 proposedPlans: ReadonlyArray<ProposedPlan>,1787 proposedPlans: ReadonlyArray<ProposedPlan>,
1849 workEntries: ReadonlyArray<WorkLogEntry>,1788 workEntries: ReadonlyArray<WorkLogEntry>,
1850 turnPlans: ReadonlyArray<TurnPlanEntry> = [],
1851): TimelineEntry[] {1789): TimelineEntry[] {
1852 const messageRows: TimelineEntry[] = messages.map((message) => ({1790 const messageRows: TimelineEntry[] = messages.map((message) => ({
1853 id: message.id,1791 id: message.id,
...@@ -1861,19 +1799,13 @@ export function deriveTimelineEntries(...@@ -1861,19 +1799,13 @@ export function deriveTimelineEntries(
1861 createdAt: proposedPlan.createdAt,1799 createdAt: proposedPlan.createdAt,
1862 proposedPlan,1800 proposedPlan,
1863 }));1801 }));
1864 const turnPlanRows: TimelineEntry[] = turnPlans.map((turnPlan) => ({
1865 id: turnPlan.id,
1866 kind: "turn-plan",
1867 createdAt: turnPlan.createdAt,
1868 turnPlan,
1869 }));
1870 const workRows: TimelineEntry[] = workEntries.map((entry) => ({1802 const workRows: TimelineEntry[] = workEntries.map((entry) => ({
1871 id: entry.id,1803 id: entry.id,
1872 kind: "work",1804 kind: "work",
1873 createdAt: entry.createdAt,1805 createdAt: entry.createdAt,
1874 entry,1806 entry,
1875 }));1807 }));
1876 return [...messageRows, ...proposedPlanRows, ...turnPlanRows, ...workRows].toSorted((a, b) =>1808 return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) =>
1877 a.createdAt.localeCompare(b.createdAt),1809 a.createdAt.localeCompare(b.createdAt),
1878 );1810 );
1879}1811}
apps/web/src/versionSkew.test.ts+36
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
34
4// Pinned so the direction cases below read as fixed versions instead of5// Pinned so the direction cases below read as fixed versions instead of
...@@ -9,7 +10,9 @@ vi.mock("./branding", () => branding);...@@ -9,7 +10,9 @@ vi.mock("./branding", () => branding);
9import { APP_VERSION } from "./branding";10import { APP_VERSION } from "./branding";
10import {11import {
11 buildVersionMismatchDismissalKey,12 buildVersionMismatchDismissalKey,
13 dismissServerUpdateFailure,
12 dismissVersionMismatch,14 dismissVersionMismatch,
15 isServerUpdateFailureDismissed,
13 isVersionMismatchDismissed,16 isVersionMismatchDismissed,
14 resolveServerConfigVersionMismatch,17 resolveServerConfigVersionMismatch,
15 resolveServerSelfUpdateCapability,18 resolveServerSelfUpdateCapability,
...@@ -25,6 +28,39 @@ describe("versionSkew", () => {...@@ -25,6 +28,39 @@ describe("versionSkew", () => {
25 branding.APP_VERSION = "0.0.34";28 branding.APP_VERSION = "0.0.34";
26 });29 });
2730
31 it("dismisses only the current failed attempt without clearing its retry state", () => {
32 const failure = {
33 status: "failed",
34 stage: "downloading",
35 fromVersion: "0.0.33",
36 targetVersion: "0.0.34",
37 message: "Download failed.",
38 } as const satisfies ServerUpdateState;
39 const retryFailure = { ...failure };
40 const otherEnvironmentFailure = { ...failure };
41
42 dismissServerUpdateFailure(failure);
43
44 expect(isServerUpdateFailureDismissed(failure)).toBe(true);
45 expect(failure.status).toBe("failed");
46 expect(failure.message).toBe("Download failed.");
47 expect(isServerUpdateFailureDismissed(retryFailure)).toBe(false);
48 expect(isServerUpdateFailureDismissed(otherEnvironmentFailure)).toBe(false);
49 });
50
51 it("does not dismiss an update that is still running", () => {
52 const running = {
53 status: "running",
54 stage: "resuming",
55 fromVersion: "0.0.33",
56 targetVersion: "0.0.34",
57 } as const satisfies ServerUpdateState;
58
59 dismissServerUpdateFailure(running);
60
61 expect(isServerUpdateFailureDismissed(running)).toBe(false);
62 });
63
28 it("does not warn when versions match", () => {64 it("does not warn when versions match", () => {
29 expect(resolveVersionMismatch(APP_VERSION)).toBeNull();65 expect(resolveVersionMismatch(APP_VERSION)).toBeNull();
30 });66 });
apps/web/src/versionSkew.ts+13
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts";1import type { EnvironmentId, ServerConfig, ServerSelfUpdateCapability } from "@t3tools/contracts";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
2import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";3import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
3import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
45
...@@ -13,6 +14,18 @@ export interface VersionMismatch {...@@ -13,6 +14,18 @@ export interface VersionMismatch {
1314
14export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1";15export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1";
1516
17// Runtime failures retain their identity until the next attempt. Dismiss only
18// that attempt, across chat remounts, without clearing the error in Settings.
19const dismissedServerUpdateFailures = new WeakSet<ServerUpdateState>();
20
21export function isServerUpdateFailureDismissed(state: ServerUpdateState): boolean {
22 return state.status === "failed" && dismissedServerUpdateFailures.has(state);
23}
24
25export function dismissServerUpdateFailure(state: ServerUpdateState): void {
26 if (state.status === "failed") dismissedServerUpdateFailures.add(state);
27}
28
16const VersionMismatchDismissalsSchema = Schema.Struct({29const VersionMismatchDismissalsSchema = Schema.Struct({
17 keys: Schema.Array(Schema.String),30 keys: Schema.Array(Schema.String),
18});31});