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

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


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

apps/web/src/components/BranchToolbar.tsx+3-3
...@@ -40,7 +40,6 @@ import {...@@ -40,7 +40,6 @@ 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";
4443
45interface BranchToolbarProps {44interface BranchToolbarProps {
46 environmentId: EnvironmentId;45 environmentId: EnvironmentId;
...@@ -467,9 +466,10 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -467,9 +466,10 @@ export const BranchToolbar = memo(function BranchToolbar({
467 if (!hasActiveThread || !activeProject) return null;466 if (!hasActiveThread || !activeProject) return null;
468467
469 return (468 return (
470 <ComposerSurface.ContextStrip469 <div
471 ref={setStripElement}470 ref={setStripElement}
472 data-compact={labelsOverflow ? "" : undefined}471 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 </ComposerSurface.ContextStrip>537 </div>
538 );538 );
539});539});
apps/web/src/components/ChatView.logic.test.ts+19
...@@ -32,6 +32,7 @@ import {...@@ -32,6 +32,7 @@ import {
32 resolveSendEnvMode,32 resolveSendEnvMode,
33 resolveDraftHeroState,33 resolveDraftHeroState,
34 scheduleEnvironmentReconnectWarning,34 scheduleEnvironmentReconnectWarning,
35 shoulderTabReserve,
35 startNewThreadForProject,36 startNewThreadForProject,
36 shouldDockDraftHeroForSubmission,37 shouldDockDraftHeroForSubmission,
37 shouldReleaseTimelineAnchorForToolActivity,38 shouldReleaseTimelineAnchorForToolActivity,
...@@ -45,6 +46,24 @@ const projectId = ProjectId.make("project-1");...@@ -45,6 +46,24 @@ const projectId = ProjectId.make("project-1");
45const threadId = ThreadId.make("thread-1");46const threadId = ThreadId.make("thread-1");
46const now = "2026-03-29T00:00:00.000Z";47const now = "2026-03-29T00:00:00.000Z";
4748
49describe("shoulderTabReserve", () => {
50 it("ignores the top drawer when measuring the shoulder tab band", () => {
51 const elementAt = (top: number) => ({ getBoundingClientRect: () => ({ top }) }) as HTMLElement;
52 const elements = new Map<string, HTMLElement>([
53 ['[data-chat-composer-form="true"]', elementAt(20)],
54 [".chat-composer-shoulder-tab", elementAt(100)],
55 ['[data-chat-composer-main-surface="true"]', elementAt(128)],
56 ]);
57 const overlay = {
58 querySelector: (selector: string) => elements.get(selector) ?? null,
59 } as HTMLElement;
60
61 expect(shoulderTabReserve(overlay)).toBe(28);
62 elements.set(".chat-composer-tasks-tab", elementAt(100));
63 expect(shoulderTabReserve(overlay)).toBe(0);
64 });
65});
66
48describe("draft hero submission transition", () => {67describe("draft hero submission transition", () => {
49 it("does not dock the composer before a background submission", () => {68 it("does not dock the composer before a background submission", () => {
50 expect(69 expect(
apps/web/src/components/ChatView.logic.ts+11
...@@ -39,6 +39,17 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;...@@ -39,6 +39,17 @@ export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000;
3939
40export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);40export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
4141
42export function shoulderTabReserve(overlay: HTMLElement): number {
43 if (overlay.querySelector(".chat-composer-tasks-tab")) return 0;
44 const tab = overlay.querySelector<HTMLElement>(".chat-composer-shoulder-tab");
45 const surface = overlay.querySelector<HTMLElement>('[data-chat-composer-main-surface="true"]');
46 if (!tab || !surface) return 0;
47 return Math.max(
48 0,
49 Math.round(surface.getBoundingClientRect().top - tab.getBoundingClientRect().top),
50 );
51}
52
42export function shouldDockDraftHeroForSubmission(input: {53export function shouldDockDraftHeroForSubmission(input: {
43 isDraftHeroState: boolean;54 isDraftHeroState: boolean;
44 activeThreadKey: string | null;55 activeThreadKey: string | null;
apps/web/src/components/ChatView.tsx+88-61
...@@ -183,7 +183,6 @@ import {...@@ -183,7 +183,6 @@ import {
183 CheckCircle2Icon,183 CheckCircle2Icon,
184 ChevronDownIcon,184 ChevronDownIcon,
185 GitBranchIcon,185 GitBranchIcon,
186 InfoIcon,
187 Minimize2Icon,186 Minimize2Icon,
188 PaperclipIcon,187 PaperclipIcon,
189 WifiOffIcon,188 WifiOffIcon,
...@@ -317,14 +316,14 @@ import {...@@ -317,14 +316,14 @@ import {
317 threadChangeRequestSnapshotsAtom,316 threadChangeRequestSnapshotsAtom,
318 useLinkedThreadPullRequest,317 useLinkedThreadPullRequest,
319} from "./ThreadStatusIndicators";318} from "./ThreadStatusIndicators";
320import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack";319import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack";
321import { ComposerSurface } from "./chat/ComposerSurface";
322import {320import {
323 hasAvailableClaudeCompactionProvider,321 hasAvailableClaudeCompactionProvider,
324 hasDismissedResumeCompaction,322 hasDismissedResumeCompaction,
325 shouldOfferResumeCompaction,323 shouldOfferResumeCompaction,
326} from "./chat/ContextWindowMeter.logic";324} from "./chat/ContextWindowMeter.logic";
327import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";325import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
326import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill";
328import {327import {
329 DRAFT_HERO_TRANSITION_ANIMATION_ID,328 DRAFT_HERO_TRANSITION_ANIMATION_ID,
330 DRAFT_HERO_TRANSITION_DURATION_MS,329 DRAFT_HERO_TRANSITION_DURATION_MS,
...@@ -351,6 +350,7 @@ import {...@@ -351,6 +350,7 @@ import {
351 shouldDockDraftHeroForSubmission,350 shouldDockDraftHeroForSubmission,
352 shouldReleaseTimelineAnchorForToolActivity,351 shouldReleaseTimelineAnchorForToolActivity,
353 shouldShowBranchMismatchBanner,352 shouldShowBranchMismatchBanner,
353 shoulderTabReserve,
354 shouldShowPlanFollowUpPrompt,354 shouldShowPlanFollowUpPrompt,
355 getStartedThreadModelChangeBlockReason,355 getStartedThreadModelChangeBlockReason,
356 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,356 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
...@@ -401,13 +401,10 @@ import {...@@ -401,13 +401,10 @@ import {
401 AlertDialogTitle,401 AlertDialogTitle,
402} from "./ui/alert-dialog";402} from "./ui/alert-dialog";
403import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";403import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
404import { ServerUpdateAction } from "./ServerUpdateAction";404import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction";
405import { ComposerServerUpdateStatus } from "./chat/ComposerServerUpdateStatus";
406import {405import {
407 buildVersionMismatchDismissalKey,406 buildVersionMismatchDismissalKey,
408 dismissServerUpdateFailure,
409 dismissVersionMismatch,407 dismissVersionMismatch,
410 isServerUpdateFailureDismissed,
411 isVersionMismatchDismissed,408 isVersionMismatchDismissed,
412 resolveServerConfigVersionMismatch,409 resolveServerConfigVersionMismatch,
413 resolveServerSelfUpdateCapability,410 resolveServerSelfUpdateCapability,
...@@ -2159,12 +2156,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2159,12 +2156,6 @@ function ChatViewContent(props: ChatViewProps) {
2159 const serverUpdateState = useAtomValue(2156 const serverUpdateState = useAtomValue(
2160 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),2157 serverEnvironment.updateStateAtom(serverUpdateEnvironmentId),
2161 );2158 );
2162 const [dismissedServerUpdateState, setDismissedServerUpdateState] = useState<
2163 typeof serverUpdateState | null
2164 >(null);
2165 const serverUpdateFailureDismissed =
2166 serverUpdateState === dismissedServerUpdateState ||
2167 isServerUpdateFailureDismissed(serverUpdateState);
2168 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {2159 const systemComposerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
2169 const items: ComposerBannerStackItem[] = [];2160 const items: ComposerBannerStackItem[] = [];
2170 const updateRunning = serverUpdateState.status === "running";2161 const updateRunning = serverUpdateState.status === "running";
...@@ -2192,8 +2183,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2192,8 +2183,8 @@ function ChatViewContent(props: ChatViewProps) {
2192 items.push({2183 items.push({
2193 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,2184 id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`,
2194 variant: "default",2185 variant: "default",
2195 // Prioritize live connection progress among the notices.2186 // Live connection status: calm styling, but it must front the stack.
2196 priority: "urgent",2187 urgent: true,
2197 icon: (2188 icon: (
2198 <span2189 <span
2199 className="size-1.5 animate-status-pulse rounded-full bg-foreground"2190 className="size-1.5 animate-status-pulse rounded-full bg-foreground"
...@@ -2240,24 +2231,29 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2240,24 +2231,29 @@ function ChatViewContent(props: ChatViewProps) {
2240 if (2231 if (
2241 serverUpdateEnvironmentId &&2232 serverUpdateEnvironmentId &&
2242 !reconnectingThroughVersionSkew &&2233 !reconnectingThroughVersionSkew &&
2243 (serverUpdateState.status === "idle"2234 (serverUpdateState.status !== "idle" ||
2244 ? showVersionMismatchBanner2235 (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey))
2245 : !serverUpdateFailureDismissed)
2246 ) {2236 ) {
2247 const updateInProgress = serverUpdateState.status === "running";2237 const updateInProgress = serverUpdateState.status === "running";
2248 const updateFailed = serverUpdateState.status === "failed";2238 const updateFailed = serverUpdateState.status === "failed";
2249 items.push({2239 items.push({
2250 id: `server-version:${serverUpdateEnvironmentId}`,2240 id: `server-version:${serverUpdateEnvironmentId}`,
2251 variant: updateFailed ? "error" : "default",2241 variant: updateFailed ? "error" : "default",
2252 // Prioritize update progress over passive notices, but keep activity attached.2242 // A running update is live progress the user is waiting on; only the
2253 priority: updateInProgress ? "urgent" : "notice",2243 // idle "update available" offer is calm enough to stack behind.
2254 icon: <InfoIcon aria-hidden />,2244 urgent: updateInProgress,
2245 // In-flight and failed states carry their own status dot inside
2246 // ServerUpdateProgress; only the idle offer needs an icon.
2247 icon:
2248 updateInProgress || updateFailed ? null : (
2249 <span
2250 className="size-1.5 rounded-full border border-muted-foreground/40"
2251 aria-hidden="true"
2252 />
2253 ),
2255 title:2254 title:
2256 updateInProgress || updateFailed ? (2255 updateInProgress || updateFailed ? (
2257 <ComposerServerUpdateStatus2256 `${updateFailed ? "Could not update" : "Updating"} ${versionMismatchServerLabel}`
2258 state={serverUpdateState}
2259 serverLabel={versionMismatchServerLabel}
2260 />
2261 ) : versionMismatch ? (2257 ) : versionMismatch ? (
2262 <Tooltip>2258 <Tooltip>
2263 <TooltipTrigger2259 <TooltipTrigger
...@@ -2276,9 +2272,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2276,9 +2272,11 @@ function ChatViewContent(props: ChatViewProps) {
2276 "Server update available"2272 "Server update available"
2277 ),2273 ),
2278 description:2274 description:
2279 !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed"2275 updateInProgress || updateFailed ? (
2280 ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)2276 <ServerUpdateProgress state={serverUpdateState} />
2281 : undefined,2277 ) : versionMismatchSelfUpdate === "desktop-managed" ? (
2278 serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel)
2279 ) : null,
2282 // The desktop-managed guidance is already the description; the action2280 // The desktop-managed guidance is already the description; the action
2283 // slot would only repeat it.2281 // slot would only repeat it.
2284 actions:2282 actions:
...@@ -2293,15 +2291,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2293,15 +2291,11 @@ function ChatViewContent(props: ChatViewProps) {
2293 label={updateFailed ? "Retry" : "Update"}2291 label={updateFailed ? "Retry" : "Update"}
2294 />2292 />
2295 ),2293 ),
2296 ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey)2294 ...(updateInProgress || updateFailed || !versionMismatchDismissKey
2297 ? {}2295 ? {}
2298 : {2296 : {
2299 dismissLabel: "Dismiss update notice",2297 dismissLabel: "Dismiss update notice",
2300 onDismiss: () => {2298 onDismiss: () => {
2301 if (updateFailed) {
2302 dismissServerUpdateFailure(serverUpdateState);
2303 setDismissedServerUpdateState(serverUpdateState);
2304 }
2305 dismissVersionMismatch(versionMismatchDismissKey);2299 dismissVersionMismatch(versionMismatchDismissKey);
2306 setDismissedVersionMismatchKey(versionMismatchDismissKey);2300 setDismissedVersionMismatchKey(versionMismatchDismissKey);
2307 },2301 },
...@@ -2316,7 +2310,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2316,7 +2310,6 @@ function ChatViewContent(props: ChatViewProps) {
2316 navigate,2310 navigate,
2317 setDismissedVersionMismatchKey,2311 setDismissedVersionMismatchKey,
2318 showVersionMismatchBanner,2312 showVersionMismatchBanner,
2319 serverUpdateFailureDismissed,
2320 serverUpdateState,2313 serverUpdateState,
2321 versionMismatch,2314 versionMismatch,
2322 versionMismatchDismissKey,2315 versionMismatchDismissKey,
...@@ -2404,6 +2397,21 @@ function ChatViewContent(props: ChatViewProps) {...@@ -2404,6 +2397,21 @@ function ChatViewContent(props: ChatViewProps) {
2404 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),2397 () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
2405 [activeLatestTurn?.turnId, threadActivities],2398 [activeLatestTurn?.turnId, threadActivities],
2406 );2399 );
2400 // Current step for the in-chat working row: only for the running turn's own
2401 // plan (deriveActivePlanState falls back to older turns' plans, which must
2402 // not label fresh work). Falls back to the first pending step so an
2403 // all-pending freshly written plan labels the row, matching the chip and
2404 // the server's planProgress.
2405 const workingStepLabel = useMemo(() => {
2406 if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) {
2407 return null;
2408 }
2409 return (
2410 activePlan.steps.find((step) => step.status === "inProgress")?.step ??
2411 activePlan.steps.find((step) => step.status === "pending")?.step ??
2412 null
2413 );
2414 }, [activeLatestTurn?.turnId, activePlan]);
2407 const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({2415 const showPlanFollowUpPrompt = shouldShowPlanFollowUpPrompt({
2408 pendingUserInputCount: pendingUserInputs.length,2416 pendingUserInputCount: pendingUserInputs.length,
2409 interactionMode,2417 interactionMode,
...@@ -4434,20 +4442,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4434,20 +4442,10 @@ function ChatViewContent(props: ChatViewProps) {
4434 // partition (same shell, same capability gate, same PR auto-settle input)4442 // partition (same shell, same capability gate, same PR auto-settle input)
4435 // so the banner and the sidebar row never disagree.4443 // so the banner and the sidebar row never disagree.
4436 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);4444 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
4437 const activeComposerTasksProgress = useMemo(() => {4445 const activeComposerTasksProgress =
4438 if (!activeLatestTurn || latestTurnSettled || activePlan?.turnId !== activeLatestTurn.turnId) {4446 activeLatestTurn !== null && !latestTurnSettled
4439 return null;4447 ? (activeThreadShell?.planProgress ?? null)
4440 }4448 : null;
4441 const currentStep =
4442 activePlan.steps.find((step) => step.status === "inProgress") ??
4443 activePlan.steps.find((step) => step.status === "pending");
4444 if (!currentStep) return null;
4445 return {
4446 step: currentStep.step,
4447 completedSteps: activePlan.steps.filter((step) => step.status === "completed").length,
4448 totalSteps: activePlan.steps.length,
4449 };
4450 }, [activeLatestTurn, activePlan, latestTurnSettled]);
4451 const activeComposerTaskSteps =4449 const activeComposerTaskSteps =
4452 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId4450 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
4453 ? activePlan.steps4451 ? activePlan.steps
...@@ -4462,8 +4460,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4462,8 +4460,9 @@ function ChatViewContent(props: ChatViewProps) {
4462 setComposerOverlayHeight((currentHeight) =>4460 setComposerOverlayHeight((currentHeight) =>
4463 currentHeight === nextHeight ? currentHeight : nextHeight,4461 currentHeight === nextHeight ? currentHeight : nextHeight,
4464 );4462 );
4463 const nextClearance = Math.max(0, nextHeight - shoulderTabReserve(composerOverlayElement));
4465 setScrollToEndClearance((currentClearance) =>4464 setScrollToEndClearance((currentClearance) =>
4466 currentClearance === nextHeight ? currentClearance : nextHeight,4465 currentClearance === nextClearance ? currentClearance : nextClearance,
4467 );4466 );
4468 };4467 };
44694468
...@@ -4472,8 +4471,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4472,8 +4471,11 @@ function ChatViewContent(props: ChatViewProps) {
44724471
4473 const resizeObserver = new ResizeObserver(updateHeight);4472 const resizeObserver = new ResizeObserver(updateHeight);
4474 resizeObserver.observe(composerOverlayElement);4473 resizeObserver.observe(composerOverlayElement);
4474 const tabObserver = new MutationObserver(updateHeight);
4475 tabObserver.observe(composerOverlayElement, { childList: true, subtree: true });
4475 return () => {4476 return () => {
4476 resizeObserver.disconnect();4477 resizeObserver.disconnect();
4478 tabObserver.disconnect();
4477 };4479 };
4478 }, [composerOverlayElement]);4480 }, [composerOverlayElement]);
44794481
...@@ -4801,7 +4803,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4801,7 +4803,6 @@ function ChatViewContent(props: ChatViewProps) {
4801 return {4803 return {
4802 id: `background-liveness:${activeThread.id}`,4804 id: `background-liveness:${activeThread.id}`,
4803 variant: "default",4805 variant: "default",
4804 priority: "activity",
4805 icon: (4806 icon: (
4806 <span4807 <span
4807 className={cn("size-1.5 rounded-full bg-foreground", working && "animate-status-pulse")}4808 className={cn("size-1.5 rounded-full bg-foreground", working && "animate-status-pulse")}
...@@ -4848,6 +4849,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4848,6 +4849,13 @@ function ChatViewContent(props: ChatViewProps) {
4848 onDismiss: acknowledgeActiveThreadWoke,4849 onDismiss: acknowledgeActiveThreadWoke,
4849 };4850 };
4850 }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);4851 }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]);
4852 // The stack renders items[0] front-most and tucks the rest behind hover, so
4853 // ordering is priority: urgent system banners (error/warning variants plus
4854 // calm-styled live states flagged `urgent`, like update progress), then
4855 // background liveness — its Stop button is the only stop affordance for
4856 // settled turns, so a passive "update available" notice must not cover it —
4857 // then calm system banners, the woke and branch-mismatch notices, and the
4858 // informational parked-thread banner last — it must never cover another.
4851 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {4859 const parkedThreadBannerItem = useMemo<ComposerBannerStackItem | null>(() => {
4852 if (!activeThreadSnoozed && !activeThreadSettled) {4860 if (!activeThreadSnoozed && !activeThreadSettled) {
4853 return null;4861 return null;
...@@ -4998,6 +5006,10 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4998,6 +5006,10 @@ function ChatViewContent(props: ChatViewProps) {
4998 void handleSwitchCheckoutToThread();5006 void handleSwitchCheckoutToThread();
4999 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);5007 }, [gitStatusQuery.data?.hasWorkingTreeChanges, handleSwitchCheckoutToThread]);
5000 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {5008 const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
5009 const isUrgentSystemItem = (item: ComposerBannerStackItem) =>
5010 item.urgent === true || item.variant === "error" || item.variant === "warning";
5011 const urgentSystemItems = systemComposerBannerItems.filter(isUrgentSystemItem);
5012 const calmSystemItems = systemComposerBannerItems.filter((item) => !isUrgentSystemItem(item));
5001 const backgroundLivenessItems =5013 const backgroundLivenessItems =
5002 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];5014 backgroundLivenessBannerItem === null ? [] : [backgroundLivenessBannerItem];
5003 const resumeCompactionItems =5015 const resumeCompactionItems =
...@@ -5006,16 +5018,18 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5006,16 +5018,18 @@ function ChatViewContent(props: ChatViewProps) {
5006 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];5018 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
5007 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {5019 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
5008 return [5020 return [
5009 ...systemComposerBannerItems,5021 ...urgentSystemItems,
5010 ...backgroundLivenessItems,5022 ...backgroundLivenessItems,
5023 ...calmSystemItems,
5011 ...resumeCompactionItems,5024 ...resumeCompactionItems,
5012 ...wokeThreadItems,5025 ...wokeThreadItems,
5013 ...parkedThreadItems,5026 ...parkedThreadItems,
5014 ];5027 ];
5015 }5028 }
5016 return [5029 return [
5017 ...systemComposerBannerItems,5030 ...urgentSystemItems,
5018 ...backgroundLivenessItems,5031 ...backgroundLivenessItems,
5032 ...calmSystemItems,
5019 ...resumeCompactionItems,5033 ...resumeCompactionItems,
5020 ...wokeThreadItems,5034 ...wokeThreadItems,
5021 {5035 {
...@@ -7012,6 +7026,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7012,6 +7026,8 @@ function ChatViewContent(props: ChatViewProps) {
7012 setDragActive: setIsWorkspaceFileDragActive,7026 setDragActive: setIsWorkspaceFileDragActive,
7013 addFiles: (files) => composerRef.current?.addDroppedFiles(files),7027 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
7014 });7028 });
7029 const externalComposerDrawerAttached =
7030 composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable);
70157031
7016 return (7032 return (
7017 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">7033 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
...@@ -7109,6 +7125,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7109,6 +7125,8 @@ function ChatViewContent(props: ChatViewProps) {
7109 onOpenAgents={addAgentsSurface}7125 onOpenAgents={addAgentsSurface}
7110 key={activeThread.id}7126 key={activeThread.id}
7111 isWorking={isWorking}7127 isWorking={isWorking}
7128 workingStepLabel={workingStepLabel}
7129 activeTurnStartedAt={activeWorkStartedAt}
7112 listRef={legendListRef}7130 listRef={legendListRef}
7113 timelineEntries={timelineEntries}7131 timelineEntries={timelineEntries}
7114 latestTurn={activeLatestTurn}7132 latestTurn={activeLatestTurn}
...@@ -7176,7 +7194,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7176,7 +7194,7 @@ function ChatViewContent(props: ChatViewProps) {
7176 {isDraftHeroState ? (7194 {isDraftHeroState ? (
7177 <div className="absolute inset-x-0 bottom-full z-0">7195 <div className="absolute inset-x-0 bottom-full z-0">
7178 <div7196 <div
7179 className="pb-8 group-has-data-[composer-shoulder-tab]/composer-stack:pb-4"7197 className="pb-8 group-has-[.chat-composer-shoulder-tab]/composer-stack:pb-4"
7180 style={7198 style={
7181 forceExpandedMobileComposer7199 forceExpandedMobileComposer
7182 ? {7200 ? {
...@@ -7190,7 +7208,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7190,7 +7208,13 @@ function ChatViewContent(props: ChatViewProps) {
7190 activeProjectTitle={activeProject?.title ?? null}7208 activeProjectTitle={activeProject?.title ?? null}
7191 />7209 />
7192 </div>7210 </div>
7211 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
7193 </div>7212 </div>
7213 ) : (
7214 <ComposerBannerStack className="relative z-0" items={composerBannerItems} />
7215 )}
7216 {threadSyncPhase && !activeEnvironmentUnavailable ? (
7217 <ThreadSyncStatusPill phase={threadSyncPhase} />
7194 ) : null}7218 ) : null}
7195 <div7219 <div
7196 className="relative"7220 className="relative"
...@@ -7200,8 +7224,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7200,8 +7224,14 @@ function ChatViewContent(props: ChatViewProps) {
7200 : undefined7224 : undefined
7201 }7225 }
7202 >7226 >
7203 <ComposerSurface.Shell contextStrip={showComposerContextStrip}>7227 <div
7204 <ComposerSurface.Host>7228 className={cn(
7229 "chat-composer-glass-shell relative mx-auto w-full max-w-3xl",
7230 externalComposerDrawerAttached && "chat-composer-glass-shell-attached",
7231 showComposerContextStrip && "chat-composer-glass-shell-with-context",
7232 )}
7233 >
7234 <div className="chat-composer-glass-host relative z-10 w-full rounded-[22px]">
7205 <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">7235 <div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">
7206 <ChatComposer7236 <ChatComposer
7207 composerRef={composerRef}7237 composerRef={composerRef}
...@@ -7231,7 +7261,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7231,7 +7261,7 @@ function ChatViewContent(props: ChatViewProps) {
7231 : null7261 : null
7232 }7262 }
7233 isPreparingWorktree={isPreparingWorktree}7263 isPreparingWorktree={isPreparingWorktree}
7234 bannerItems={composerBannerItems}7264 externalDrawerAttached={externalComposerDrawerAttached}
7235 environmentUnavailable={activeEnvironmentUnavailableState}7265 environmentUnavailable={activeEnvironmentUnavailableState}
7236 activePendingApproval={activePendingApproval}7266 activePendingApproval={activePendingApproval}
7237 pendingApprovals={pendingApprovals}7267 pendingApprovals={pendingApprovals}
...@@ -7246,9 +7276,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7246,9 +7276,6 @@ function ChatViewContent(props: ChatViewProps) {
7246 activeProposedPlan={activeProposedPlan}7276 activeProposedPlan={activeProposedPlan}
7247 activeTasksProgress={activeComposerTasksProgress}7277 activeTasksProgress={activeComposerTasksProgress}
7248 activeTaskSteps={activeComposerTaskSteps}7278 activeTaskSteps={activeComposerTaskSteps}
7249 isWorking={isWorking}
7250 activeWorkStartedAt={activeWorkStartedAt}
7251 threadSyncPhase={activeEnvironmentUnavailable ? null : threadSyncPhase}
7252 runtimeMode={runtimeMode}7279 runtimeMode={runtimeMode}
7253 interactionMode={interactionMode}7280 interactionMode={interactionMode}
7254 lockedProvider={lockedProvider}7281 lockedProvider={lockedProvider}
...@@ -7295,7 +7322,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7295,7 +7322,7 @@ function ChatViewContent(props: ChatViewProps) {
7295 onExpandImage={onExpandTimelineImage}7322 onExpandImage={onExpandTimelineImage}
7296 />7323 />
7297 </div>7324 </div>
7298 </ComposerSurface.Host>7325 </div>
7299 <div className="min-h-0">7326 <div className="min-h-0">
7300 <div7327 <div
7301 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}7328 data-terminal-open={terminalUiState.terminalOpen ? "true" : undefined}
...@@ -7333,7 +7360,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -7333,7 +7360,7 @@ function ChatViewContent(props: ChatViewProps) {
7333 )}7360 )}
7334 </div>7361 </div>
7335 </div>7362 </div>
7336 </ComposerSurface.Shell>7363 </div>
7337 <div7364 <div
7338 aria-hidden7365 aria-hidden
7339 className="h-[calc(env(safe-area-inset-bottom)+1rem)] sm:h-[calc(env(safe-area-inset-bottom)+1.25rem)]"7366 className="h-[calc(env(safe-area-inset-bottom)+1rem)] sm:h-[calc(env(safe-area-inset-bottom)+1.25rem)]"
apps/web/src/components/chat/ChatComposer.tsx+173-218
...@@ -10,6 +10,7 @@ import type {...@@ -10,6 +10,7 @@ import type {
10 ScopedThreadRef,10 ScopedThreadRef,
11 ServerProvider,11 ServerProvider,
12 ThreadId,12 ThreadId,
13 TurnId,
13} from "@t3tools/contracts";14} from "@t3tools/contracts";
14import {15import {
15 ProviderDriverKind,16 ProviderDriverKind,
...@@ -71,20 +72,10 @@ import { ComposerStashBadge } from "./ComposerStashBadge";...@@ -71,20 +72,10 @@ import { ComposerStashBadge } from "./ComposerStashBadge";
71import { ComposerStashMenu } from "./ComposerStashMenu";72import { ComposerStashMenu } from "./ComposerStashMenu";
72import {73import {
73 ComposerTasksBadge,74 ComposerTasksBadge,
74 ComposerTasksContent,
75 ComposerTasksDrawer,75 ComposerTasksDrawer,
76 type ComposerTaskStep,76 type ComposerTaskStep,
77 type ComposerTasksProgress,77 type ComposerTasksProgress,
78} from "./ComposerTasksBadge";78} from "./ComposerTasksBadge";
79import { ComposerActivityRow, type ComposerActivityStatus } from "./ComposerActivityStatus";
80import type { ThreadSyncPhase } from "../../threadSync";
81import { ComposerBanner } from "./ComposerBanner";
82import { ComposerSurface } from "./ComposerSurface";
83import {
84 ComposerBannerStack,
85 type ComposerBannerStackContent,
86 type ComposerBannerStackItem,
87} from "./ComposerBannerStack";
88import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";79import { compressImageForStash, prepareImageForAttachment } from "../../lib/imageCompression";
89import {80import {
90 fileAttachmentTooLargeMessage,81 fileAttachmentTooLargeMessage,
...@@ -604,7 +595,7 @@ export interface ChatComposerProps {...@@ -604,7 +595,7 @@ export interface ChatComposerProps {
604 isSendBusy: boolean;595 isSendBusy: boolean;
605 sendDisabledReason: string | null;596 sendDisabledReason: string | null;
606 isPreparingWorktree: boolean;597 isPreparingWorktree: boolean;
607 bannerItems: readonly ComposerBannerStackItem[];598 externalDrawerAttached: boolean;
608 environmentUnavailable: {599 environmentUnavailable: {
609 readonly label: string;600 readonly label: string;
610 readonly connection: EnvironmentConnectionPresentation;601 readonly connection: EnvironmentConnectionPresentation;
...@@ -632,9 +623,6 @@ export interface ChatComposerProps {...@@ -632,9 +623,6 @@ export interface ChatComposerProps {
632 activeProposedPlan: Thread["proposedPlans"][number] | null;623 activeProposedPlan: Thread["proposedPlans"][number] | null;
633 activeTasksProgress: ComposerTasksProgress | null;624 activeTasksProgress: ComposerTasksProgress | null;
634 activeTaskSteps: readonly ComposerTaskStep[] | null;625 activeTaskSteps: readonly ComposerTaskStep[] | null;
635 isWorking: boolean;
636 activeWorkStartedAt: string | null;
637 threadSyncPhase: ThreadSyncPhase | null;
638626
639 // Mode627 // Mode
640 runtimeMode: RuntimeMode;628 runtimeMode: RuntimeMode;
...@@ -735,8 +723,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -735,8 +723,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
735 respondingRequestIds,723 respondingRequestIds,
736 showPlanFollowUpPrompt,724 showPlanFollowUpPrompt,
737 activeProposedPlan,725 activeProposedPlan,
738 isWorking,726 activeTasksProgress,
739 activeWorkStartedAt,727 activeTaskSteps,
740 runtimeMode,728 runtimeMode,
741 interactionMode,729 interactionMode,
742 lockedProvider,730 lockedProvider,
...@@ -775,8 +763,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -775,8 +763,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
775 setThreadError,763 setThreadError,
776 onExpandImage,764 onExpandImage,
777 } = props;765 } = props;
778 const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;
779 const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;
780 // ------------------------------------------------------------------766 // ------------------------------------------------------------------
781 // Store subscriptions (prompt / images / terminal contexts)767 // Store subscriptions (prompt / images / terminal contexts)
782 // ------------------------------------------------------------------768 // ------------------------------------------------------------------
...@@ -1178,6 +1164,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1178,6 +1164,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1178 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);1164 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
1179 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);1165 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
1180 const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);1166 const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);
1167 const [dismissedTasksTurnId, setDismissedTasksTurnId] = useState<TurnId | null>(null);
1181 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({1168 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
1182 key: 0,1169 key: 0,
1183 active: false,1170 active: false,
...@@ -2587,9 +2574,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2587,9 +2574,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2587 const deleteStashEntry = useCallback(2574 const deleteStashEntry = useCallback(
2588 (entry: PromptStashEntry) => {2575 (entry: PromptStashEntry) => {
2589 const { entry: removed, durable } = takeStashEntry(entry.id);2576 const { entry: removed, durable } = takeStashEntry(entry.id);
2590 if (!stashQueue.some((candidate) => candidate.id !== entry.id)) {
2591 setIsStashMenuOpen(false);
2592 }
2593 if (durable && removed) {2577 if (durable && removed) {
2594 for (const file of removed.files ?? []) {2578 for (const file of removed.files ?? []) {
2595 releasePersistedAttachmentUpload({2579 releasePersistedAttachmentUpload({
...@@ -2609,7 +2593,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2609,7 +2593,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2609 });2593 });
2610 }2594 }
2611 },2595 },
2612 [stashQueue, takeStashEntry],2596 [takeStashEntry],
2613 );2597 );
26142598
2615 const stashCurrentPrompt = useCallback(async () => {2599 const stashCurrentPrompt = useCallback(async () => {
...@@ -2822,22 +2806,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2822,22 +2806,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2822 const toggleTasksDrawer = useCallback(() => {2806 const toggleTasksDrawer = useCallback(() => {
2823 setIsTasksDrawerOpen((open) => !open);2807 setIsTasksDrawerOpen((open) => !open);
2824 }, []);2808 }, []);
2825 const activityStatus = useMemo<ComposerActivityStatus | undefined>(2809 const activeTasksTurnId = activeThread?.latestTurn?.turnId ?? null;
2826 () =>2810 const tasksDismissedForActiveTurn =
2827 props.threadSyncPhase2811 activeTasksTurnId !== null && dismissedTasksTurnId === activeTasksTurnId;
2828 ? { kind: "sync", phase: props.threadSyncPhase }2812 const visibleTasksProgress = tasksDismissedForActiveTurn ? null : activeTasksProgress;
2829 : isWorking2813 const visibleTaskSteps = tasksDismissedForActiveTurn ? null : activeTaskSteps;
2830 ? { kind: "working", startedAt: activeWorkStartedAt }
2831 : undefined,
2832 [props.threadSyncPhase, isWorking, activeWorkStartedAt],
2833 );
2834 const hasBannerItems = props.bannerItems.length > 0;
2835 const hasBlockingComposerTopDrawer =2814 const hasBlockingComposerTopDrawer =
2836 activePendingApproval !== null || pendingUserInputs.length > 0;2815 activePendingApproval !== null || pendingUserInputs.length > 0;
2816 const dismissTasks = useCallback(() => {
2817 if (activeTasksTurnId !== null) {
2818 setDismissedTasksTurnId(activeTasksTurnId);
2819 }
2820 setIsTasksDrawerOpen(false);
2821 }, [activeTasksTurnId]);
2837 const showInlineStashBadge =2822 const showInlineStashBadge =
2838 stashQueue.length > 0 &&2823 stashQueue.length > 0 &&
2839 !isComposerApprovalState &&2824 !isComposerApprovalState &&
2840 (hasBannerItems || showComposerTopDrawer || isTasksDrawerOpen || isMobileViewport);2825 (props.externalDrawerAttached ||
2826 showComposerTopDrawer ||
2827 isTasksDrawerOpen ||
2828 isComposerCollapsedMobile);
2841 const inlineStashBadge = showInlineStashBadge ? (2829 const inlineStashBadge = showInlineStashBadge ? (
2842 <ComposerStashBadge2830 <ComposerStashBadge
2843 count={stashQueue.length}2831 count={stashQueue.length}
...@@ -2849,66 +2837,37 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2849,66 +2837,37 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2849 />2837 />
2850 ) : null;2838 ) : null;
2851 const showInlineTasksBadge =2839 const showInlineTasksBadge =
2852 activeTasksProgress !== null &&2840 visibleTasksProgress !== null &&
2853 activeTaskSteps !== null &&2841 visibleTaskSteps !== null &&
2854 !isTasksDrawerOpen &&2842 !isTasksDrawerOpen &&
2855 !hasBlockingComposerTopDrawer &&2843 !hasBlockingComposerTopDrawer &&
2856 (hasBannerItems || showComposerTopDrawer || isComposerCollapsedMobile);2844 (props.externalDrawerAttached || showComposerTopDrawer || isComposerCollapsedMobile);
2857 const inlineTasksBadge = showInlineTasksBadge ? (2845 const inlineTasksBadge = showInlineTasksBadge ? (
2858 <ComposerTasksBadge2846 <ComposerTasksBadge
2859 expanded={false}2847 expanded={false}
2848 onDismiss={dismissTasks}
2860 onToggle={toggleTasksDrawer}2849 onToggle={toggleTasksDrawer}
2861 placement="inline"2850 placement="inline"
2862 progress={activeTasksProgress}2851 progress={visibleTasksProgress}
2863 steps={activeTaskSteps}2852 steps={visibleTaskSteps}
2864 activityStatus={activityStatus}
2865 />2853 />
2866 ) : null;2854 ) : null;
2867 const showShoulderTabs =2855 const showShoulderTabs =
2868 !hasBannerItems && !showComposerTopDrawer && !isTasksDrawerOpen && !isComposerCollapsedMobile;2856 !props.externalDrawerAttached &&
2857 !showComposerTopDrawer &&
2858 !isTasksDrawerOpen &&
2859 !isComposerCollapsedMobile;
2869 const hasShoulderTab =2860 const hasShoulderTab =
2870 showShoulderTabs &&2861 showShoulderTabs &&
2871 (activityStatus !== undefined ||2862 (stashQueue.length > 0 ||
2872 (stashQueue.length > 0 && !showInlineStashBadge) ||2863 (visibleTasksProgress !== null &&
2873 (activeTasksProgress !== null &&2864 visibleTaskSteps !== null &&
2874 activeTaskSteps !== null &&2865 visibleTasksProgress.totalSteps > 0));
2875 activeTasksProgress.totalSteps > 0));
2876 const tasksHostActivityStatus =
2877 activeTasksProgress !== null &&
2878 activeTasksProgress.totalSteps > 0 &&
2879 activeTaskSteps !== null &&
2880 !hasBlockingComposerTopDrawer &&
2881 (showShoulderTabs || showInlineTasksBadge || isTasksDrawerOpen);
2882 const standaloneActivityStatus = !tasksHostActivityStatus ? activityStatus : undefined;
2883 const activityStackContent = hasBannerItems ? (
2884 !hasBlockingComposerTopDrawer && activeTasksProgress && activeTaskSteps ? (
2885 <ComposerTasksContent
2886 expanded={isTasksDrawerOpen}
2887 onToggle={toggleTasksDrawer}
2888 progress={activeTasksProgress}
2889 steps={activeTaskSteps}
2890 activityStatus={activityStatus}
2891 />
2892 ) : activityStatus ? (
2893 <ComposerActivityRow status={activityStatus} />
2894 ) : null
2895 ) : null;
2896 const activityStackItem: ComposerBannerStackContent | null = activityStackContent
2897 ? {
2898 id: "composer-activity",
2899 variant: "default",
2900 priority: "activity",
2901 content: activityStackContent,
2902 }
2903 : null;
2904 const bannerStackItems = activityStackItem
2905 ? [...props.bannerItems, activityStackItem]
2906 : props.bannerItems;
2907 useEffect(() => {2866 useEffect(() => {
2908 if (activeTasksProgress === null || activeTaskSteps === null) {2867 if (visibleTasksProgress === null || visibleTaskSteps === null) {
2909 setIsTasksDrawerOpen(false);2868 setIsTasksDrawerOpen(false);
2910 }2869 }
2911 }, [activeTaskSteps, activeTasksProgress]);2870 }, [visibleTaskSteps, visibleTasksProgress]);
29122871
2913 useEffect(() => {2872 useEffect(() => {
2914 if (hasBlockingComposerTopDrawer) {2873 if (hasBlockingComposerTopDrawer) {
...@@ -3415,49 +3374,62 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3415,49 +3374,62 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3415 onDragOverCapture={composerMentionDragHandlers.onDragOver}3374 onDragOverCapture={composerMentionDragHandlers.onDragOver}
3416 onDragLeaveCapture={onComposerMentionDragLeaveCapture}3375 onDragLeaveCapture={onComposerMentionDragLeaveCapture}
3417 onDropCapture={composerMentionDragHandlers.onDrop}3376 onDropCapture={composerMentionDragHandlers.onDrop}
3418 className="mx-auto w-full min-w-0 max-w-3xl"3377 className={cn("mx-auto w-full min-w-0 max-w-3xl", hasShoulderTab && "pt-7")}
3419 data-chat-composer-form="true"3378 data-chat-composer-form="true"
3420 >3379 >
3421 <ComposerBannerStack key={activeThreadId} className="relative z-0" items={bannerStackItems} />
3422 {!activityStackItem &&
3423 (inlineTasksBadge || (standaloneActivityStatus && !showShoulderTabs)) ? (
3424 <ComposerBanner.Attachment>
3425 <ComposerBanner.Root data-chat-composer-activity-strip="true">
3426 {inlineTasksBadge ??
3427 (standaloneActivityStatus ? (
3428 <ComposerActivityRow status={standaloneActivityStatus} />
3429 ) : null)}
3430 </ComposerBanner.Root>
3431 </ComposerBanner.Attachment>
3432 ) : null}
3433 {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (3380 {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
3434 <ComposerBanner.Attachment>3381 <div
3435 <ComposerBanner.Root3382 className="chat-composer-top-drawer"
3436 data-chat-composer-top-drawer="true"3383 data-chat-composer-top-drawer="true"
3437 variant={activePendingApproval ? "warning" : "info"}3384 data-variant={activePendingApproval ? "warning" : "info"}
3438 >3385 >
3439 {activePendingApproval ? (3386 {!isComposerCollapsedMobile && activePendingApproval ? (
3440 <ComposerBanner.Row3387 <div className="flex min-w-0 flex-wrap items-center gap-1 px-3 py-1.5 sm:px-4">
3441 layout="wrap-actions"3388 <ComposerPendingApprovalPanel
3442 data-chat-composer-collapsed-controls="true"3389 approval={activePendingApproval}
3443 >3390 pendingCount={pendingApprovals.length}
3444 <ComposerBanner.Icon />3391 />
3445 <ComposerBanner.Content>3392 <div className="flex min-w-0 flex-wrap items-center gap-0.5">
3446 <ComposerPendingApprovalPanel3393 <ComposerPendingApprovalActions
3447 approval={activePendingApproval}3394 requestId={activePendingApproval.requestId}
3448 pendingCount={pendingApprovals.length}3395 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3449 />3396 options={activePendingApproval.options}
3450 </ComposerBanner.Content>3397 onRespondToApproval={onRespondToApproval}
3451 <ComposerBanner.Actions>3398 />
3452 <ComposerPendingApprovalActions3399 </div>
3453 requestId={activePendingApproval.requestId}3400 </div>
3454 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}3401 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3455 options={activePendingApproval.options}3402 <ComposerPendingUserInputPanel
3456 onRespondToApproval={onRespondToApproval}3403 pendingUserInputs={pendingUserInputs}
3457 />3404 respondingRequestIds={respondingRequestIds}
3458 </ComposerBanner.Actions>3405 answers={activePendingDraftAnswers}
3459 </ComposerBanner.Row>3406 questionIndex={activePendingQuestionIndex}
3460 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (3407 onToggleOption={onSelectActivePendingUserInputOption}
3408 onAdvance={onAdvanceActivePendingUserInput}
3409 />
3410 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (
3411 <ComposerPlanFollowUpBanner
3412 key={activeProposedPlan.id}
3413 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
3414 />
3415 ) : isComposerCollapsedMobile && activePendingApproval ? (
3416 <div data-chat-composer-collapsed-controls="true">
3417 <ComposerPendingApprovalPanel
3418 approval={activePendingApproval}
3419 pendingCount={pendingApprovals.length}
3420 className="px-3 pt-2 sm:px-4"
3421 />
3422 <div className="flex flex-wrap items-center justify-end gap-1 px-3 pt-2 pb-3 sm:px-4">
3423 <ComposerPendingApprovalActions
3424 requestId={activePendingApproval.requestId}
3425 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3426 options={activePendingApproval.options}
3427 onRespondToApproval={onRespondToApproval}
3428 />
3429 </div>
3430 </div>
3431 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
3432 <div data-chat-composer-collapsed-controls="true">
3461 <ComposerPendingUserInputPanel3433 <ComposerPendingUserInputPanel
3462 pendingUserInputs={pendingUserInputs}3434 pendingUserInputs={pendingUserInputs}
3463 respondingRequestIds={respondingRequestIds}3435 respondingRequestIds={respondingRequestIds}
...@@ -3466,116 +3438,96 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3466,116 +3438,96 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3466 onToggleOption={onSelectActivePendingUserInputOption}3438 onToggleOption={onSelectActivePendingUserInputOption}
3467 onAdvance={onAdvanceActivePendingUserInput}3439 onAdvance={onAdvanceActivePendingUserInput}
3468 />3440 />
3469 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (3441 <div className="px-3 pb-3 sm:px-4">
3470 <ComposerPlanFollowUpBanner3442 <div
3471 key={activeProposedPlan.id}3443 data-chat-composer-mobile-pending-compact="true"
3472 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}3444 className={cn(
3473 />3445 "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80",
3474 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (3446 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",
3475 <div data-chat-composer-collapsed-controls="true">3447 )}
3476 <ComposerPendingUserInputPanel3448 >
3477 pendingUserInputs={pendingUserInputs}3449 <button
3478 respondingRequestIds={respondingRequestIds}3450 type="button"
3479 answers={activePendingDraftAnswers}
3480 questionIndex={activePendingQuestionIndex}
3481 onToggleOption={onSelectActivePendingUserInputOption}
3482 onAdvance={onAdvanceActivePendingUserInput}
3483 />
3484 <ComposerBanner.Body>
3485 <div
3486 data-chat-composer-mobile-pending-compact="true"
3487 className={cn(3451 className={cn(
3488 "flex min-w-0 items-center gap-2 rounded-lg border border-border/55 bg-background/55 p-1.5 pl-3 transition-colors hover:bg-background/80",3452 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",
3489 !activePendingProgress?.activeQuestion?.multiSelect && "p-0",3453 activePendingProgress?.customAnswer ? "text-foreground" : "text-placeholder",
3454 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",
3490 )}3455 )}
3456 onPointerDown={(event) => event.preventDefault()}
3457 onClick={expandMobileComposer}
3458 aria-label="Write custom answer"
3491 >3459 >
3492 <button3460 {activePendingProgress?.customAnswer || "Write custom answer"}
3493 type="button"3461 </button>
3494 className={cn(3462 {inlineTasksBadge}
3495 "min-w-0 flex-1 truncate bg-transparent py-1.5 text-left text-sm",3463 {inlineStashBadge}
3496 activePendingProgress?.customAnswer3464 {activePendingProgress?.activeQuestion?.multiSelect ? (
3497 ? "text-foreground"3465 <ComposerPrimaryActions
3498 : "text-placeholder",3466 compact
3499 !activePendingProgress?.activeQuestion?.multiSelect && "px-3 py-2",3467 pendingAction={pendingPrimaryAction}
3500 )}3468 isRunning={false}
3501 onPointerDown={(event) => event.preventDefault()}3469 showPlanFollowUpPrompt={false}
3502 onClick={expandMobileComposer}3470 promptHasText={false}
3503 aria-label="Write custom answer"3471 isSendBusy={isSendBusy}
3504 >3472 sendDisabledReason={sendDisabledReason}
3505 {activePendingProgress?.customAnswer || "Write custom answer"}3473 isConnecting={isConnecting}
3506 </button>3474 isEnvironmentUnavailable={
3507 {inlineStashBadge}3475 environmentUnavailable !== null ||
3508 {activePendingProgress?.activeQuestion?.multiSelect ? (3476 noProviderAvailable ||
3509 <ComposerPrimaryActions3477 projectSelectionRequired
3510 compact3478 }
3511 pendingAction={pendingPrimaryAction}3479 isPreparingWorktree={false}
3512 isRunning={false}3480 hasSendableContent={false}
3513 showPlanFollowUpPrompt={false}3481 preserveComposerFocusOnPointerDown
3514 promptHasText={false}3482 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3515 isSendBusy={isSendBusy}3483 onInterrupt={handleInterruptPrimaryAction}
3516 sendDisabledReason={sendDisabledReason}3484 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3517 isConnecting={isConnecting}3485 />
3518 isEnvironmentUnavailable={3486 ) : null}
3519 environmentUnavailable !== null ||3487 </div>
3520 noProviderAvailable ||
3521 projectSelectionRequired
3522 }
3523 isPreparingWorktree={false}
3524 hasSendableContent={false}
3525 preserveComposerFocusOnPointerDown
3526 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3527 onInterrupt={handleInterruptPrimaryAction}
3528 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3529 />
3530 ) : null}
3531 </div>
3532 </ComposerBanner.Body>
3533 </div>3488 </div>
3534 ) : null}3489 </div>
3535 </ComposerBanner.Root>3490 ) : null}
3536 </ComposerBanner.Attachment>3491 </div>
3537 ) : null}3492 ) : null}
3538 {!activityStackItem &&3493 {isTasksDrawerOpen &&
3539 isTasksDrawerOpen &&
3540 !hasBlockingComposerTopDrawer &&3494 !hasBlockingComposerTopDrawer &&
3541 activeTasksProgress &&3495 visibleTasksProgress &&
3542 activeTaskSteps ? (3496 visibleTaskSteps ? (
3543 <ComposerTasksDrawer3497 <ComposerTasksDrawer
3498 onDismiss={dismissTasks}
3544 onCollapse={toggleTasksDrawer}3499 onCollapse={toggleTasksDrawer}
3545 progress={activeTasksProgress}3500 progress={visibleTasksProgress}
3546 steps={activeTaskSteps}3501 steps={visibleTaskSteps}
3547 activityStatus={activityStatus}
3548 />3502 />
3549 ) : null}3503 ) : null}
3550 {hasShoulderTab ? (
3551 <ComposerBanner.Dock>
3552 {standaloneActivityStatus ? (
3553 <ComposerBanner.Root width="content" data-composer-shoulder-tab>
3554 <ComposerActivityRow status={standaloneActivityStatus} />
3555 </ComposerBanner.Root>
3556 ) : null}
3557 {activeTasksProgress && activeTaskSteps ? (
3558 <ComposerTasksBadge
3559 expanded={false}
3560 onToggle={toggleTasksDrawer}
3561 progress={activeTasksProgress}
3562 steps={activeTaskSteps}
3563 activityStatus={activityStatus}
3564 />
3565 ) : null}
3566 {!showInlineStashBadge ? (
3567 <ComposerStashBadge
3568 count={stashQueue.length}
3569 menuOpen={isStashMenuOpen}
3570 pulseKey={stashPulse.key}
3571 pulsing={stashPulse.active}
3572 onToggleMenu={toggleStashMenu}
3573 />
3574 ) : null}
3575 </ComposerBanner.Dock>
3576 ) : null}
3577 <div className="relative">3504 <div className="relative">
3578 <ComposerSurface.Main className={composerProviderState.composerFrameClassName}>3505 {showShoulderTabs && visibleTasksProgress && visibleTaskSteps ? (
3506 <ComposerTasksBadge
3507 expanded={false}
3508 hasTrailingShoulder={stashQueue.length > 0}
3509 onDismiss={dismissTasks}
3510 onToggle={toggleTasksDrawer}
3511 progress={visibleTasksProgress}
3512 steps={visibleTaskSteps}
3513 />
3514 ) : null}
3515 {showShoulderTabs ? (
3516 <ComposerStashBadge
3517 count={stashQueue.length}
3518 menuOpen={isStashMenuOpen}
3519 pulseKey={stashPulse.key}
3520 pulsing={stashPulse.active}
3521 onToggleMenu={toggleStashMenu}
3522 />
3523 ) : null}
3524 <div
3525 data-chat-composer-main-surface="true"
3526 className={cn(
3527 "group relative z-10 rounded-[22px] p-px transition-colors duration-200",
3528 composerProviderState.composerFrameClassName,
3529 )}
3530 >
3579 <div3531 <div
3580 ref={composerSurfaceRef}3532 ref={composerSurfaceRef}
3581 data-chat-composer-surface="true"3533 data-chat-composer-surface="true"
...@@ -3607,6 +3559,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3607,6 +3559,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3607 : prompt.trim() ||3559 : prompt.trim() ||
3608 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}3560 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
3609 </button>3561 </button>
3562 {inlineTasksBadge}
3610 {inlineStashBadge}3563 {inlineStashBadge}
3611 <button3564 <button
3612 type="button"3565 type="button"
...@@ -3967,6 +3920,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3967,6 +3920,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3967 data-chat-composer-mobile-pending-actions="true"3920 data-chat-composer-mobile-pending-actions="true"
3968 className="absolute bottom-0 right-0 flex items-center justify-end gap-1"3921 className="absolute bottom-0 right-0 flex items-center justify-end gap-1"
3969 >3922 >
3923 {inlineTasksBadge}
3970 {inlineStashBadge}3924 {inlineStashBadge}
3971 <ComposerPrimaryActions3925 <ComposerPrimaryActions
3972 compact3926 compact
...@@ -4124,6 +4078,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4124,6 +4078,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4124 </Tooltip>4078 </Tooltip>
4125 </>4079 </>
4126 ) : null}4080 ) : null}
4081 {showMobilePendingAnswerActions ? null : inlineTasksBadge}
4127 {showMobilePendingAnswerActions ? null : inlineStashBadge}4082 {showMobilePendingAnswerActions ? null : inlineStashBadge}
4128 <ComposerFooterPrimaryActions4083 <ComposerFooterPrimaryActions
4129 compact={isComposerPrimaryActionsCompact}4084 compact={isComposerPrimaryActionsCompact}
...@@ -4162,7 +4117,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -4162,7 +4117,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
4162 </div>4117 </div>
4163 )}4118 )}
4164 </div>4119 </div>
4165 </ComposerSurface.Main>4120 </div>
4166 </div>4121 </div>
4167 </form>4122 </form>
4168 );4123 );
apps/web/src/components/chat/ComposerActivityStatus.tsx deleted-106
...@@ -1,106 +0,0 @@
1import { LoaderCircleIcon } from "lucide-react";
2import { useEffect, useRef } from "react";
3import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
4import { ComposerBanner } from "./ComposerBanner";
5
6export type ComposerActivityStatus =
7 | { readonly kind: "working"; readonly startedAt: string | null }
8 | { readonly kind: "sync"; readonly phase: ThreadSyncPhase };
9
10export function ComposerActivityIcon({ status }: { readonly status: ComposerActivityStatus }) {
11 if (status.kind !== "sync") return null;
12 return (
13 <ComposerBanner.Icon>
14 <LoaderCircleIcon className="motion-safe:animate-spin" />
15 </ComposerBanner.Icon>
16 );
17}
18
19export function ComposerActivityRow({ status }: { readonly status: ComposerActivityStatus }) {
20 return (
21 <ComposerBanner.Row>
22 <ComposerActivityIcon status={status} />
23 <ComposerBanner.Content>
24 <ComposerActivityLabel status={status} />
25 </ComposerBanner.Content>
26 </ComposerBanner.Row>
27 );
28}
29
30export function ComposerActivityLabel({ status }: { readonly status: ComposerActivityStatus }) {
31 if (status.kind === "sync") {
32 return (
33 <span
34 className="shrink-0 whitespace-nowrap text-muted-foreground"
35 data-composer-sync-status={status.phase}
36 role="status"
37 >
38 {threadSyncLabel(status.phase)}
39 </span>
40 );
41 }
42 return (
43 <span
44 className="shrink-0 whitespace-nowrap text-muted-foreground"
45 data-composer-working-status="true"
46 >
47 {status.startedAt ? (
48 <>
49 Working for <WorkingTimer createdAt={status.startedAt} />
50 </>
51 ) : (
52 "Working…"
53 )}
54 </span>
55 );
56}
57
58/** Updates only the elapsed text, without committing the composer or timeline each second. */
59function WorkingTimer({ createdAt }: { createdAt: string }) {
60 const textRef = useRef<HTMLSpanElement>(null);
61 const initialText = formatWorkingTimerNow(createdAt);
62
63 useEffect(() => {
64 const updateText = () => {
65 if (textRef.current) {
66 textRef.current.textContent = formatWorkingTimerNow(createdAt);
67 }
68 };
69 updateText();
70 const id = setInterval(updateText, 1000);
71 return () => clearInterval(id);
72 }, [createdAt]);
73
74 return (
75 <span ref={textRef} className="tabular-nums">
76 {initialText}
77 </span>
78 );
79}
80
81function formatWorkingTimer(startIso: string, endIso: string): string | null {
82 const startedAtMs = Date.parse(startIso);
83 const endedAtMs = Date.parse(endIso);
84 if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) {
85 return null;
86 }
87
88 const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000));
89 if (elapsedSeconds < 60) {
90 return `${elapsedSeconds}s`;
91 }
92
93 const hours = Math.floor(elapsedSeconds / 3600);
94 const minutes = Math.floor((elapsedSeconds % 3600) / 60);
95 const seconds = elapsedSeconds % 60;
96
97 if (hours > 0) {
98 return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
99 }
100
101 return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
102}
103
104function formatWorkingTimerNow(startIso: string): string {
105 return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
106}
apps/web/src/components/chat/ComposerBanner.tsx deleted-330
...@@ -1,330 +0,0 @@
1import { mergeProps } from "@base-ui/react/merge-props";
2import { useRender } from "@base-ui/react/use-render";
3import { ChevronDownIcon, XIcon } from "lucide-react";
4import type { ComponentProps } from "react";
5
6import { cn } from "~/lib/utils";
7import { Button, buttonVariants } from "../ui/button";
8import { ScrollArea } from "../ui/scroll-area";
9
10export type ComposerBannerVariant = "default" | "error" | "info" | "success" | "warning";
11
12const surfaceColors = cn(
13 "[--chat-composer-attached-surface:var(--chat-composer-glass-surface,var(--card))]",
14 "dark:[--chat-composer-attached-surface:var(--chat-composer-glass-surface,color-mix(in_srgb,var(--background)_96%,var(--color-white)))]",
15 "[html[data-theme-id]_&]:[--chat-composer-attached-surface:var(--app-theme-surface-raised)]",
16);
17
18const neutralOutline = cn(
19 "[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--contrast-foreground)_8%,transparent))]",
20 "dark:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--color-white)_5%,transparent))]",
21 "[html[data-theme-id]_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,var(--app-theme-toolbar-border))]",
22 "dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-attached-outline:var(--chat-composer-outline,color-mix(in_srgb,var(--app-theme-input)_30%,var(--background)))]",
23 "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-attached-outline:#241e28]",
24);
25
26const variantColors: Record<ComposerBannerVariant, string> = {
27 default: neutralOutline,
28 error:
29 "[--chat-composer-attached-outline:color-mix(in_srgb,var(--error)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--error)_8%,transparent)]",
30 info: "[--chat-composer-attached-outline:color-mix(in_srgb,var(--info)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--info)_4%,transparent)]",
31 success:
32 "[--chat-composer-attached-outline:color-mix(in_srgb,var(--success)_32%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--success)_4%,transparent)]",
33 warning:
34 "[--chat-composer-attached-outline:color-mix(in_srgb,var(--warning)_28%,transparent)] [--chat-composer-attached-tint:color-mix(in_srgb,var(--warning)_8%,transparent)]",
35};
36
37/** Shared glass and attachment seam, also used by the command menu without banner row padding. */
38function Surface({
39 placement = "attached",
40 variant = "default",
41 className,
42 ...props
43}: ComponentProps<"div"> & {
44 placement?: "attached" | "floating";
45 variant?: ComposerBannerVariant;
46}) {
47 return (
48 <div
49 data-composer-banner-surface={placement}
50 data-variant={variant}
51 className={cn(
52 surfaceColors,
53 "relative isolate border-0 bg-transparent shadow-none [--chat-composer-attached-tint:transparent]",
54 variantColors[variant],
55 placement === "attached"
56 ? "[--chat-composer-attachment-overlap:calc(1rem+1px)] before:rounded-t-[16px]"
57 : "[--chat-composer-attachment-overlap:0px] before:rounded-[1rem]",
58 "before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:border before:border-(--chat-composer-attached-outline)",
59 "before:bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint))] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)",
60 "before:mask-[linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),black_var(--chat-composer-attachment-overlap))] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]",
61 "dark:supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-[linear-gradient(var(--chat-composer-attached-tint),var(--chat-composer-attached-tint)),linear-gradient(to_top,transparent_0_var(--chat-composer-attachment-overlap),rgb(0_0_0/18%)_var(--chat-composer-attachment-overlap),transparent_calc(var(--chat-composer-attachment-overlap)+10px))]",
62 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-(--chat-composer-attached-surface)",
63 className,
64 )}
65 {...props}
66 />
67 );
68}
69
70// A peeking notice uses the first hidden notice's severity, never the attached row's.
71const peekBorder: Record<ComposerBannerVariant, string> = {
72 default: "border-(--chat-composer-attached-outline)",
73 error: "border-destructive/24",
74 info: "border-info/24",
75 success: "border-success/24",
76 warning: "border-warning/24",
77};
78
79function Peek({
80 className,
81 variant = "default",
82 ...props
83}: ComponentProps<"button"> & { variant?: ComposerBannerVariant }) {
84 return (
85 <button
86 type="button"
87 data-slot="composer-banner-peek"
88 className={cn(
89 surfaceColors,
90 neutralOutline,
91 "absolute inset-x-0 bottom-0 z-0 mx-auto h-3 w-[96%] cursor-pointer rounded-t-2xl border border-b-0 shadow-[0_6px_18px_rgb(0_0_0/6%)]",
92 "bg-[color-mix(in_srgb,var(--chat-composer-attached-surface)_var(--glass-opacity),transparent)] backdrop-blur-(--glass-blur) backdrop-saturate-(--glass-saturation)",
93 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:bg-(--chat-composer-attached-surface)",
94 "transition-opacity duration-150 ease-out focus-visible:outline-2 focus-visible:outline-ring",
95 peekBorder[variant],
96 className,
97 )}
98 {...props}
99 />
100 );
101}
102
103function Attachment({ className, ...props }: ComponentProps<"div">) {
104 return (
105 <div
106 data-slot="composer-banner-attachment"
107 className={cn(
108 "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2.75rem)] max-w-[45.25rem]",
109 // Adjacent attachments share their outline, including notices outside the form.
110 "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0",
111 "[&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:rounded-none [&+:has([data-chat-composer-form])_[data-chat-composer-form]>[data-slot=composer-banner-attachment]:first-child_[data-composer-banner-surface=attached]]:before:border-t-0",
112 className,
113 )}
114 {...props}
115 />
116 );
117}
118
119function Dock({ className, ...props }: ComponentProps<"div">) {
120 return (
121 <Attachment
122 className={cn("flex items-end gap-1 *:data-[composer-banner-width=fill]:flex-1", className)}
123 {...props}
124 />
125 );
126}
127
128function Root({
129 className,
130 placement = "attached",
131 variant = "default",
132 width = "fill",
133 ...props
134}: ComponentProps<"div"> & {
135 placement?: "attached" | "floating";
136 variant?: ComposerBannerVariant;
137 width?: "fill" | "content";
138}) {
139 return (
140 <Surface
141 className={cn(
142 "min-w-0 p-1 pb-[calc(var(--chat-composer-attachment-overlap)+(--spacing(1)))] text-xs/4 [--composer-banner-icon-column:--spacing(7)] sm:[--composer-banner-icon-column:--spacing(6)]",
143 width === "content" ? "w-fit max-w-full flex-none" : "@container",
144 className,
145 )}
146 data-slot="composer-banner"
147 placement={placement}
148 data-composer-banner-width={width}
149 variant={variant}
150 {...props}
151 />
152 );
153}
154
155/** The same row can be a status, a list item, or an entire disclosure button. */
156function Row({
157 className,
158 render,
159 layout = "inline",
160 ...props
161}: useRender.ComponentProps<"div"> & {
162 layout?: "inline" | "wrap-actions";
163}) {
164 const rowProps = {
165 className: cn(
166 "group/banner-row grid min-h-(--composer-banner-icon-column) w-full min-w-0 grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)_auto] items-center gap-x-1 text-start",
167 "not-has-[>[data-slot=composer-banner-actions]]:grid-cols-[var(--composer-banner-icon-column)_minmax(0,1fr)]",
168 "[&:is(button)]:cursor-pointer [&:is(button)]:rounded-[0.5rem] [&:is(button)]:focus-visible:outline-2 [&:is(button)]:focus-visible:-outline-offset-2 [&:is(button)]:focus-visible:outline-ring",
169 layout === "wrap-actions" &&
170 "@max-[400px]:flex @max-[400px]:flex-wrap @max-[400px]:gap-y-1 @max-[400px]:*:data-[slot=composer-banner-actions]:ms-auto @max-[400px]:*:data-[slot=composer-banner-actions]:max-w-full @max-[400px]:has-[>[data-slot=composer-banner-icon]]:*:data-[slot=composer-banner-actions]:max-w-[calc(100%-var(--composer-banner-icon-column)-(--spacing(1)))] @max-[400px]:*:data-[slot=composer-banner-content]:min-h-(--composer-banner-icon-column) @max-[400px]:*:data-[slot=composer-banner-content]:flex-[1_1_10rem]",
171 className,
172 ),
173 "data-composer-banner-row": "true",
174 "data-composer-banner-layout": layout,
175 };
176 return useRender({
177 defaultTagName: "div",
178 render,
179 props: mergeProps<"div">(rowProps, props),
180 });
181}
182
183function Icon({ className, ...props }: ComponentProps<"span">) {
184 return (
185 <span
186 aria-hidden
187 data-slot="composer-banner-icon"
188 className={cn(
189 "col-start-1 row-start-1 flex w-(--composer-banner-icon-column) min-w-0 flex-none items-center justify-center text-muted-foreground [&>svg]:size-3",
190 className,
191 )}
192 {...props}
193 />
194 );
195}
196
197function Content({ className, ...props }: ComponentProps<"span">) {
198 return (
199 <span
200 data-slot="composer-banner-content"
201 className={cn(
202 "col-start-2 row-start-1 flex min-w-0 items-center gap-1 *:data-[slot=composer-banner-separator]:mx-0",
203 "group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:col-[1/3] group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-2 sm:group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:ps-1.5",
204 "group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-2 sm:group-not-has-[>[data-slot=composer-banner-icon],>[data-slot=composer-banner-actions]]/banner-row:pe-1.5",
205 className,
206 )}
207 {...props}
208 />
209 );
210}
211
212function Separator() {
213 return (
214 <span
215 aria-hidden
216 data-slot="composer-banner-separator"
217 className="mx-1 inline-block flex-none text-muted-foreground/40"
218 >
219 ·
220 </span>
221 );
222}
223
224function Actions({ className, ...props }: ComponentProps<"span">) {
225 return (
226 <span
227 data-slot="composer-banner-actions"
228 className={cn(
229 "col-start-3 row-start-1 flex flex-wrap items-center justify-end gap-1",
230 className,
231 )}
232 {...props}
233 />
234 );
235}
236
237/** Child rows keep their parent's columns and begin immediately after its header. */
238function Children({ className, render, ...props }: useRender.ComponentProps<"div">) {
239 return useRender({
240 defaultTagName: "div",
241 render,
242 props: mergeProps<"div">(
243 { className: cn("grid gap-px [&_[data-composer-banner-row]]:min-h-5", className) },
244 props,
245 ),
246 });
247}
248
249/** Bounded banner content uses the app's scroll area and fades only overflowing edges. */
250function Scroll({ className, ...props }: ComponentProps<typeof ScrollArea>) {
251 return (
252 <ScrollArea
253 scrollFade
254 className={cn(
255 "h-auto max-h-[min(24rem,40dvh)] rounded-none [&>[data-slot=scroll-area-viewport][data-has-overflow-y]]:pe-2",
256 className,
257 )}
258 {...props}
259 />
260 );
261}
262
263function Count({ className, ...props }: ComponentProps<"span">) {
264 return (
265 <span
266 className={cn(
267 "inline-flex min-w-(--composer-banner-icon-column,1em) flex-none justify-center font-medium text-muted-foreground tabular-nums",
268 className,
269 )}
270 {...props}
271 />
272 );
273}
274
275function Body({ className, ...props }: ComponentProps<"div">) {
276 return (
277 <div
278 className={cn(
279 "min-w-0 ps-[calc(var(--composer-banner-icon-column)+(--spacing(1)))]",
280 className,
281 )}
282 {...props}
283 />
284 );
285}
286
287function Dot({ className, ...props }: ComponentProps<"span">) {
288 return (
289 <span className={cn("size-1.5 flex-none rounded-full bg-current", className)} {...props} />
290 );
291}
292
293function ToggleIcon({ expanded, className }: { expanded: boolean; className?: string }) {
294 return (
295 <span
296 aria-hidden
297 className={cn(buttonVariants({ size: "icon-xs", variant: "ghost" }), className)}
298 >
299 <ChevronDownIcon className={cn("size-3.5", !expanded && "rotate-180")} />
300 </span>
301 );
302}
303
304function Dismiss({ className, children, ...props }: ComponentProps<typeof Button>) {
305 return (
306 <Button size="icon-xs" variant="ghost" className={className} {...props}>
307 {children ?? <XIcon className="size-3.5" />}
308 </Button>
309 );
310}
311
312export const ComposerBanner = {
313 Surface,
314 Peek,
315 Attachment,
316 Dock,
317 Root,
318 Row,
319 Icon,
320 Content,
321 Separator,
322 Actions,
323 Children,
324 Scroll,
325 Count,
326 Body,
327 Dot,
328 ToggleIcon,
329 Dismiss,
330};
apps/web/src/components/chat/ComposerBannerStack.test.tsx created+108
...@@ -0,0 +1,108 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
5
6const banner = (
7 id: string,
8 variant: ComposerBannerStackItem["variant"] = "warning",
9): ComposerBannerStackItem => ({
10 id,
11 variant,
12 icon: <span aria-hidden="true">!</span>,
13 title: `${id} warning`,
14});
15
16describe("ComposerBannerStack", () => {
17 it("keeps expanded banners in layout flow so surrounding content moves out of their way", () => {
18 const markup = renderToStaticMarkup(
19 <ComposerBannerStack items={[banner("front"), banner("stacked")]} />,
20 );
21
22 const expandedItems = markup.match(
23 /<div data-composer-banner-stack-expanded-items="true" class="([^"]+)">/,
24 );
25
26 expect(expandedItems?.[1]).toContain("grid-rows-[0fr]");
27 expect(expandedItems?.[1]).toContain("group-hover/banner-stack:grid-rows-[1fr]");
28 expect(expandedItems?.[1]).toContain("z-20");
29 expect(expandedItems?.[1]).not.toContain("absolute");
30 expect(markup.indexOf("front warning")).toBeLessThan(markup.indexOf("stacked warning"));
31 expect(markup).toContain("invisible pointer-events-none");
32 expect(markup).toContain("group-focus-within/banner-stack:visible");
33 });
34
35 it("colors the collapsed stack cap by the hidden banner's variant, not a fixed warning", () => {
36 const neutralBehind = renderToStaticMarkup(
37 <ComposerBannerStack items={[banner("front", "default"), banner("stacked", "default")]} />,
38 );
39 expect(neutralBehind).toContain("chat-composer-banner-stack-cap");
40 expect(neutralBehind).toContain("border-[var(--chat-composer-attached-outline)]");
41 expect(neutralBehind).not.toContain("border-border");
42 expect(neutralBehind).not.toContain("border-warning/24");
43
44 const warningBehind = renderToStaticMarkup(
45 <ComposerBannerStack items={[banner("front", "default"), banner("stacked", "warning")]} />,
46 );
47 expect(warningBehind).toContain("border-warning/24");
48 });
49
50 it("does not render an expandable region for a single banner", () => {
51 const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />);
52
53 expect(markup).not.toContain("data-composer-banner-stack-expanded-items");
54 expect(markup).toContain("chat-composer-drawer-surface");
55 expect(markup).toContain("chat-composer-drawer-attached");
56 expect(markup).not.toContain("before:mask-none");
57 expect(markup).toContain("text-xs");
58 expect(markup).toContain('data-composer-banner-drawer="true"');
59 expect(markup).toContain('data-variant="warning"');
60 expect(markup).toContain("transform:none");
61 expect(markup).not.toContain("will-change:transform");
62 });
63 it("applies item-specific surface and action layout classes", () => {
64 const markup = renderToStaticMarkup(
65 <ComposerBannerStack
66 items={[
67 {
68 ...banner("branch"),
69 className: "branch-surface",
70 actionClassName: "branch-actions",
71 actions: <button type="button">Repair</button>,
72 },
73 ]}
74 />,
75 );
76
77 expect(markup).toContain("branch-surface");
78 expect(markup).toContain("branch-actions");
79 });
80
81 it("renders a disabled compaction action on the shared accessible banner surface", () => {
82 const markup = renderToStaticMarkup(
83 <ComposerBannerStack
84 items={[
85 {
86 id: "resume-compaction",
87 variant: "info",
88 icon: <span aria-hidden="true">!</span>,
89 title: "Resume with less context",
90 description: "250k tokens from an older session",
91 actions: (
92 <button type="button" disabled>
93 Compact
94 </button>
95 ),
96 dismissLabel: "Keep full history",
97 onDismiss: () => {},
98 },
99 ]}
100 />,
101 );
102
103 expect(markup).toContain('role="alert"');
104 expect(markup).toContain("chat-composer-drawer-attached");
105 expect(markup).toContain('disabled=""');
106 expect(markup).toContain('aria-label="Keep full history"');
107 });
108});
apps/web/src/components/chat/ComposerBannerStack.tsx+133-173
...@@ -1,52 +1,60 @@...@@ -1,52 +1,60 @@
1import { useEffect, useId, useRef, useState, type ReactNode } from "react";1import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
2import { XIcon } from "lucide-react";
23
3import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
4import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";5import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert";
6import { Button } from "../ui/button";
57
6// Match the duration-220 exit transition before removing a dismissed notice.
7const DISMISS_TRANSITION_MS = 220;8const DISMISS_TRANSITION_MS = 220;
9const frontExitStyle = {
10 opacity: 0,
11 transform: "translate3d(0, 4rem, 0)",
12} satisfies CSSProperties;
13const stackedExitStyle = {
14 opacity: 0,
15 transform: "translate3d(0, 7rem, 0)",
16} satisfies CSSProperties;
17const restingStyle = {
18 opacity: 1,
19 transform: "none",
20} satisfies CSSProperties;
21const exitTransitionStyle = {
22 transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`,
23} satisfies CSSProperties;
24
25// The collapsed cap peeking above the front banner is the only hint that more
26// banners are stacked behind it, so its border must match the severity of the
27// first hidden banner — a neutral banner must not masquerade as a warning.
28const stackCapBorderClass: Record<ComposerBannerStackItem["variant"], string> = {
29 default: "border-[var(--chat-composer-attached-outline)]",
30 error: "border-destructive/24",
31 info: "border-info/24",
32 success: "border-success/24",
33 warning: "border-warning/24",
34};
835
9export interface ComposerBannerStackItem {36export interface ComposerBannerStackItem {
10 readonly id: string;37 readonly id: string;
11 readonly variant: ComposerBannerVariant;38 readonly variant: "default" | "error" | "info" | "success" | "warning";
12 readonly priority?: "urgent" | "activity" | "notice";39 // Ordering hint for stack assemblers: front this banner even though its
40 // variant is calm (e.g. live update progress). The stack itself ignores it.
41 readonly urgent?: boolean;
13 readonly icon: ReactNode;42 readonly icon: ReactNode;
14 readonly title: ReactNode;43 readonly title: ReactNode;
15 readonly description?: ReactNode;44 readonly description?: ReactNode;
16 readonly children?: ReactNode;
17 readonly actions?: ReactNode;45 readonly actions?: ReactNode;
18 readonly className?: string;46 readonly className?: string;
47 readonly actionClassName?: string;
19 readonly dismissLabel?: string;48 readonly dismissLabel?: string;
20 readonly onDismiss?: () => void;49 readonly onDismiss?: () => void;
21}50}
2251
23export type ComposerBannerStackContent = Pick<
24 ComposerBannerStackItem,
25 "id" | "variant" | "priority" | "className"
26> & { readonly content: ReactNode };
27
28type ComposerBannerStackEntry = ComposerBannerStackItem | ComposerBannerStackContent;
29
30function bannerPriority(item: ComposerBannerStackEntry) {
31 if (item.priority === "activity") {
32 return 0;
33 }
34 if (item.priority === "urgent" || item.variant === "error" || item.variant === "warning") {
35 return 1;
36 }
37 return 2;
38}
39
40interface ComposerBannerStackProps {52interface ComposerBannerStackProps {
41 readonly className?: string;53 readonly className?: string;
42 readonly items: ReadonlyArray<ComposerBannerStackEntry>;54 readonly items: ReadonlyArray<ComposerBannerStackItem>;
43}55}
4456
45export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) {57export 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();
50 const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null);58 const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null);
51 const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);59 const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
52 const exitingItemId =60 const exitingItemId =
...@@ -62,27 +70,21 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -62,27 +70,21 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
62 };70 };
63 }, []);71 }, []);
6472
65 useEffect(() => {
66 if (items.length < 2) setStackExpanded(false);
67 }, [items.length]);
68
69 if (items.length === 0) {73 if (items.length === 0) {
70 return null;74 return null;
71 }75 }
7276
73 // Activity stays attached. Urgency and severity only order the notices behind it.77 const frontItem = items[0];
74 const orderedItems = items.toSorted((a, b) => bannerPriority(a) - bannerPriority(b));
75 const frontItem = orderedItems[0];
76 if (!frontItem) {78 if (!frontItem) {
77 return null;79 return null;
78 }80 }
79 const stackedItems = orderedItems.slice(1);81 const stackedItems = items.slice(1);
80 const hasStack = stackedItems.length > 0;82 const hasStack = stackedItems.length > 0;
81 const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;83 const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id;
82 const firstStackedItem = stackedItems[0];84 const firstStackedItem = stackedItems[0];
8385
84 const requestDismiss = (item: ComposerBannerStackEntry) => {86 const requestDismiss = (item: ComposerBannerStackItem) => {
85 if (!("onDismiss" in item) || !item.onDismiss || exitingItemId) {87 if (!item.onDismiss || exitingItemId) {
86 return;88 return;
87 }89 }
88 setExitingItemId(item.id);90 setExitingItemId(item.id);
...@@ -96,28 +98,37 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -96,28 +98,37 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
96 };98 };
9799
98 return (100 return (
99 <ComposerBanner.Attachment101 <div
100 className={className}102 className={cn("group/banner-stack chat-composer-drawer-slot", className)}
101 data-composer-banner-drawer="true"103 data-composer-banner-drawer="true"
102 data-chat-composer-collapsed-controls="true"
103 >104 >
104 <div className={cn("relative flex flex-col-reverse", hasStack && stackExpanded && "z-50")}>105 <div
106 className={cn(
107 "relative flex flex-col-reverse",
108 hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null,
109 )}
110 >
111 {showCollapsedStackCap && firstStackedItem ? (
112 <div
113 className={cn(
114 "pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-2xl",
115 "chat-composer-banner-stack-cap border border-b-0 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",
116 stackCapBorderClass[firstStackedItem.variant],
117 "transition-opacity duration-150 ease-out",
118 "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0",
119 )}
120 style={{ width: "96%" }}
121 aria-hidden="true"
122 />
123 ) : null}
105 <div124 <div
106 className={cn(125 className={cn(
107 "relative z-10 transition-[translate,opacity] duration-220 ease-in",126 "relative z-10",
108 exitingItemId === frontItem.id127 exitingItemId === frontItem.id ? "pointer-events-none" : null,
109 ? "pointer-events-none translate-y-16 opacity-0"
110 : "opacity-100",
111 )}128 )}
112 onPointerDownCapture={() => {129 style={{
113 setStackExpanded(false);130 ...exitTransitionStyle,
114 const activeElement = document.activeElement;131 ...(exitingItemId === frontItem.id ? frontExitStyle : restingStyle),
115 if (
116 activeElement instanceof HTMLElement &&
117 noticesRef.current?.contains(activeElement)
118 ) {
119 activeElement.blur();
120 }
121 }}132 }}
122 >133 >
123 <ComposerBannerStackAlert134 <ComposerBannerStackAlert
...@@ -129,86 +140,44 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -129,86 +140,44 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
129 </div>140 </div>
130 {hasStack ? (141 {hasStack ? (
131 <div142 <div
132 ref={noticesRef}143 data-composer-banner-stack-expanded-items="true"
133 className="relative z-20"144 className={cn(
134 onPointerEnter={(event) => {145 "relative z-20 grid grid-rows-[0fr] transition-[grid-template-rows] duration-150 ease-out",
135 if (event.pointerType !== "touch") setStackExpanded(true);146 "group-hover/banner-stack:grid-rows-[1fr] group-focus-within/banner-stack:grid-rows-[1fr]",
136 }}147 )}
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 }}
155 >148 >
156 {showCollapsedStackCap && firstStackedItem ? (149 <div className="min-h-0 overflow-hidden">
157 <ComposerBanner.Peek150 <div
158 ref={peekRef}151 className={cn(
159 variant={firstStackedItem.variant}152 "invisible pointer-events-none space-y-2 pb-2 opacity-0",
160 aria-label="Show other notices"153 "translate-y-1 transform-gpu transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]",
161 aria-expanded={stackExpanded}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",
162 aria-controls={expandedItemsId}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",
163 onClick={(event) => {156 )}
164 event.currentTarget.focus({ preventScroll: true });157 >
165 setStackExpanded(true);158 {stackedItems.map((item) => (
166 }}159 <div
167 className={cn(stackExpanded && "opacity-0")}160 key={item.id}
168 />161 className={cn(exitingItemId === item.id ? "pointer-events-none" : null)}
169 ) : null}162 style={{
170 <div163 ...exitTransitionStyle,
171 id={expandedItemsId}164 ...(exitingItemId === item.id ? stackedExitStyle : restingStyle),
172 data-composer-banner-stack-expanded-items="true"165 }}
173 className={cn(166 >
174 "grid transition-[grid-template-rows] duration-150 ease-out",167 <ComposerBannerStackAlert
175 stackExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",168 item={item}
176 )}169 attached={false}
177 >170 exiting={exitingItemId === item.id}
178 <div className="min-h-0 overflow-hidden">171 onDismissRequest={() => requestDismiss(item)}
179 <div172 />
180 className={cn(173 </div>
181 "transform-gpu space-y-2 pb-2 transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]",174 ))}
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>
206 </div>175 </div>
207 </div>176 </div>
208 </div>177 </div>
209 ) : null}178 ) : null}
210 </div>179 </div>
211 </ComposerBanner.Attachment>180 </div>
212 );181 );
213}182}
214183
...@@ -218,59 +187,50 @@ function ComposerBannerStackAlert({...@@ -218,59 +187,50 @@ function ComposerBannerStackAlert({
218 exiting,187 exiting,
219 onDismissRequest,188 onDismissRequest,
220}: {189}: {
221 readonly item: ComposerBannerStackEntry;190 readonly item: ComposerBannerStackItem;
222 readonly attached: boolean;191 readonly attached: boolean;
223 readonly exiting: boolean;192 readonly exiting: boolean;
224 readonly onDismissRequest: () => void;193 readonly onDismissRequest: () => void;
225}) {194}) {
226 if ("content" in item) {195 const dismissOnly = item.onDismiss && !item.actions;
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 }
237196
238 return (197 return (
239 <ComposerBanner.Root198 <Alert
240 role="alert"
241 placement={attached ? "attached" : "floating"}
242 variant={item.variant}199 variant={item.variant}
243 className={item.className}200 className={cn(
201 attached
202 ? "chat-composer-drawer-surface chat-composer-drawer-attached px-3 pt-2 pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)] text-xs sm:px-4"
203 : "alert-glass rounded-[22px]",
204 item.className,
205 )}
206 data-variant={item.variant}
244 >207 >
245 <ComposerBanner.Row layout="wrap-actions">208 {item.icon}
246 <ComposerBanner.Icon>{item.icon}</ComposerBanner.Icon>209 <AlertTitle>{item.title}</AlertTitle>
247 <ComposerBanner.Content className="font-medium">{item.title}</ComposerBanner.Content>210 {item.description ? <AlertDescription>{item.description}</AlertDescription> : null}
248 {item.actions || item.onDismiss ? (211 {item.actions || item.onDismiss ? (
249 <ComposerBanner.Actions>212 <AlertAction
250 {item.actions}213 className={cn(
251 {item.onDismiss ? (214 item.actionClassName,
252 <ComposerBanner.Dismiss215 dismissOnly
253 aria-label={item.dismissLabel ?? "Dismiss warning"}216 ? "max-sm:col-start-3 max-sm:row-start-1 max-sm:mt-0 max-sm:self-start"
254 disabled={exiting}217 : undefined,
255 onClick={onDismissRequest}218 )}
256 />219 >
257 ) : null}220 {item.actions}
258 </ComposerBanner.Actions>221 {item.onDismiss ? (
259 ) : null}222 <Button
260 </ComposerBanner.Row>223 size="icon-xs"
261 {item.description || item.children ? (224 variant="ghost"
262 <ComposerBanner.Children>225 aria-label={item.dismissLabel ?? "Dismiss warning"}
263 {item.description ? (226 disabled={exiting}
264 <ComposerBanner.Row>227 onClick={onDismissRequest}
265 <ComposerBanner.Icon />228 >
266 <ComposerBanner.Content className="text-muted-foreground">229 <XIcon className="size-3.5" />
267 {item.description}230 </Button>
268 </ComposerBanner.Content>
269 </ComposerBanner.Row>
270 ) : null}231 ) : null}
271 {item.children}232 </AlertAction>
272 </ComposerBanner.Children>
273 ) : null}233 ) : null}
274 </ComposerBanner.Root>234 </Alert>
275 );235 );
276}236}
apps/web/src/components/chat/ComposerCommandMenu.test.tsx+2
...@@ -19,6 +19,8 @@ describe("ComposerCommandMenu", () => {...@@ -19,6 +19,8 @@ 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");
22 expect(markup).not.toContain("dropdown-glass");24 expect(markup).not.toContain("dropdown-glass");
23 });25 });
2426
apps/web/src/components/chat/ComposerCommandMenu.tsx+3-4
...@@ -24,7 +24,6 @@ import { cn } from "~/lib/utils";...@@ -24,7 +24,6 @@ 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";
2827
29export type ComposerCommandItem =28export type ComposerCommandItem =
30 | {29 | {
...@@ -89,9 +88,9 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -89,9 +88,9 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
89 );88 );
90 }}89 }}
91 >90 >
92 <ComposerBanner.Surface91 <div
93 ref={listRef}92 ref={listRef}
94 className="w-full overflow-hidden pb-(--chat-composer-attachment-overlap) **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"93 className="chat-composer-drawer-surface chat-composer-drawer-attached relative w-full overflow-hidden **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"
95 data-composer-command-drawer="true"94 data-composer-command-drawer="true"
96 >95 >
97 {props.items.length > 0 ? (96 {props.items.length > 0 ? (
...@@ -126,7 +125,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -126,7 +125,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
126 </p>125 </p>
127 </div>126 </div>
128 )}127 )}
129 </ComposerBanner.Surface>128 </div>
130 </Command>129 </Command>
131 );130 );
132});131});
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 <span34 <div
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 </span>57 </div>
58 );58 );
59});59});
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+42-25
...@@ -5,10 +5,9 @@ import {...@@ -5,10 +5,9 @@ import {
5 derivePendingUserInputProgress,5 derivePendingUserInputProgress,
6 type PendingUserInputDraftAnswer,6 type PendingUserInputDraftAnswer,
7} from "../../pendingUserInput";7} from "../../pendingUserInput";
8import { CheckIcon } from "lucide-react";8import { CheckIcon, ChevronDownIcon } 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";
1211
13interface PendingUserInputPanelProps {12interface PendingUserInputPanelProps {
14 pendingUserInputs: PendingUserInput[];13 pendingUserInputs: PendingUserInput[];
...@@ -168,40 +167,58 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -168,40 +167,58 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
168167
169 return (168 return (
170 <Collapsible169 <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 <CollapsibleTrigger176 {/* The trigger's wrapper is inset less than the card's text column, and
177 render={<ComposerBanner.Row render={<button type="button" />} />}177 the trigger pays the difference back as padding: the hover background
178 title={178 and focus ring bleed 10px past that column on both sides, while the
179 isCollapsed ? "Show the question and its options" : "Hide the question and its options"179 header label and the chevron still line up with the left and right
180 }180 edges of the question text below. The negative block margin keeps the
181 data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}181 taller hit area from pushing the panel down. */}
182 >182 <div className="flex items-center gap-1 px-1 sm:px-2">
183 <ComposerBanner.Icon />183 <CollapsibleTrigger
184 <ComposerBanner.Content>184 title={
185 <span className="shrink-0 font-medium text-muted-foreground">185 isCollapsed ? "Show the question and its options" : "Hide the question and its options"
186 }
187 data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}
188 className="group -my-1 flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors duration-150 hover:bg-muted/35 focus-visible:ring-1 focus-visible:ring-primary/25"
189 >
190 <span className="text-xs font-medium text-muted-foreground group-hover:text-foreground/85">
186 {activeQuestion.header}191 {activeQuestion.header}
187 </span>192 </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>
195 {prompt.questions.length > 1 ? (193 {prompt.questions.length > 1 ? (
196 <span className="text-[10px] font-medium text-muted-foreground tabular-nums">194 <span className="text-[10px] font-medium text-muted-foreground tabular-nums">
197 {questionIndex + 1}/{prompt.questions.length}195 {questionIndex + 1}/{prompt.questions.length}
198 </span>196 </span>
199 ) : null}197 ) : null}
200 <ComposerBanner.ToggleIcon expanded={!isCollapsed} />198 {/* Collapsed, the header is otherwise just a section label and a
201 </ComposerBanner.Actions>199 counter, so the question itself is echoed here as a one-line
202 </CollapsibleTrigger>200 reminder of what is being asked. */}
203 <CollapsiblePanel>201 {isCollapsed ? (
204 <ComposerBanner.Body>202 <span className="min-w-0 flex-1 truncate text-secondary-label text-xs">
203 {activeQuestion.question}
204 </span>
205 ) : null}
206 {/* The chevron points at the body: down while it is open below the
207 header, up while it is collapsed into it. */}
208 <ChevronDownIcon
209 aria-hidden="true"
210 className={cn(
211 "ml-auto size-3.5 shrink-0 text-secondary-label transition-transform duration-150 group-hover:text-foreground",
212 isCollapsed && "rotate-180",
213 )}
214 />
215 </CollapsibleTrigger>
216 </div>
217 {/* The panel carries the horizontal padding itself: it clips its content
218 while the height animates, so the option buttons have to sit inside
219 that padding or their focus rings get shaved off at the edges. */}
220 <CollapsiblePanel className="px-3 sm:px-4">
221 <div className="pt-2 pb-0.5">
205 <p className="text-sm text-foreground/85">{activeQuestion.question}</p>222 <p className="text-sm text-foreground/85">{activeQuestion.question}</p>
206 {activeQuestion.multiSelect ? (223 {activeQuestion.multiSelect ? (
207 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>224 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
...@@ -259,7 +276,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -259,7 +276,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
259 );276 );
260 })}277 })}
261 </div>278 </div>
262 </ComposerBanner.Body>279 </div>
263 </CollapsiblePanel>280 </CollapsiblePanel>
264 </Collapsible>281 </Collapsible>
265 );282 );
apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx+9-8
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { memo } from "react";1import { memo } from "react";
2import { ComposerBanner } from "./ComposerBanner";
32
4export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({3export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({
5 planTitle,4 planTitle,
...@@ -7,14 +6,16 @@ export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBann...@@ -7,14 +6,16 @@ export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBann
7 planTitle: string | null;6 planTitle: string | null;
8}) {7}) {
9 return (8 return (
10 <ComposerBanner.Row>9 <div className="px-3 py-2 sm:px-4">
11 <ComposerBanner.Icon />10 <div className="flex flex-wrap items-center gap-2">
12 <ComposerBanner.Content>11 <span className="text-xs font-medium text-muted-foreground">Plan ready</span>
13 <span className="shrink-0 font-medium text-muted-foreground">Plan ready</span>
14 {planTitle ? (12 {planTitle ? (
15 <span className="min-w-0 flex-1 truncate text-foreground/85">{planTitle}</span>13 <span className="min-w-0 flex-1 truncate text-xs text-foreground/85">{planTitle}</span>
16 ) : null}14 ) : null}
17 </ComposerBanner.Content>15 </div>
18 </ComposerBanner.Row>16 {/* <div className="mt-2 text-xs text-muted-foreground">
17 Review the plan
18 </div> */}
19 </div>
19 );20 );
20});21});
apps/web/src/components/chat/ComposerServerUpdateStatus.tsx deleted-49
...@@ -1,49 +0,0 @@
1import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
2import { useId, useState } from "react";
3
4import { serverUpdateStageLabel } from "../ServerUpdateAction";
5import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6import { ComposerBanner } from "./ComposerBanner";
7
8/** One text line, clipped at the end so the error detail never squeezes its title. */
9export function ComposerServerUpdateStatus({
10 state,
11 serverLabel = "server",
12}: {
13 readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
14 readonly serverLabel?: string;
15}) {
16 const [detailsOpen, setDetailsOpen] = useState(false);
17 const triggerId = useId();
18 const title = `${state.status === "failed" ? "Could not update" : "Updating"} ${serverLabel}`;
19 const detail = state.status === "failed" ? state.message : serverUpdateStageLabel(state.stage);
20 return (
21 <span
22 role={state.status === "failed" ? "alert" : "status"}
23 className="min-w-0"
24 data-composer-server-update-status={state.status}
25 >
26 <Tooltip open={detailsOpen} onOpenChange={setDetailsOpen} triggerId={triggerId}>
27 <TooltipTrigger
28 id={triggerId}
29 closeOnClick={false}
30 render={
31 <button
32 type="button"
33 aria-label={`${title}: ${detail}`}
34 className="block max-w-full cursor-help truncate rounded-sm text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
35 onClick={() => setDetailsOpen(true)}
36 >
37 {title}
38 <ComposerBanner.Separator />
39 <span className="font-normal text-muted-foreground">{detail}</span>
40 </button>
41 }
42 />
43 <TooltipPopup side="top" className="max-w-80">
44 {title}: {detail}
45 </TooltipPopup>
46 </Tooltip>
47 </span>
48 );
49}
apps/web/src/components/chat/ComposerStashBadge.test.tsx created+68
...@@ -0,0 +1,68 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerStashBadge } from "./ComposerStashBadge";
5
6describe("ComposerStashBadge", () => {
7 it("renders as an attached composer tab instead of a floating pill", () => {
8 const markup = renderToStaticMarkup(
9 <ComposerStashBadge
10 count={3}
11 menuOpen={false}
12 pulseKey={0}
13 pulsing={false}
14 onToggleMenu={() => {}}
15 />,
16 );
17
18 expect(markup).toContain("chat-composer-stash-tab");
19 expect(markup).toContain("rounded-t-xl");
20 expect(markup).toContain("border-b-0");
21 expect(markup).toContain("items-center");
22 expect(markup).not.toContain("items-start");
23 expect(markup).not.toContain("rounded-full");
24 expect(markup).not.toContain("opacity-70");
25 expect(markup).not.toContain("border-border");
26 expect(markup).toContain('aria-expanded="false"');
27 });
28
29 it("reports when the stash drawer is open", () => {
30 const markup = renderToStaticMarkup(
31 <ComposerStashBadge
32 count={3}
33 menuOpen
34 pulseKey={0}
35 pulsing={false}
36 onToggleMenu={() => {}}
37 />,
38 );
39
40 expect(markup).toContain('aria-expanded="true"');
41 expect(markup).toContain("text-foreground");
42 expect(markup).toContain("pointer-events-none");
43 expect(markup).not.toContain("invisible");
44 });
45
46 it("keeps a compact inline entry point when a drawer occupies the tab", () => {
47 const markup = renderToStaticMarkup(
48 <ComposerStashBadge
49 count={3}
50 menuOpen
51 placement="inline"
52 pulseKey={0}
53 pulsing={false}
54 onToggleMenu={() => {}}
55 />,
56 );
57
58 expect(markup).toContain('data-slot="button"');
59 expect(markup).toContain("rounded-sm");
60 expect(markup).toContain("focus-visible:ring-2");
61 expect(markup).toContain("pointer-coarse:after:min-h-11");
62 expect(markup).toContain("Stashed prompts: 3. Open stash.");
63 expect(markup).toContain('aria-expanded="true"');
64 expect(markup).not.toContain("chat-composer-stash-tab");
65 expect(markup).not.toContain("rounded-t-xl");
66 expect(markup).not.toContain(" pointer-events-none ");
67 });
68});
apps/web/src/components/chat/ComposerStashBadge.tsx+27-27
...@@ -3,7 +3,6 @@ import { memo } from "react";...@@ -3,7 +3,6 @@ import { memo } from "react";
33
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { Button } from "../ui/button";5import { Button } from "../ui/button";
6import { ComposerBanner } from "./ComposerBanner";
76
8/**7/**
9 * Bookmark control that shows the stash count and opens the stash menu. It8 * Bookmark control that shows the stash count and opens the stash menu. It
...@@ -25,16 +24,17 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {...@@ -25,16 +24,17 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
25 if (props.count === 0) return null;24 if (props.count === 0) return null;
26 const inline = props.placement === "inline";25 const inline = props.placement === "inline";
27 const count = (26 const count = (
28 <ComposerBanner.Count27 <span
29 key={props.pulseKey}28 key={props.pulseKey}
30 className={cn(29 className={cn(
30 "text-[10px] font-medium leading-none tabular-nums",
31 props.pulsing31 props.pulsing
32 ? "animate-[prompt-stash-count-enter_180ms_ease-out_both] text-primary motion-reduce:animate-none"32 ? "animate-[prompt-stash-count-enter_180ms_ease-out_both] text-primary motion-reduce:animate-none"
33 : "text-muted-foreground",33 : "text-muted-foreground",
34 )}34 )}
35 >35 >
36 {props.count}36 {props.count}
37 </ComposerBanner.Count>37 </span>
38 );38 );
3939
40 if (inline) {40 if (inline) {
...@@ -46,7 +46,7 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {...@@ -46,7 +46,7 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
46 aria-label={`Stashed prompts: ${props.count}. Open stash.`}46 aria-label={`Stashed prompts: ${props.count}. Open stash.`}
47 aria-expanded={props.menuOpen}47 aria-expanded={props.menuOpen}
48 className={cn(48 className={cn(
49 "shrink-0 gap-1 px-1.5 text-xs sm:text-xs",49 "shrink-0 gap-1 px-1.5",
50 (props.menuOpen || props.pulsing) &&50 (props.menuOpen || props.pulsing) &&
51 "[--control-icon-color:currentColor] text-foreground",51 "[--control-icon-color:currentColor] text-foreground",
52 )}52 )}
...@@ -60,28 +60,28 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {...@@ -60,28 +60,28 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
60 }60 }
6161
62 return (62 return (
63 <ComposerBanner.Root width="content" data-composer-shoulder-tab className="ml-auto">63 <button
64 <ComposerBanner.Row64 type="button"
65 render={<button type="button" />}65 data-prompt-stash-badge="true"
66 data-prompt-stash-badge="true"66 aria-label={`Stashed prompts: ${props.count}. Open stash.`}
67 aria-label={`Stashed prompts: ${props.count}. Open stash.`}67 aria-expanded={props.menuOpen}
68 aria-expanded={props.menuOpen}68 className={cn(
69 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-colors duration-200",70 "transition-[color,border-color] duration-200",
71 props.menuOpen && "pointer-events-none",71 props.menuOpen && "pointer-events-none",
72 props.menuOpen || props.pulsing72 props.menuOpen || props.pulsing
73 ? "text-foreground"73 ? "text-foreground"
74 : "text-muted-foreground hover:text-foreground",74 : "text-muted-foreground hover:text-foreground",
75 )}75 )}
76 onPointerDown={(event) => event.preventDefault()}76 onPointerDown={(event) => {
77 onClick={props.onToggleMenu}77 // Keep composer focus so Escape/typing flows stay intact.
78 >78 event.preventDefault();
79 <ComposerBanner.Icon>79 }}
80 <BookmarkIcon />80 onClick={props.onToggleMenu}
81 </ComposerBanner.Icon>81 >
82 <ComposerBanner.Content>Stash</ComposerBanner.Content>82 <BookmarkIcon className="size-3 shrink-0" aria-hidden="true" />
83 <ComposerBanner.Actions>{count}</ComposerBanner.Actions>83 Stash
84 </ComposerBanner.Row>84 {count}
85 </ComposerBanner.Root>85 </button>
86 );86 );
87});87});
apps/web/src/components/chat/ComposerStashMenu.test.tsx+44
...@@ -5,6 +5,41 @@ import { describe, expect, it } from "vite-plus/test";...@@ -5,6 +5,41 @@ 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
8 it("shows saved image thumbnails and incomplete image states", () => {43 it("shows saved image thumbnails and incomplete image states", () => {
9 const markup = renderToStaticMarkup(44 const markup = renderToStaticMarkup(
10 <ComposerStashMenu45 <ComposerStashMenu
...@@ -46,6 +81,14 @@ describe("ComposerStashMenu", () => {...@@ -46,6 +81,14 @@ describe("ComposerStashMenu", () => {
46 expect(markup).toContain('src="data:image/png;base64,AA=="');81 expect(markup).toContain('src="data:image/png;base64,AA=="');
47 expect(markup).toContain("1 image dropped");82 expect(markup).toContain("1 image dropped");
48 expect(markup).toContain("saving 1 image");83 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");
49 });92 });
5093
51 it("labels mixed file and image stashes without treating images as files", () => {94 it("labels mixed file and image stashes without treating images as files", () => {
...@@ -86,6 +129,7 @@ describe("ComposerStashMenu", () => {...@@ -86,6 +129,7 @@ describe("ComposerStashMenu", () => {
86 );129 );
87130
88 expect(markup).toContain("(2 attachments)");131 expect(markup).toContain("(2 attachments)");
132 expect(markup).toContain("size-3.5 text-secondary-label");
89 expect(markup).not.toContain("(2 files)");133 expect(markup).not.toContain("(2 files)");
90 });134 });
91});135});
apps/web/src/components/chat/ComposerStashMenu.tsx+80-88
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1import { BookmarkIcon, FileIcon, FileTextIcon } from "lucide-react";1import { FileIcon, XIcon } 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 { ComposerBanner } from "./ComposerBanner";7import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
8import { Button } from "../ui/button";
89
9const SNIPPET_MAX_CHARS = 90;10const SNIPPET_MAX_CHARS = 90;
1011
...@@ -29,7 +30,7 @@ function stashEntrySnippet(entry: PromptStashEntry): string {...@@ -29,7 +30,7 @@ function stashEntrySnippet(entry: PromptStashEntry): string {
29}30}
3031
31/**32/**
32 * Attached banner listing the stashed prompts. Keyboard-first: opened by ⌘S on an33 * Popover listing the stashed prompts. Keyboard-first: opened by ⌘S on an
33 * empty composer, navigated with arrows, restored with Enter, dismissed34 * empty composer, navigated with arrows, restored with Enter, dismissed
34 * with Escape. The listener runs capture-phase on window so it wins over35 * with Escape. The listener runs capture-phase on window so it wins over
35 * the Lexical editor's handlers while the menu is open.36 * the Lexical editor's handlers while the menu is open.
...@@ -63,6 +64,13 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -63,6 +64,13 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
63 return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true);64 return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true);
64 }, [onClose]);65 }, [onClose]);
6566
67 useEffect(() => {
68 if (entries.length === 0) return;
69 if (!entries.some((entry) => entry.id === highlightedId)) {
70 setHighlightedId(entries[0]?.id ?? null);
71 }
72 }, [entries, highlightedId]);
73
66 useEffect(() => {74 useEffect(() => {
67 const handler = (event: KeyboardEvent) => {75 const handler = (event: KeyboardEvent) => {
68 if (event.key === "Escape") {76 if (event.key === "Escape") {
...@@ -75,17 +83,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -75,17 +83,11 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
75 if (entries.length === 0) return;83 if (entries.length === 0) return;
76 event.preventDefault();84 event.preventDefault();
77 event.stopPropagation();85 event.stopPropagation();
78 const currentIndex = entries.findIndex((entry) => entry.id === highlightedEntry?.id);86 const currentIndex = entries.findIndex((entry) => entry.id === highlightedId);
79 const offset = event.key === "ArrowDown" ? 1 : -1;87 const offset = event.key === "ArrowDown" ? 1 : -1;
80 const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0;88 const normalizedIndex = currentIndex >= 0 ? currentIndex : offset === 1 ? -1 : 0;
81 const nextIndex = (normalizedIndex + offset + entries.length) % entries.length;89 const nextIndex = (normalizedIndex + offset + entries.length) % entries.length;
82 setHighlightedId(entries[nextIndex]?.id ?? null);90 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 }
89 return;91 return;
90 }92 }
91 if (event.key === "Enter") {93 if (event.key === "Enter") {
...@@ -109,77 +111,64 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -109,77 +111,64 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
109 };111 };
110 window.addEventListener("keydown", handler, true);112 window.addEventListener("keydown", handler, true);
111 return () => window.removeEventListener("keydown", handler, true);113 return () => window.removeEventListener("keydown", handler, true);
112 }, [entries, highlightedEntry, onClose, onDelete, onRestore]);114 }, [entries, highlightedEntry, highlightedId, onClose, onDelete, onRestore]);
113115
114 return (116 return (
115 <ComposerBanner.Root ref={drawerRef} data-composer-stash-drawer="true">117 <Command autoHighlight={false} mode="none">
116 <ComposerBanner.Row118 <div
117 render={<button type="button" />}119 ref={drawerRef}
118 aria-label="Close stash"120 className="chat-composer-drawer-surface chat-composer-drawer-attached relative w-full overflow-hidden"
119 aria-expanded="true"121 data-composer-stash-drawer="true"
120 onPointerDown={(event) => event.preventDefault()}
121 onClick={onClose}
122 >122 >
123 <ComposerBanner.Icon>123 <div className="flex h-7 items-center justify-end px-2 pt-1">
124 <BookmarkIcon />124 <Button
125 </ComposerBanner.Icon>125 variant="ghost-muted"
126 <ComposerBanner.Content className="text-muted-foreground">Stash</ComposerBanner.Content>126 size="icon-micro"
127 <ComposerBanner.Actions>127 aria-label="Close stash"
128 <ComposerBanner.Count>{entries.length}</ComposerBanner.Count>128 onPointerDown={(event) => event.preventDefault()}
129 <ComposerBanner.ToggleIcon expanded />129 onClick={onClose}
130 </ComposerBanner.Actions>130 >
131 </ComposerBanner.Row>131 <XIcon className="size-3" />
132 <ComposerBanner.Scroll>132 </Button>
133 <ComposerBanner.Children render={<ul />} aria-label="Stashed prompts">133 </div>
134 {entries.length === 0 ? (134 <CommandList className="max-h-64 scroll-pb-6">
135 <ComposerBanner.Row render={<li />}>135 <CommandGroup>
136 <ComposerBanner.Icon />136 {entries.length === 0 ? (
137 <ComposerBanner.Content className="text-muted-foreground">137 <p className="px-3 py-1.5 text-secondary-label text-xs">
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 </ComposerBanner.Content>142 </p>
143 </ComposerBanner.Row>143 ) : (
144 ) : (144 entries.map((entry) => (
145 entries.map((entry) => (145 <CommandItem
146 <ComposerBanner.Row146 key={entry.id}
147 render={<li />}147 value={entry.id}
148 key={entry.id}148 className={cn(
149 data-stash-entry={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",
150 data-highlighted={highlightedEntry?.id === entry.id || undefined}150 highlightedId === entry.id && "bg-accent! text-accent-foreground!",
151 className={cn(151 )}
152 "relative rounded-sm",152 onMouseMove={() => {
153 highlightedEntry?.id === entry.id && "bg-accent text-accent-foreground",153 if (highlightedId !== entry.id) setHighlightedId(entry.id);
154 )}154 }}
155 onMouseMove={() => {155 onMouseDown={(event) => {
156 if (highlightedId !== entry.id) setHighlightedId(entry.id);156 event.preventDefault();
157 }}157 }}
158 onFocus={() => setHighlightedId(entry.id)}158 onClick={() => {
159 >159 onRestore(entry);
160 <ComposerBanner.Icon>160 }}
161 <FileTextIcon />161 >
162 </ComposerBanner.Icon>162 <span className="min-w-0 flex-1 truncate text-xs">
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 >
172 {stashEntrySnippet(entry)}163 {stashEntrySnippet(entry)}
173 </button>164 </span>
174 </ComposerBanner.Content>
175 <ComposerBanner.Actions>
176 {entry.pendingImageCount ? (165 {entry.pendingImageCount ? (
177 <span className="shrink-0 text-muted-foreground">166 <span className="shrink-0 text-[10px] text-secondary-label">
178 saving {entry.pendingImageCount} image167 saving {entry.pendingImageCount} image
179 {entry.pendingImageCount === 1 ? "" : "s"}…168 {entry.pendingImageCount === 1 ? "" : "s"}…
180 </span>169 </span>
181 ) : missingImageCount(entry) > 0 ? (170 ) : missingImageCount(entry) > 0 ? (
182 <span className="shrink-0 text-warning-foreground">171 <span className="shrink-0 text-[10px] text-warning-foreground">
183 {missingImageCount(entry)} image172 {missingImageCount(entry)} image
184 {missingImageCount(entry) === 1 ? "" : "s"} dropped173 {missingImageCount(entry) === 1 ? "" : "s"} dropped
185 </span>174 </span>
...@@ -192,35 +181,38 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -192,35 +181,38 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
192 src={attachment.dataUrl}181 src={attachment.dataUrl}
193 alt=""182 alt=""
194 aria-hidden="true"183 aria-hidden="true"
195 className="size-4 rounded border border-border/70 object-cover"184 className="size-5 rounded border border-border/70 object-cover"
196 />185 />
197 ))}186 ))}
198 </span>187 </span>
199 ) : null}188 ) : null}
200 {(entry.files?.length ?? 0) > 0 ? (189 {(entry.files?.length ?? 0) > 0 ? (
201 <span className="flex shrink-0 items-center gap-1 text-muted-foreground">190 <span className="flex shrink-0 items-center gap-1 text-secondary-label text-xs">
202 <FileIcon className="size-3" aria-hidden />191 <FileIcon className="size-3.5 text-secondary-label" />
203 {entry.files!.length}192 {entry.files!.length}
204 </span>193 </span>
205 ) : null}194 ) : null}
206 <time195 <span className="shrink-0 text-secondary-label text-xs max-sm:hidden">
207 dateTime={entry.createdAt}
208 className="shrink-0 text-muted-foreground tabular-nums max-sm:hidden"
209 >
210 {formatRelativeTimeLabel(entry.createdAt)}196 {formatRelativeTimeLabel(entry.createdAt)}
211 </time>197 </span>
212 <ComposerBanner.Dismiss198 <Button
213 className="z-10"199 variant="ghost"
200 size="icon-xs"
201 className="pointer-events-none shrink-0 [--control-icon-color:currentColor] opacity-0 shadow-none! transition-opacity pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 group-hover/stash:pointer-events-auto group-hover/stash:opacity-100 group-focus-within/stash:pointer-events-auto group-focus-within/stash:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
214 aria-label="Delete stashed prompt"202 aria-label="Delete stashed prompt"
215 onPointerDown={(event) => event.preventDefault()}203 onClick={(event) => {
216 onClick={() => onDelete(entry)}204 event.stopPropagation();
217 />205 onDelete(entry);
218 </ComposerBanner.Actions>206 }}
219 </ComposerBanner.Row>207 >
220 ))208 <XIcon className="size-3.5 stroke-2" />
221 )}209 </Button>
222 </ComposerBanner.Children>210 </CommandItem>
223 </ComposerBanner.Scroll>211 ))
224 </ComposerBanner.Root>212 )}
213 </CommandGroup>
214 </CommandList>
215 </div>
216 </Command>
225 );217 );
226});218});
apps/web/src/components/chat/ComposerSurface.tsx deleted-99
...@@ -1,99 +0,0 @@
1import type { ComponentProps } from "react";
2
3import { cn } from "~/lib/utils";
4
5/** One glass backdrop until a top attachment needs the composer to cover its overlap. */
6function Shell({
7 contextStrip = false,
8 className,
9 ...props
10}: ComponentProps<"div"> & { contextStrip?: boolean }) {
11 return (
12 <div
13 data-slot="composer-shell"
14 data-with-context={contextStrip || undefined}
15 className={cn(
16 "group/composer-surface relative isolate mx-auto w-full max-w-3xl",
17 "[--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]",
18 "dark:[--chat-composer-glass-surface:color-mix(in_srgb,var(--background)_96%,var(--color-white))] dark:[--chat-composer-highlight:rgb(255_255_255/3%)] dark:[--chat-composer-outline:color-mix(in_srgb,var(--color-white)_5%,transparent)]",
19 "[html[data-theme-id]_&]:[--chat-composer-glass-surface:var(--app-theme-surface-raised)] [html[data-theme-id]_&]:[--chat-composer-outline:var(--app-theme-toolbar-border)]",
20 "dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-highlight:color-mix(in_srgb,var(--app-theme-input)_12%,transparent)] dark:[html[data-theme-id]:not([data-theme-id=t3-chat])_&]:[--chat-composer-outline:color-mix(in_srgb,var(--app-theme-input)_30%,var(--background))]",
21 "dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-highlight:color-mix(in_srgb,#432d48_12%,transparent)] dark:[html[data-theme-id=t3-chat]_&]:[--chat-composer-outline:#241e28]",
22 "before:pointer-events-none before:absolute before:inset-0 before:z-0 before:rounded-[22px] before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] before:backdrop-blur-(--glass-blur) before:backdrop-saturate-(--glass-saturation)",
23 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:before:bg-(--chat-composer-glass-surface)",
24 "has-data-[composer-banner-surface=attached]:before:hidden",
25 contextStrip && [
26 "[--chat-composer-context-extension:2.25rem] sm:[--chat-composer-context-extension:2rem]",
27 // Keep one continuous backdrop around the fixed-pixel corners and rem-sized strip inset.
28 "supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:rounded-none",
29 "before:[clip-path:shape(from_0_22px,curve_to_22px_0_with_0_9.85px/9.85px_0,line_to_calc(100%-22px)_0,curve_to_100%_22px_with_calc(100%-9.85px)_0/100%_9.85px,line_to_100%_calc(100%-var(--chat-composer-context-extension)-1.375rem),curve_to_calc(100%-1.375rem)_calc(100%-var(--chat-composer-context-extension))_with_100%_calc(100%-var(--chat-composer-context-extension)-0.6156rem)/calc(100%-0.6156rem)_calc(100%-var(--chat-composer-context-extension)),line_to_calc(100%-1.375rem)_calc(100%-16px),curve_to_calc(100%-1.375rem-16px)_100%_with_calc(100%-1.375rem)_calc(100%-7.16px)/calc(100%-1.375rem-7.16px)_100%,line_to_calc(1.375rem+16px)_100%,curve_to_1.375rem_calc(100%-16px)_with_calc(1.375rem+7.16px)_100%/1.375rem_calc(100%-7.16px),line_to_1.375rem_calc(100%-var(--chat-composer-context-extension)),curve_to_0_calc(100%-var(--chat-composer-context-extension)-1.375rem)_with_0.6156rem_calc(100%-var(--chat-composer-context-extension))/0_calc(100%-var(--chat-composer-context-extension)-0.6156rem),line_to_0_22px,close)]",
30 "not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:bottom-(--chat-composer-context-extension)",
31 ],
32 className,
33 )}
34 {...props}
35 />
36 );
37}
38
39const outlineClasses =
40 "after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:border after:border-(--chat-composer-outline) dark:after:shadow-[inset_0_1px_var(--chat-composer-highlight)]";
41
42// The bottom strip continues the outline, so leave the seam between its corners open.
43const contextSeamClasses =
44 "group-data-with-context/composer-surface:after:[clip-path:polygon(0_0,100%_0,100%_100%,calc(100%-22px)_100%,calc(100%-22px)_calc(100%-2px),22px_calc(100%-2px),22px_100%,0_100%)]";
45
46function Host({ className, ...props }: ComponentProps<"div">) {
47 return (
48 <div
49 data-slot="composer-host"
50 className={cn(
51 "relative z-10 w-full rounded-[22px] shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] after:z-1 dark:shadow-none",
52 outlineClasses,
53 contextSeamClasses,
54 "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none group-has-data-[composer-banner-surface=attached]/composer-surface:after:hidden",
55 className,
56 )}
57 {...props}
58 />
59 );
60}
61
62function Main({ className, ...props }: ComponentProps<"div">) {
63 return (
64 <div
65 data-chat-composer-main-surface="true"
66 className={cn(
67 "group relative z-10 rounded-[22px] p-px transition-colors duration-200",
68 outlineClasses,
69 contextSeamClasses,
70 "after:z-20 after:hidden group-has-data-[composer-banner-surface=attached]/composer-surface:after:block",
71 "group-has-data-[composer-banner-surface=attached]/composer-surface:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] group-has-data-[composer-banner-surface=attached]/composer-surface:backdrop-blur-(--glass-blur) group-has-data-[composer-banner-surface=attached]/composer-surface:backdrop-saturate-(--glass-saturation)",
72 "group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)] dark:group-has-data-[composer-banner-surface=attached]/composer-surface:shadow-none",
73 "not-supports-[(backdrop-filter:blur(1px))_or_(-webkit-backdrop-filter:blur(1px))]:group-has-data-[composer-banner-surface=attached]/composer-surface:bg-(--chat-composer-glass-surface)",
74 "group-has-data-[composer-banner-surface=attached]/composer-surface:**:data-[chat-composer-mobile-collapsed=true]:min-h-[calc(1rem+1px)]",
75 className,
76 )}
77 {...props}
78 />
79 );
80}
81
82function ContextStrip({ className, ...props }: ComponentProps<"div">) {
83 return (
84 <div
85 data-slot="composer-context-strip"
86 className={cn(
87 "group/composer-context relative isolate mx-auto -mt-4 flex w-[calc(100%-2.75rem)] max-w-181 items-center gap-2 overflow-x-clip overflow-y-visible ps-1 pe-2 pt-5 pb-1",
88 "before:absolute before:inset-0 before:-z-1 before:rounded-b-[16px] before:border before:border-(--chat-composer-outline) before:mask-[linear-gradient(to_bottom,transparent_0_1rem,black_1rem)] before:shadow-[0_12px_28px_-18px_rgb(0_0_0/40%)]",
89 "dark:before:border-white/7 dark:before:bg-[linear-gradient(to_bottom,transparent_0_1rem,rgb(0_0_0/18%)_1rem,transparent_calc(1rem+10px)),linear-gradient(rgb(255_255_255/2%),rgb(255_255_255/2%))] dark:before:shadow-[0_14px_32px_-18px_rgb(0_0_0/75%)]",
90 "group-has-data-[composer-banner-surface=attached]/composer-surface:before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] group-has-data-[composer-banner-surface=attached]/composer-surface:before:backdrop-blur-(--glass-blur) group-has-data-[composer-banner-surface=attached]/composer-surface:before:backdrop-saturate-(--glass-saturation)",
91 "not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:bg-[color-mix(in_srgb,var(--chat-composer-glass-surface)_var(--glass-opacity),transparent)] not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:backdrop-blur-(--glass-blur) not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:backdrop-saturate-(--glass-saturation)",
92 className,
93 )}
94 {...props}
95 />
96 );
97}
98
99export const ComposerSurface = { Shell, Host, Main, ContextStrip };
apps/web/src/components/chat/ComposerTasksBadge.test.tsx created+190
...@@ -0,0 +1,190 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerTasksBadge, ComposerTasksDrawer } from "./ComposerTasksBadge";
5
6const progress = {
7 step: "Attach task progress",
8 completedSteps: 1,
9 totalSteps: 3,
10};
11const steps = [
12 { durationMs: 4_000, step: "Inspect the composer", status: "completed" as const },
13 { step: "Attach task progress", status: "inProgress" as const },
14 { step: "Verify the result", status: "pending" as const },
15];
16
17describe("ComposerTasksBadge", () => {
18 it("renders active progress as an attached composer tab", () => {
19 const markup = renderToStaticMarkup(
20 <ComposerTasksBadge
21 expanded={false}
22 onDismiss={() => undefined}
23 onToggle={() => undefined}
24 progress={progress}
25 steps={steps}
26 />,
27 );
28
29 expect(markup).toContain('data-composer-tasks-badge="true"');
30 expect(markup).toContain('aria-expanded="false"');
31 expect(markup).toContain("chat-composer-shoulder-tab");
32 expect(markup).toContain("chat-composer-tasks-tab");
33 expect(markup).toContain("rounded-t-xl");
34 expect(markup).toContain("border-b-0");
35 expect(markup).toContain("left-5.5");
36 expect(markup).toContain("right-5.5");
37 expect(markup).toContain('data-composer-task-current="true"');
38 expect(markup).toContain("min-w-0 flex-1 truncate");
39 expect(markup).toContain("w-20");
40 expect(markup).toContain("Tasks");
41 expect(markup).toContain("Attach task progress");
42 expect(markup).not.toContain("·");
43 expect(markup).toContain("1/3");
44 expect(markup).toContain("Current task: Attach task progress");
45 expect(markup).toContain("lucide-list-todo");
46 expect(markup).toContain('aria-label="Dismiss tasks for this turn"');
47 expect(markup).toContain("lucide-x");
48 expect(markup).not.toContain("lucide-chevron");
49 expect(markup).toContain("bg-success");
50 expect(markup).toContain("bg-primary");
51 expect(markup).toContain("bg-muted-foreground/25");
52 });
53
54 it("leaves room for the stash tab when both shoulders are present", () => {
55 const markup = renderToStaticMarkup(
56 <ComposerTasksBadge
57 expanded={false}
58 hasTrailingShoulder
59 onDismiss={() => undefined}
60 onToggle={() => undefined}
61 progress={progress}
62 steps={steps}
63 />,
64 );
65
66 expect(markup).toContain("right-30");
67 expect(markup).not.toContain("right-5.5");
68 });
69
70 it("has a compact inline fallback for occupied composer shoulders", () => {
71 const markup = renderToStaticMarkup(
72 <ComposerTasksBadge
73 expanded={false}
74 onDismiss={() => undefined}
75 onToggle={() => undefined}
76 placement="inline"
77 progress={progress}
78 steps={steps}
79 />,
80 );
81
82 expect(markup).toContain("rounded-sm");
83 expect(markup).toContain("1/3");
84 expect(markup).not.toContain("chat-composer-shoulder-tab");
85 expect(markup).not.toContain("rounded-t-xl");
86 });
87
88 it("expands into a read-only attached task list", () => {
89 const markup = renderToStaticMarkup(
90 <ComposerTasksDrawer
91 onCollapse={() => undefined}
92 onDismiss={() => undefined}
93 progress={progress}
94 steps={steps}
95 />,
96 );
97
98 expect(markup).toContain('data-chat-composer-tasks-drawer="true"');
99 expect(markup).toContain('data-chat-composer-collapsed-controls="true"');
100 expect(markup).not.toContain("data-variant");
101 expect(markup).toContain('aria-expanded="true"');
102 expect(markup).toContain('aria-label="Collapse tasks. 1 of 3 complete."');
103 expect(markup).toContain('aria-label="Task list. 1 of 3 complete."');
104 expect(markup).toContain('role="list"');
105 expect(markup).toContain('data-composer-tasks-list="true"');
106 expect(markup).toContain('tabindex="0"');
107 expect(markup).toContain("max-h-[min(24rem,40dvh)]");
108 expect(markup).toContain("overflow-y-auto");
109 expect(markup).toContain("overscroll-contain");
110 expect(markup).toContain("focus-visible:ring-2");
111 expect(markup).toContain("focus-visible:ring-inset");
112 expect(markup).toContain("Inspect the composer");
113 expect(markup).toContain('data-composer-task-duration="true"');
114 expect(markup).toContain("ml-auto w-10");
115 expect(markup).toContain("4.0s");
116 expect(markup).toContain("now");
117 expect(markup).toContain("Attach task progress");
118 expect(markup).toContain("Verify the result");
119 expect(markup).toContain("lucide-list-todo");
120 expect(markup).toContain("lucide-chevron-down");
121 expect(markup).toContain('aria-label="Dismiss tasks for this turn"');
122 expect(markup).not.toContain("bg-success");
123 expect(markup).not.toContain("bg-primary");
124 expect(markup).not.toContain("bg-muted-foreground/25");
125 });
126
127 it("drops the step segments when they would render as a blank gap", () => {
128 const manySteps = Array.from({ length: 24 }, (_, index) => ({
129 step: `Step ${index + 1}`,
130 status: "pending" as const,
131 }));
132 const tab = renderToStaticMarkup(
133 <ComposerTasksBadge
134 expanded={false}
135 onDismiss={() => undefined}
136 onToggle={() => undefined}
137 progress={{ ...progress, totalSteps: manySteps.length }}
138 steps={manySteps}
139 />,
140 );
141 const inline = renderToStaticMarkup(
142 <ComposerTasksBadge
143 expanded={false}
144 onDismiss={() => undefined}
145 onToggle={() => undefined}
146 placement="inline"
147 progress={{ ...progress, totalSteps: manySteps.length }}
148 steps={manySteps}
149 />,
150 );
151
152 expect(tab).not.toContain("w-20");
153 expect(tab).toContain("1/24");
154 expect(inline).not.toContain("w-10");
155 expect(inline).toContain("1/24");
156 });
157
158 it("keeps every long-list task inside the bounded scroll region", () => {
159 const longSteps = Array.from({ length: 20 }, (_, index) => ({
160 step: `Task ${index + 1}`,
161 status: index === 0 ? ("inProgress" as const) : ("pending" as const),
162 }));
163 const markup = renderToStaticMarkup(
164 <ComposerTasksDrawer
165 onCollapse={() => undefined}
166 onDismiss={() => undefined}
167 progress={{ step: "Task 1", completedSteps: 0, totalSteps: longSteps.length }}
168 steps={longSteps}
169 />,
170 );
171
172 const listStart = markup.indexOf('data-composer-tasks-list="true"');
173 expect(listStart).toBeGreaterThan(markup.indexOf('aria-label="Collapse tasks.'));
174 expect(markup.slice(listStart)).toContain("Task 20");
175 });
176
177 it("does not render an empty task count", () => {
178 const markup = renderToStaticMarkup(
179 <ComposerTasksBadge
180 expanded={false}
181 onDismiss={() => undefined}
182 onToggle={() => undefined}
183 progress={{ ...progress, totalSteps: 0 }}
184 steps={steps}
185 />,
186 );
187
188 expect(markup).toBe("");
189 });
190});
apps/web/src/components/chat/ComposerTasksBadge.tsx+171-156
...@@ -1,14 +1,9 @@...@@ -1,14 +1,9 @@
1import { ListTodoIcon } from "lucide-react";1import { ChevronDownIcon, ListTodoIcon, XIcon } from "lucide-react";
2import { memo, type ComponentProps } from "react";2import { memo } from "react";
33
4import { formatDuration } from "../../session-logic";4import { formatDuration } from "../../session-logic";
5import { cn } from "~/lib/utils";5import { cn } from "~/lib/utils";
6import { ComposerBanner } from "./ComposerBanner";6import { Button } from "../ui/button";
7import {
8 ComposerActivityIcon,
9 ComposerActivityLabel,
10 type ComposerActivityStatus,
11} from "./ComposerActivityStatus";
127
13export interface ComposerTasksProgress {8export interface ComposerTasksProgress {
14 readonly step: string;9 readonly step: string;
...@@ -61,185 +56,205 @@ function TaskSegments({...@@ -61,185 +56,205 @@ function TaskSegments({
61 );56 );
62}57}
6358
64function TaskSummary({59export const ComposerTasksBadge = memo(function ComposerTasksBadge({
65 expanded,60 expanded,
61 hasTrailingShoulder = false,
62 onDismiss,
63 onToggle,
64 placement = "tab",
66 progress,65 progress,
67 steps,66 steps,
68 activityStatus,
69}: {67}: {
70 readonly expanded: boolean;68 readonly expanded: boolean;
69 readonly hasTrailingShoulder?: boolean;
70 readonly onDismiss: () => void;
71 readonly onToggle: () => void;
72 readonly placement?: "inline" | "tab";
71 readonly progress: ComposerTasksProgress;73 readonly progress: ComposerTasksProgress;
72 readonly steps: readonly ComposerTaskStep[];74 readonly steps: readonly ComposerTaskStep[];
73 readonly activityStatus: ComposerActivityStatus | undefined;
74}) {75}) {
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
75 return (118 return (
76 <>119 <div
77 {activityStatus !== undefined ? (120 className={cn(
78 <ComposerActivityIcon status={activityStatus} />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",
79 ) : (122 hasTrailingShoulder ? "right-30" : "right-5.5",
80 <ComposerBanner.Icon>123 allDone && "text-foreground",
81 <ListTodoIcon />
82 </ComposerBanner.Icon>
83 )}124 )}
84 <ComposerBanner.Content>125 data-composer-tasks-badge="true"
85 {activityStatus !== undefined ? (126 >
86 <ComposerActivityLabel status={activityStatus} />127 <button
87 ) : (128 type="button"
88 <span className="shrink-0 text-muted-foreground">Tasks</span>129 aria-expanded={expanded}
89 )}130 aria-label={label}
90 {activityStatus !== undefined ? <ComposerBanner.Separator /> : null}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>
91 <span137 <span
92 className="min-w-0 flex-1 truncate text-left font-medium text-foreground/80"138 className="min-w-0 flex-1 truncate text-left font-medium text-foreground/80"
93 data-composer-task-current="true"139 data-composer-task-current="true"
94 >140 >
95 {progress.step}141 {progress.step}
96 </span>142 </span>
97 </ComposerBanner.Content>143 <span
98 <ComposerBanner.Actions>144 className={cn(
99 <ComposerBanner.Count145 "shrink-0 font-medium tabular-nums",
100 className={progress.completedSteps >= progress.totalSteps ? "text-success" : undefined}146 allDone ? "text-success" : "text-muted-foreground",
101 data-composer-task-progress="true"147 )}
102 >148 >
103 {progress.completedSteps}/{progress.totalSteps}149 {progress.completedSteps}/{progress.totalSteps}
104 </ComposerBanner.Count>150 </span>
105 <TaskSegments className="hidden w-20 sm:flex" steps={steps} />151 <TaskSegments className="w-20" steps={steps} />
106 <ComposerBanner.ToggleIcon expanded={expanded} />152 </button>
107 </ComposerBanner.Actions>153 <Button
108 </>154 size="icon-micro"
109 );155 variant="ghost-muted"
110}156 aria-label="Dismiss tasks for this turn"
111157 className="shrink-0"
112export const ComposerTasksBadge = memo(function ComposerTasksBadge({158 onClick={onDismiss}
113 expanded,159 onPointerDown={(event) => event.preventDefault()}
114 onToggle,160 >
115 placement = "tab",161 <XIcon aria-hidden className="size-3" />
116 progress,162 </Button>
117 steps,163 </div>
118 activityStatus,
119}: {
120 readonly expanded: boolean;
121 readonly onToggle: () => void;
122 readonly placement?: "inline" | "tab";
123 readonly progress: ComposerTasksProgress;
124 readonly steps: readonly ComposerTaskStep[];
125 readonly activityStatus?: ComposerActivityStatus | undefined;
126}) {
127 if (progress.totalSteps <= 0) return null;
128
129 const row = (
130 <ComposerBanner.Row
131 render={<button type="button" />}
132 aria-expanded={expanded}
133 aria-label={`${expanded ? "Collapse tasks" : "Tasks"}: ${progress.completedSteps} of ${progress.totalSteps} complete. Current task: ${progress.step}`}
134 data-composer-tasks-badge="true"
135 onClick={onToggle}
136 onPointerDown={(event) => event.preventDefault()}
137 >
138 <TaskSummary
139 expanded={expanded}
140 progress={progress}
141 steps={steps}
142 activityStatus={activityStatus}
143 />
144 </ComposerBanner.Row>
145 );
146 return placement === "inline" ? (
147 row
148 ) : (
149 <ComposerBanner.Root data-composer-shoulder-tab>{row}</ComposerBanner.Root>
150 );164 );
151});165});
152166
153export const ComposerTasksContent = memo(function ComposerTasksContent({167export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
154 expanded,168 onDismiss,
155 onToggle,169 onCollapse,
156 progress,170 progress,
157 steps,171 steps,
158 activityStatus,
159}: {172}: {
160 readonly expanded: boolean;173 readonly onDismiss: () => void;
161 readonly onToggle: () => void;174 readonly onCollapse: () => void;
162 readonly progress: ComposerTasksProgress;175 readonly progress: ComposerTasksProgress;
163 readonly steps: readonly ComposerTaskStep[];176 readonly steps: readonly ComposerTaskStep[];
164 readonly activityStatus?: ComposerActivityStatus | undefined;
165}) {177}) {
166 return (178 return (
167 <div179 <div
180 className="chat-composer-top-drawer"
168 data-chat-composer-collapsed-controls="true"181 data-chat-composer-collapsed-controls="true"
169 data-chat-composer-tasks-drawer={expanded ? "true" : undefined}182 data-chat-composer-tasks-drawer="true"
170 >183 >
171 <ComposerTasksBadge184 <div className="flex items-center gap-1 px-3 py-1.5 sm:px-4">
172 expanded={expanded}185 <button
173 onToggle={onToggle}186 type="button"
174 placement="inline"187 aria-expanded="true"
175 progress={progress}188 aria-label={`Collapse tasks. ${progress.completedSteps} of ${progress.totalSteps} complete.`}
176 steps={steps}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"
177 activityStatus={activityStatus}190 onClick={onCollapse}
178 />191 onPointerDown={(event) => event.preventDefault()}
179 {expanded ? (192 >
180 <ComposerBanner.Scroll data-composer-tasks-scroll="true">193 <ListTodoIcon aria-hidden className="size-3.5 shrink-0" />
181 <ComposerBanner.Children194 <span className="font-medium text-foreground">Tasks</span>
182 render={<ul />}195 <span className="tabular-nums">
183 aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`}196 {progress.completedSteps}/{progress.totalSteps}
184 data-composer-tasks-list="true"197 </span>
185 >198 <ChevronDownIcon aria-hidden className="ml-auto size-3.5 shrink-0" />
186 {keyedTaskSteps(steps).map(({ key, step }) => (199 </button>
187 <ComposerBanner.Row key={key} render={<li />}>200 <Button
188 <ComposerBanner.Icon201 size="icon-micro"
189 className={cn(202 variant="ghost-muted"
190 "font-mono text-[10px]",203 aria-label="Dismiss tasks for this turn"
191 step.status === "completed"204 className="shrink-0"
192 ? "text-success"205 onClick={onDismiss}
193 : step.status === "inProgress"206 onPointerDown={(event) => event.preventDefault()}
194 ? "text-primary"207 >
195 : "text-muted-foreground/40",208 <XIcon aria-hidden className="size-3" />
196 )}209 </Button>
197 >210 </div>
198 {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}211 <div
199 </ComposerBanner.Icon>212 aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`}
200 <ComposerBanner.Content213 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"
201 className={cn(214 data-composer-tasks-list="true"
202 step.status === "completed"215 role="list"
203 ? "text-muted-foreground/55"216 tabIndex={0}
204 : step.status === "inProgress"217 >
205 ? "text-foreground/90"218 {keyedTaskSteps(steps).map(({ key, step }) => (
206 : "text-muted-foreground/70",219 <div key={key} className="flex items-baseline gap-2 text-xs leading-5" role="listitem">
207 )}220 <span
208 >221 aria-hidden
209 {step.step}222 className={cn(
210 </ComposerBanner.Content>223 "w-3 shrink-0 text-center font-mono text-[10px]",
211 <ComposerBanner.Actions>224 step.status === "completed"
212 <span225 ? "text-success"
213 className="w-10 text-right text-[10px] text-muted-foreground/45 tabular-nums"226 : step.status === "inProgress"
214 data-composer-task-duration="true"227 ? "text-primary"
215 >228 : "text-muted-foreground/40",
216 {step.durationMs !== undefined229 )}
217 ? formatDuration(step.durationMs)230 >
218 : step.status === "inProgress"231 {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}
219 ? "now"232 </span>
220 : null}233 <span
221 </span>234 className={cn(
222 </ComposerBanner.Actions>235 "min-w-0 flex-1",
223 </ComposerBanner.Row>236 step.status === "completed"
224 ))}237 ? "text-muted-foreground/55"
225 </ComposerBanner.Children>238 : step.status === "inProgress"
226 </ComposerBanner.Scroll>239 ? "text-foreground/90"
227 ) : 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>
228 </div>258 </div>
229 );259 );
230});260});
231
232export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
233 onCollapse,
234 ...props
235}: Omit<ComponentProps<typeof ComposerTasksContent>, "expanded" | "onToggle"> & {
236 readonly onCollapse: () => void;
237}) {
238 return (
239 <ComposerBanner.Attachment>
240 <ComposerBanner.Root>
241 <ComposerTasksContent {...props} expanded onToggle={onCollapse} />
242 </ComposerBanner.Root>
243 </ComposerBanner.Attachment>
244 );
245});
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+39-22
...@@ -321,6 +321,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -321,6 +321,7 @@ describe("deriveMessagesTimelineRows", () => {
321 ],321 ],
322 expandedTurnIds: new Set(["turn-1" as never]),322 expandedTurnIds: new Set(["turn-1" as never]),
323 isWorking: false,323 isWorking: false,
324 activeTurnStartedAt: null,
324 turnDiffSummaryByAssistantMessageId: new Map(),325 turnDiffSummaryByAssistantMessageId: new Map(),
325 revertTurnCountByUserMessageId: new Map(),326 revertTurnCountByUserMessageId: new Map(),
326 });327 });
...@@ -374,6 +375,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -374,6 +375,7 @@ describe("deriveMessagesTimelineRows", () => {
374 completedAt: null,375 completedAt: null,
375 },376 },
376 isWorking: false,377 isWorking: false,
378 activeTurnStartedAt: null,
377 turnDiffSummaryByAssistantMessageId: new Map(),379 turnDiffSummaryByAssistantMessageId: new Map(),
378 revertTurnCountByUserMessageId: new Map(),380 revertTurnCountByUserMessageId: new Map(),
379 });381 });
...@@ -430,6 +432,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -430,6 +432,7 @@ describe("deriveMessagesTimelineRows", () => {
430 },432 },
431 ],433 ],
432 isWorking: false,434 isWorking: false,
435 activeTurnStartedAt: null,
433 turnDiffSummaryByAssistantMessageId: new Map([436 turnDiffSummaryByAssistantMessageId: new Map([
434 ["assistant-1" as never, assistantTurnDiffSummary],437 ["assistant-1" as never, assistantTurnDiffSummary],
435 ]),438 ]),
...@@ -510,6 +513,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -510,6 +513,7 @@ describe("deriveMessagesTimelineRows", () => {
510 const collapsedRows = deriveMessagesTimelineRows({513 const collapsedRows = deriveMessagesTimelineRows({
511 timelineEntries,514 timelineEntries,
512 isWorking: false,515 isWorking: false,
516 activeTurnStartedAt: null,
513 turnDiffSummaryByAssistantMessageId: new Map(),517 turnDiffSummaryByAssistantMessageId: new Map(),
514 revertTurnCountByUserMessageId: new Map(),518 revertTurnCountByUserMessageId: new Map(),
515 });519 });
...@@ -533,6 +537,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -533,6 +537,7 @@ describe("deriveMessagesTimelineRows", () => {
533 timelineEntries,537 timelineEntries,
534 expandedTurnIds: new Set(["turn-1" as never]),538 expandedTurnIds: new Set(["turn-1" as never]),
535 isWorking: false,539 isWorking: false,
540 activeTurnStartedAt: null,
536 turnDiffSummaryByAssistantMessageId: new Map(),541 turnDiffSummaryByAssistantMessageId: new Map(),
537 revertTurnCountByUserMessageId: new Map(),542 revertTurnCountByUserMessageId: new Map(),
538 });543 });
...@@ -598,6 +603,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -598,6 +603,7 @@ describe("deriveMessagesTimelineRows", () => {
598 const rows = deriveMessagesTimelineRows({603 const rows = deriveMessagesTimelineRows({
599 timelineEntries,604 timelineEntries,
600 isWorking: false,605 isWorking: false,
606 activeTurnStartedAt: null,
601 turnDiffSummaryByAssistantMessageId: new Map(),607 turnDiffSummaryByAssistantMessageId: new Map(),
602 revertTurnCountByUserMessageId: new Map(),608 revertTurnCountByUserMessageId: new Map(),
603 });609 });
...@@ -692,6 +698,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -692,6 +698,7 @@ describe("deriveMessagesTimelineRows", () => {
692 completedAt: null,698 completedAt: null,
693 },699 },
694 isWorking: true,700 isWorking: true,
701 activeTurnStartedAt: "2026-01-01T00:00:14Z",
695 turnDiffSummaryByAssistantMessageId: new Map(),702 turnDiffSummaryByAssistantMessageId: new Map(),
696 revertTurnCountByUserMessageId: new Map(),703 revertTurnCountByUserMessageId: new Map(),
697 });704 });
...@@ -728,6 +735,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -728,6 +735,7 @@ describe("deriveMessagesTimelineRows", () => {
728 completedAt: "2026-01-01T00:00:47Z",735 completedAt: "2026-01-01T00:00:47Z",
729 },736 },
730 isWorking: false,737 isWorking: false,
738 activeTurnStartedAt: null,
731 turnDiffSummaryByAssistantMessageId: new Map(),739 turnDiffSummaryByAssistantMessageId: new Map(),
732 revertTurnCountByUserMessageId: new Map(),740 revertTurnCountByUserMessageId: new Map(),
733 });741 });
...@@ -795,6 +803,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -795,6 +803,7 @@ describe("deriveMessagesTimelineRows", () => {
795 completedAt: "2026-01-01T00:00:22Z",803 completedAt: "2026-01-01T00:00:22Z",
796 },804 },
797 isWorking: true,805 isWorking: true,
806 activeTurnStartedAt: "2026-01-01T00:01:00Z",
798 turnDiffSummaryByAssistantMessageId: new Map(),807 turnDiffSummaryByAssistantMessageId: new Map(),
799 revertTurnCountByUserMessageId: new Map(),808 revertTurnCountByUserMessageId: new Map(),
800 });809 });
...@@ -803,6 +812,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -803,6 +812,7 @@ describe("deriveMessagesTimelineRows", () => {
803 "turn-fold:turn-1",812 "turn-fold:turn-1",
804 "assistant-final-entry",813 "assistant-final-entry",
805 "user-followup-entry",814 "user-followup-entry",
815 "working-indicator-row",
806 ]);816 ]);
807 const finalRow = rows.find((row) => row.id === "assistant-final-entry");817 const finalRow = rows.find((row) => row.id === "assistant-final-entry");
808 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);818 expect(finalRow?.kind === "message" && finalRow.showAssistantMeta).toBe(true);
...@@ -845,12 +855,14 @@ describe("deriveMessagesTimelineRows", () => {...@@ -845,12 +855,14 @@ describe("deriveMessagesTimelineRows", () => {
845 completedAt: null,855 completedAt: null,
846 },856 },
847 isWorking: true,857 isWorking: true,
858 activeTurnStartedAt: "2026-01-01T00:00:00Z",
848 turnDiffSummaryByAssistantMessageId: new Map(),859 turnDiffSummaryByAssistantMessageId: new Map(),
849 revertTurnCountByUserMessageId: new Map(),860 revertTurnCountByUserMessageId: new Map(),
850 });861 });
851862
852 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);863 expect(rows.some((row) => row.kind === "turn-fold")).toBe(false);
853 expect(rows.map((row) => row.id)).toEqual([864 expect(rows.map((row) => row.id)).toEqual([
865 "working-indicator-row",
854 "assistant-thought-entry",866 "assistant-thought-entry",
855 "work-live:work-entry-1",867 "work-live:work-entry-1",
856 ]);868 ]);
...@@ -912,11 +924,12 @@ describe("deriveMessagesTimelineRows", () => {...@@ -912,11 +924,12 @@ describe("deriveMessagesTimelineRows", () => {
912 completedAt: null,924 completedAt: null,
913 },925 },
914 isWorking: true,926 isWorking: true,
927 activeTurnStartedAt: "2026-01-01T00:00:00Z",
915 turnDiffSummaryByAssistantMessageId: new Map(),928 turnDiffSummaryByAssistantMessageId: new Map(),
916 revertTurnCountByUserMessageId: new Map(),929 revertTurnCountByUserMessageId: new Map(),
917 });930 });
918931
919 expect(rows.map((row) => row.kind)).toEqual(["work-live"]);932 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
920 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({933 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
921 entry: { id: "running-command" },934 entry: { id: "running-command" },
922 groupedEntries: [935 groupedEntries: [
...@@ -982,11 +995,12 @@ describe("deriveMessagesTimelineRows", () => {...@@ -982,11 +995,12 @@ describe("deriveMessagesTimelineRows", () => {
982 completedAt: null,995 completedAt: null,
983 },996 },
984 isWorking: true,997 isWorking: true,
998 activeTurnStartedAt: "2026-01-01T00:00:00Z",
985 turnDiffSummaryByAssistantMessageId: new Map(),999 turnDiffSummaryByAssistantMessageId: new Map(),
986 revertTurnCountByUserMessageId: new Map(),1000 revertTurnCountByUserMessageId: new Map(),
987 });1001 });
9881002
989 expect(rows.map((row) => row.kind)).toEqual(["work-toggle", "message", "work-live"]);1003 expect(rows.map((row) => row.kind)).toEqual(["working", "work-toggle", "message", "work-live"]);
990 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({1004 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
991 hiddenCount: 1,1005 hiddenCount: 1,
992 summary: "Ran 1 command",1006 summary: "Ran 1 command",
...@@ -1048,11 +1062,12 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1048,11 +1062,12 @@ describe("deriveMessagesTimelineRows", () => {
1048 completedAt: null,1062 completedAt: null,
1049 },1063 },
1050 isWorking: true,1064 isWorking: true,
1065 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1051 turnDiffSummaryByAssistantMessageId: new Map(),1066 turnDiffSummaryByAssistantMessageId: new Map(),
1052 revertTurnCountByUserMessageId: new Map(),1067 revertTurnCountByUserMessageId: new Map(),
1053 });1068 });
10541069
1055 expect(rows.map((row) => row.kind)).toEqual(["work-live", "message", "work-live"]);1070 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]);
1056 expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([1071 expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([
1057 "first-running",1072 "first-running",
1058 "second-running",1073 "second-running",
...@@ -1094,6 +1109,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1094,6 +1109,7 @@ describe("deriveMessagesTimelineRows", () => {
1094 ],1109 ],
1095 latestTurn: null,1110 latestTurn: null,
1096 isWorking: true,1111 isWorking: true,
1112 activeTurnStartedAt: "2026-01-01T00:01:00Z",
1097 turnDiffSummaryByAssistantMessageId: new Map(),1113 turnDiffSummaryByAssistantMessageId: new Map(),
1098 revertTurnCountByUserMessageId: new Map(),1114 revertTurnCountByUserMessageId: new Map(),
1099 });1115 });
...@@ -1154,6 +1170,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1154,6 +1170,7 @@ describe("deriveMessagesTimelineRows", () => {
1154 completedAt: null,1170 completedAt: null,
1155 },1171 },
1156 isWorking: true,1172 isWorking: true,
1173 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1157 turnDiffSummaryByAssistantMessageId: new Map(),1174 turnDiffSummaryByAssistantMessageId: new Map(),
1158 revertTurnCountByUserMessageId: new Map(),1175 revertTurnCountByUserMessageId: new Map(),
1159 });1176 });
...@@ -1189,11 +1206,12 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1189,11 +1206,12 @@ describe("deriveMessagesTimelineRows", () => {
1189 completedAt: null,1206 completedAt: null,
1190 },1207 },
1191 isWorking: true,1208 isWorking: true,
1209 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1192 turnDiffSummaryByAssistantMessageId: new Map(),1210 turnDiffSummaryByAssistantMessageId: new Map(),
1193 revertTurnCountByUserMessageId: new Map(),1211 revertTurnCountByUserMessageId: new Map(),
1194 });1212 });
11951213
1196 expect(rows.map((row) => row.kind)).toEqual(["work-live"]);1214 expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]);
1197 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({1215 expect(rows.find((row) => row.kind === "work-live")).toMatchObject({
1198 entry: { id: "latest-command" },1216 entry: { id: "latest-command" },
1199 groupedEntries: [{ id: "latest-command" }],1217 groupedEntries: [{ id: "latest-command" }],
...@@ -1250,6 +1268,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1250,6 +1268,7 @@ describe("deriveMessagesTimelineRows", () => {
1250 },1268 },
1251 runningTurnId: "turn-2" as never,1269 runningTurnId: "turn-2" as never,
1252 isWorking: true,1270 isWorking: true,
1271 activeTurnStartedAt: "2026-01-01T00:01:00Z",
1253 turnDiffSummaryByAssistantMessageId: new Map(),1272 turnDiffSummaryByAssistantMessageId: new Map(),
1254 revertTurnCountByUserMessageId: new Map(),1273 revertTurnCountByUserMessageId: new Map(),
1255 });1274 });
...@@ -1294,6 +1313,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1294,6 +1313,7 @@ describe("deriveMessagesTimelineRows", () => {
1294 ],1313 ],
1295 expandedTurnIds: new Set(["turn-1" as never]),1314 expandedTurnIds: new Set(["turn-1" as never]),
1296 isWorking: false,1315 isWorking: false,
1316 activeTurnStartedAt: null,
1297 turnDiffSummaryByAssistantMessageId: new Map(),1317 turnDiffSummaryByAssistantMessageId: new Map(),
1298 revertTurnCountByUserMessageId: new Map(),1318 revertTurnCountByUserMessageId: new Map(),
1299 });1319 });
...@@ -1331,6 +1351,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1331,6 +1351,7 @@ describe("deriveMessagesTimelineRows", () => {
1331 completedAt: null,1351 completedAt: null,
1332 },1352 },
1333 isWorking: true,1353 isWorking: true,
1354 activeTurnStartedAt: "2026-01-01T00:00:00Z",
1334 turnDiffSummaryByAssistantMessageId: new Map(),1355 turnDiffSummaryByAssistantMessageId: new Map(),
1335 revertTurnCountByUserMessageId: new Map(),1356 revertTurnCountByUserMessageId: new Map(),
1336 });1357 });
...@@ -1344,10 +1365,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1344,10 +1365,7 @@ describe("deriveMessagesTimelineRows", () => {
1344 expect(assistantRow?.showAssistantCopyButton).toBe(false);1365 expect(assistantRow?.showAssistantCopyButton).toBe(false);
1345 });1366 });
13461367
1347 it.each([1368 it("models work log overflow expansion as inserted list rows", () => {
1348 ["tools", "tool", "Used 3 tools"],
1349 ["tools and status updates", "info", "Used 2 tools and received 1 update"],
1350 ] as const)("expands %s through the same activity group", (_, middleTone, summary) => {
1351 const timelineEntries = [1369 const timelineEntries = [
1352 {1370 {
1353 id: "work-entry-1",1371 id: "work-entry-1",
...@@ -1368,9 +1386,9 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1368,9 +1386,9 @@ describe("deriveMessagesTimelineRows", () => {
1368 entry: {1386 entry: {
1369 id: "work-2",1387 id: "work-2",
1370 createdAt: "2026-01-01T00:00:02Z",1388 createdAt: "2026-01-01T00:00:02Z",
1371 label: "Status updated",1389 label: "edit",
1372 detail: "Editing MessagesTimeline.tsx",1390 detail: "Editing MessagesTimeline.tsx",
1373 tone: middleTone,1391 tone: "tool" as const,
1374 },1392 },
1375 },1393 },
1376 {1394 {
...@@ -1390,6 +1408,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1390,6 +1408,7 @@ describe("deriveMessagesTimelineRows", () => {
1390 const baseInput = {1408 const baseInput = {
1391 timelineEntries,1409 timelineEntries,
1392 isWorking: false,1410 isWorking: false,
1411 activeTurnStartedAt: null,
1393 turnDiffSummaryByAssistantMessageId: new Map(),1412 turnDiffSummaryByAssistantMessageId: new Map(),
1394 revertTurnCountByUserMessageId: new Map(),1413 revertTurnCountByUserMessageId: new Map(),
1395 };1414 };
...@@ -1404,7 +1423,8 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1404,7 +1423,8 @@ describe("deriveMessagesTimelineRows", () => {
1404 groupId: "work-group:work-entry-1",1423 groupId: "work-group:work-entry-1",
1405 hiddenCount: 3,1424 hiddenCount: 3,
1406 expanded: false,1425 expanded: false,
1407 summary,1426 onlyToolEntries: true,
1427 summary: "Used 3 tools",
1408 });1428 });
1409 expect(expandedRows.map((row) => row.id)).toEqual([1429 expect(expandedRows.map((row) => row.id)).toEqual([
1410 "work-toggle:work-entry-1",1430 "work-toggle:work-entry-1",
...@@ -1439,6 +1459,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1439,6 +1459,7 @@ describe("deriveMessagesTimelineRows", () => {
1439 const rows = deriveMessagesTimelineRows({1459 const rows = deriveMessagesTimelineRows({
1440 timelineEntries,1460 timelineEntries,
1441 isWorking: false,1461 isWorking: false,
1462 activeTurnStartedAt: null,
1442 turnDiffSummaryByAssistantMessageId: new Map(),1463 turnDiffSummaryByAssistantMessageId: new Map(),
1443 revertTurnCountByUserMessageId: new Map(),1464 revertTurnCountByUserMessageId: new Map(),
1444 });1465 });
...@@ -1455,7 +1476,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1455,7 +1476,7 @@ describe("deriveMessagesTimelineRows", () => {
1455 ["an error-toned entry recovers", ["error", "info", "completed"], false],1476 ["an error-toned entry recovers", ["error", "info", "completed"], false],
1456 ["the final failure is hidden", ["completed", "failed", "info"], true],1477 ["the final failure is hidden", ["completed", "failed", "info"], true],
1457 ["the final failure is visible", ["failed", "info", "failed"], true],1478 ["the final failure is visible", ["failed", "info", "failed"], true],
1458 ["the only failure is visible", ["completed", "info", "failed"], true],1479 ["the only failure is visible", ["completed", "info", "failed"], false],
1459 ] as const)(1480 ] as const)(
1460 "uses the final tool call for mixed work groups when %s",1481 "uses the final tool call for mixed work groups when %s",
1461 (_, statuses, hasFailure) => {1482 (_, statuses, hasFailure) => {
...@@ -1485,23 +1506,16 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1485,23 +1506,16 @@ describe("deriveMessagesTimelineRows", () => {
1485 const rows = deriveMessagesTimelineRows({1506 const rows = deriveMessagesTimelineRows({
1486 timelineEntries,1507 timelineEntries,
1487 isWorking: false,1508 isWorking: false,
1509 activeTurnStartedAt: null,
1488 turnDiffSummaryByAssistantMessageId: new Map(),1510 turnDiffSummaryByAssistantMessageId: new Map(),
1489 revertTurnCountByUserMessageId: new Map(),1511 revertTurnCountByUserMessageId: new Map(),
1490 });1512 });
14911513
1492 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({1514 expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({
1493 hiddenCount: statuses.some((status) => status === "error") ? 2 : 3,1515 hiddenCount: 2,
1494 summary: statuses.some((status) => status === "error")1516 summary: null,
1495 ? "Received 1 update and used 1 tool"
1496 : "Used 2 tools and received 1 update",
1497 hasFailure,1517 hasFailure,
1498 });1518 });
1499 if (statuses.some((status) => status === "error")) {
1500 expect(rows[0]).toMatchObject({
1501 kind: "work",
1502 groupedEntries: [{ tone: "error", label: "Command failed" }],
1503 });
1504 }
1505 },1519 },
1506 );1520 );
1507});1521});
...@@ -1543,6 +1557,7 @@ describe("computeStableMessagesTimelineRows", () => {...@@ -1543,6 +1557,7 @@ describe("computeStableMessagesTimelineRows", () => {
1543 },1557 },
1544 ],1558 ],
1545 isWorking: false,1559 isWorking: false,
1560 activeTurnStartedAt: null,
1546 turnDiffSummaryByAssistantMessageId: new Map(),1561 turnDiffSummaryByAssistantMessageId: new Map(),
1547 revertTurnCountByUserMessageId: new Map(),1562 revertTurnCountByUserMessageId: new Map(),
1548 });1563 });
...@@ -1591,6 +1606,7 @@ describe("computeStableMessagesTimelineRows", () => {...@@ -1591,6 +1606,7 @@ describe("computeStableMessagesTimelineRows", () => {
1591 },1606 },
1592 ],1607 ],
1593 isWorking: false,1608 isWorking: false,
1609 activeTurnStartedAt: null,
1594 turnDiffSummaryByAssistantMessageId: new Map(),1610 turnDiffSummaryByAssistantMessageId: new Map(),
1595 revertTurnCountByUserMessageId: new Map(),1611 revertTurnCountByUserMessageId: new Map(),
1596 });1612 });
...@@ -1646,6 +1662,7 @@ describe("computeStableMessagesTimelineRows", () => {...@@ -1646,6 +1662,7 @@ describe("computeStableMessagesTimelineRows", () => {
1646 },1662 },
1647 ],1663 ],
1648 isWorking: false,1664 isWorking: false,
1665 activeTurnStartedAt: null,
1649 turnDiffSummaryByAssistantMessageId: new Map(),1666 turnDiffSummaryByAssistantMessageId: new Map(),
1650 revertTurnCountByUserMessageId: new Map(),1667 revertTurnCountByUserMessageId: new Map(),
1651 });1668 });
apps/web/src/components/chat/MessagesTimeline.logic.ts+127-32
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";11import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types";
12import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";12import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts";
1313
14export const MAX_VISIBLE_WORK_LOG_ENTRIES = 1;
14export const TIMELINE_MINIMAP_ITEM_SPACING = 8;15export const TIMELINE_MINIMAP_ITEM_SPACING = 8;
15export const TIMELINE_MINIMAP_MIN_ITEMS = 2;16export const TIMELINE_MINIMAP_MIN_ITEMS = 2;
16export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";17export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
...@@ -201,8 +202,9 @@ export type MessagesTimelineRow =...@@ -201,8 +202,9 @@ export type MessagesTimelineRow =
201 groupId: string;202 groupId: string;
202 hiddenCount: number;203 hiddenCount: number;
203 expanded: boolean;204 expanded: boolean;
204 summary: string;205 onlyToolEntries: boolean;
205 summaryKind: ToolGroupSummaryKind;206 summary: string | null;
207 summaryKind: ToolGroupSummaryKind | null;
206 hasFailure: boolean;208 hasFailure: boolean;
207 }209 }
208 | {210 | {
...@@ -236,6 +238,12 @@ export type MessagesTimelineRow =...@@ -236,6 +238,12 @@ export type MessagesTimelineRow =
236 id: string;238 id: string;
237 createdAt: string;239 createdAt: string;
238 turnPlan: TurnPlanEntry;240 turnPlan: TurnPlanEntry;
241 }
242 | {
243 kind: "working";
244 id: string;
245 createdAt: string | null;
246 showThinking: boolean;
239 };247 };
240248
241export interface StableMessagesTimelineRowsState {249export interface StableMessagesTimelineRowsState {
...@@ -266,7 +274,7 @@ export function normalizeCompactToolLabel(value: string): string {...@@ -266,7 +274,7 @@ export function normalizeCompactToolLabel(value: string): string {
266 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();274 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
267}275}
268276
269type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other" | "update";277type ToolGroupAction = "read" | "edit" | "command" | "code-search" | "search" | "other";
270type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed";278type ToolGroupSummaryKind = ToolGroupAction | "dynamic-tool" | "agent-tool" | "tone-tool" | "mixed";
271279
272export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {280export function workLogEntryIsLocalCodeSearch(entry: WorkLogEntry): boolean {
...@@ -296,7 +304,7 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {...@@ -296,7 +304,7 @@ export function toolGroupAction(entry: WorkLogEntry): ToolGroupAction {
296 }304 }
297 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";305 if (workLogEntryIsLocalCodeSearch(entry)) return "code-search";
298 if (entry.itemType === "web_search") return "search";306 if (entry.itemType === "web_search") return "search";
299 return workLogEntryIsToolLike(entry) ? "other" : "update";307 return "other";
300}308}
301309
302function toolGroupActionCount(310function toolGroupActionCount(
...@@ -331,8 +339,6 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {...@@ -331,8 +339,6 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
331 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;339 return `Searched code ${count} ${count === 1 ? "time" : "times"}`;
332 case "other":340 case "other":
333 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;341 return `Used ${count} ${count === 1 ? "tool" : "tools"}`;
334 case "update":
335 return `Received ${count} ${count === 1 ? "update" : "updates"}`;
336 }342 }
337}343}
338344
...@@ -658,6 +664,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -658,6 +664,7 @@ export function deriveMessagesTimelineRows(input: {
658 expandedTurnIds?: ReadonlySet<TurnId>;664 expandedTurnIds?: ReadonlySet<TurnId>;
659 expandedWorkGroupIds?: ReadonlySet<string>;665 expandedWorkGroupIds?: ReadonlySet<string>;
660 isWorking: boolean;666 isWorking: boolean;
667 activeTurnStartedAt: string | null;
661 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;668 turnDiffSummaryByAssistantMessageId: ReadonlyMap<MessageId, TurnDiffSummary>;
662 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;669 revertTurnCountByUserMessageId: ReadonlyMap<MessageId, number>;
663}): MessagesTimelineRow[] {670}): MessagesTimelineRow[] {
...@@ -707,6 +714,26 @@ export function deriveMessagesTimelineRows(input: {...@@ -707,6 +714,26 @@ export function deriveMessagesTimelineRows(input: {
707 unsettledTurnId !== null &&714 unsettledTurnId !== null &&
708 entry.toolLifecycleStatus === "inProgress" &&715 entry.toolLifecycleStatus === "inProgress" &&
709 entry.turnId === unsettledTurnId;716 entry.turnId === unsettledTurnId;
717 const isVisibleActiveToolEntry = (entry: WorkLogEntry) =>
718 workLogEntryIsToolLike(entry) && workEntryIsVisibleInGroup(entry, true);
719 const activeEntries = input.isWorking
720 ? input.timelineEntries.filter((entry, index) => entryBelongsToActiveTurn(entry, index))
721 : [];
722 const activeTurnHasVisibleContent = activeEntries.some((entry) => {
723 if (entry.kind === "message") {
724 return entry.message.role === "assistant" && (entry.message.text?.trim().length ?? 0) > 0;
725 }
726 if (entry.kind === "work") {
727 return (
728 entry.entry.agentSpawn === undefined &&
729 workLogEntryIsToolLike(entry.entry) &&
730 entry.entry.toolLifecycleStatus === "inProgress"
731 );
732 }
733 if (entry.kind === "proposed-plan" || entry.kind === "turn-plan") return true;
734 return false;
735 });
736
710 const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = [];737 const activeToolEntries: Array<Extract<TimelineEntry, { kind: "work" }>> = [];
711 for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {738 for (let index = input.timelineEntries.length - 1; index >= activeTurnHeaderIndex; index -= 1) {
712 const entry = input.timelineEntries[index]!;739 const entry = input.timelineEntries[index]!;
...@@ -714,7 +741,8 @@ export function deriveMessagesTimelineRows(input: {...@@ -714,7 +741,8 @@ export function deriveMessagesTimelineRows(input: {
714 !entryBelongsToActiveTurn(entry, index) ||741 !entryBelongsToActiveTurn(entry, index) ||
715 entry.kind !== "work" ||742 entry.kind !== "work" ||
716 entry.entry.agentSpawn !== undefined ||743 entry.entry.agentSpawn !== undefined ||
717 entry.entry.tone === "error"744 entry.entry.tone === "error" ||
745 !workLogEntryIsToolLike(entry.entry)
718 ) {746 ) {
719 break;747 break;
720 }748 }
...@@ -722,7 +750,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -722,7 +750,7 @@ export function deriveMessagesTimelineRows(input: {
722 }750 }
723 const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));751 const activeWorkEntryIds = new Set(activeToolEntries.map((entry) => entry.id));
724 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(752 const visibleActiveToolEntries = omitSupersededLifecycleMarkers(
725 activeToolEntries.filter((entry) => workEntryIsVisibleInGroup(entry.entry, true)),753 activeToolEntries.filter((entry) => isVisibleActiveToolEntry(entry.entry)),
726 (entry) => entry.entry,754 (entry) => entry.entry,
727 );755 );
728 const activeWorkAnchor = activeToolEntries[0];756 const activeWorkAnchor = activeToolEntries[0];
...@@ -743,6 +771,14 @@ export function deriveMessagesTimelineRows(input: {...@@ -743,6 +771,14 @@ export function deriveMessagesTimelineRows(input: {
743 };771 };
744 })()772 })()
745 : null;773 : null;
774 const appendWorkingRow = () => {
775 nextRows.push({
776 kind: "working",
777 id: "working-indicator-row",
778 createdAt: input.activeTurnStartedAt,
779 showThinking: activeWorkRow === null && !activeTurnHasVisibleContent,
780 });
781 };
746 const appendActiveWorkRows = () => {782 const appendActiveWorkRows = () => {
747 if (activeWorkRow === null) return;783 if (activeWorkRow === null) return;
748 nextRows.push(activeWorkRow);784 nextRows.push(activeWorkRow);
...@@ -765,6 +801,10 @@ export function deriveMessagesTimelineRows(input: {...@@ -765,6 +801,10 @@ export function deriveMessagesTimelineRows(input: {
765 continue;801 continue;
766 }802 }
767803
804 if (input.isWorking && index === activeTurnHeaderIndex) {
805 appendWorkingRow();
806 }
807
768 if (timelineEntry.id === activeWorkPlacementEntryId) {808 if (timelineEntry.id === activeWorkPlacementEntryId) {
769 appendActiveWorkRows();809 appendActiveWorkRows();
770 }810 }
...@@ -790,17 +830,6 @@ export function deriveMessagesTimelineRows(input: {...@@ -790,17 +830,6 @@ export function deriveMessagesTimelineRows(input: {
790 }830 }
791831
792 if (timelineEntry.kind === "work") {832 if (timelineEntry.kind === "work") {
793 if (timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.tone === "error") {
794 nextRows.push({
795 kind: "work",
796 id: timelineEntry.id,
797 createdAt: timelineEntry.createdAt,
798 groupedEntries: [timelineEntry.entry],
799 isExpandedToolGroupEntry: false,
800 isLastExpandedToolGroupEntry: false,
801 });
802 continue;
803 }
804 const groupedEntries = [timelineEntry.entry];833 const groupedEntries = [timelineEntry.entry];
805 let cursor = index + 1;834 let cursor = index + 1;
806 while (cursor < input.timelineEntries.length) {835 while (cursor < input.timelineEntries.length) {
...@@ -808,8 +837,6 @@ export function deriveMessagesTimelineRows(input: {...@@ -808,8 +837,6 @@ export function deriveMessagesTimelineRows(input: {
808 if (837 if (
809 !nextEntry ||838 !nextEntry ||
810 nextEntry.kind !== "work" ||839 nextEntry.kind !== "work" ||
811 nextEntry.entry.agentSpawn !== undefined ||
812 nextEntry.entry.tone === "error" ||
813 activeWorkEntryIds.has(nextEntry.id) ||840 activeWorkEntryIds.has(nextEntry.id) ||
814 collapsedEntryIds.has(nextEntry.id) ||841 collapsedEntryIds.has(nextEntry.id) ||
815 foldsByAnchorEntryId.has(nextEntry.id)842 foldsByAnchorEntryId.has(nextEntry.id)
...@@ -826,8 +853,14 @@ export function deriveMessagesTimelineRows(input: {...@@ -826,8 +853,14 @@ export function deriveMessagesTimelineRows(input: {
826 (entry) => entry,853 (entry) => entry,
827 );854 );
828 if (visibleGroupedEntries.length > 0) {855 if (visibleGroupedEntries.length > 0) {
856 const onlyToolEntries = visibleGroupedEntries.every(
857 (entry) =>
858 workLogEntryIsToolLike(entry) &&
859 entry.agentSpawn === undefined &&
860 entry.tone !== "error",
861 );
829 const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);862 const activeInProgressToolEntries = visibleGroupedEntries.filter(workEntryIsInActiveRun);
830 if (activeInProgressToolEntries.length > 0) {863 if (onlyToolEntries && activeInProgressToolEntries.length > 0) {
831 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);864 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
832 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;865 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
833 const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;866 const latestActiveToolEntry = activeInProgressToolEntries.at(-1)!;
...@@ -852,11 +885,10 @@ export function deriveMessagesTimelineRows(input: {...@@ -852,11 +885,10 @@ export function deriveMessagesTimelineRows(input: {
852 });885 });
853 }886 }
854 }887 }
855 } else {888 } else if (onlyToolEntries) {
856 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);889 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
857 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;890 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
858 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);891 const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
859 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
860 nextRows.push({892 nextRows.push({
861 kind: "work-toggle",893 kind: "work-toggle",
862 id: `work-toggle:${timelineEntry.id}`,894 id: `work-toggle:${timelineEntry.id}`,
...@@ -864,15 +896,10 @@ export function deriveMessagesTimelineRows(input: {...@@ -864,15 +896,10 @@ export function deriveMessagesTimelineRows(input: {
864 groupId,896 groupId,
865 hiddenCount: visibleGroupedEntries.length,897 hiddenCount: visibleGroupedEntries.length,
866 expanded,898 expanded,
867 summary:899 onlyToolEntries: true,
868 visibleGroupedEntries.length === 1 &&900 summary: summarizeToolGroup(visibleGroupedEntries),
869 !workLogEntryIsToolLike(visibleGroupedEntries[0]!)
870 ? visibleGroupedEntries[0]!.label
871 : summarizeToolGroup(visibleGroupedEntries),
872 summaryKind,901 summaryKind,
873 hasFailure:902 hasFailure: workEntryDisplayIndicatesToolFailure(visibleGroupedEntries.at(-1)!),
874 latestToolEntry !== undefined &&
875 workEntryDisplayIndicatesToolFailure(latestToolEntry),
876 });903 });
877 if (expanded) {904 if (expanded) {
878 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {905 for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {
...@@ -886,6 +913,64 @@ export function deriveMessagesTimelineRows(input: {...@@ -886,6 +913,64 @@ export function deriveMessagesTimelineRows(input: {
886 });913 });
887 }914 }
888 }915 }
916 } else if (visibleGroupedEntries.length <= MAX_VISIBLE_WORK_LOG_ENTRIES) {
917 nextRows.push({
918 kind: "work",
919 id: timelineEntry.id,
920 createdAt: timelineEntry.createdAt,
921 groupedEntries: visibleGroupedEntries,
922 isExpandedToolGroupEntry: false,
923 isLastExpandedToolGroupEntry: false,
924 });
925 } else {
926 const groupId = workGroupId(timelineEntry.id, timelineEntry.entry);
927 const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
928 // Agent-spawn CTA rows are always visible: a running fleet must
929 // never hide behind a "+N tool calls" toggle. Selection is by
930 // membership (spawn OR recent-tail), preserving the group's
931 // chronological order in both collapsed and expanded states
932 // (review finding: concatenating two filtered lists moved a
933 // mid-group spawn row above earlier tool rows).
934 const overflowCandidates = visibleGroupedEntries.filter(
935 (entry) => entry.agentSpawn === undefined,
936 );
937 const hiddenEntries = overflowCandidates.slice(0, -MAX_VISIBLE_WORK_LOG_ENTRIES);
938 const hiddenIds = new Set(hiddenEntries.map((entry) => entry.id));
939 const visibleEntries = visibleGroupedEntries.filter(
940 (entry) => entry.agentSpawn !== undefined || !hiddenIds.has(entry.id),
941 );
942 const renderedEntries = expanded ? visibleGroupedEntries : visibleEntries;
943
944 for (const workEntry of renderedEntries) {
945 nextRows.push({
946 kind: "work",
947 id: workEntry.id,
948 createdAt: workEntry.createdAt,
949 groupedEntries: [workEntry],
950 isExpandedToolGroupEntry: false,
951 isLastExpandedToolGroupEntry: false,
952 });
953 }
954
955 if (hiddenEntries.length > 0) {
956 const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
957
958 nextRows.push({
959 kind: "work-toggle",
960 id: `work-toggle:${timelineEntry.id}`,
961 createdAt: timelineEntry.createdAt,
962 groupId,
963 hiddenCount: hiddenEntries.length,
964 expanded,
965 onlyToolEntries: hiddenEntries.every(workLogEntryIsToolLike),
966 summary: null,
967 summaryKind: null,
968 hasFailure:
969 latestToolEntry !== undefined &&
970 workEntryDisplayIndicatesToolFailure(latestToolEntry) &&
971 hiddenEntries.some(workEntryDisplayIndicatesToolFailure),
972 });
973 }
889 }974 }
890 }975 }
891 index = cursor - 1;976 index = cursor - 1;
...@@ -948,6 +1033,10 @@ export function deriveMessagesTimelineRows(input: {...@@ -948,6 +1033,10 @@ export function deriveMessagesTimelineRows(input: {
948 });1033 });
949 }1034 }
9501035
1036 if (input.isWorking && activeTurnHeaderIndex === input.timelineEntries.length) {
1037 appendWorkingRow();
1038 }
1039
951 return nextRows;1040 return nextRows;
952}1041}
9531042
...@@ -976,6 +1065,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -976,6 +1065,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
976 if (a.kind !== b.kind || a.id !== b.id) return false;1065 if (a.kind !== b.kind || a.id !== b.id) return false;
9771066
978 switch (a.kind) {1067 switch (a.kind) {
1068 case "working":
1069 return (
1070 a.createdAt === (b as typeof a).createdAt && a.showThinking === (b as typeof a).showThinking
1071 );
1072
979 case "turn-fold": {1073 case "turn-fold": {
980 const bf = b as typeof a;1074 const bf = b as typeof a;
981 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;1075 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;
...@@ -1018,6 +1112,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -1018,6 +1112,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
1018 a.groupId === bw.groupId &&1112 a.groupId === bw.groupId &&
1019 a.hiddenCount === bw.hiddenCount &&1113 a.hiddenCount === bw.hiddenCount &&
1020 a.expanded === bw.expanded &&1114 a.expanded === bw.expanded &&
1115 a.onlyToolEntries === bw.onlyToolEntries &&
1021 a.summary === bw.summary &&1116 a.summary === bw.summary &&
1022 a.summaryKind === bw.summaryKind &&1117 a.summaryKind === bw.summaryKind &&
1023 a.hasFailure === bw.hasFailure1118 a.hasFailure === bw.hasFailure
apps/web/src/components/chat/MessagesTimeline.test.tsx+26-3
...@@ -177,6 +177,7 @@ const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";...@@ -177,6 +177,7 @@ const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z";
177function buildProps() {177function buildProps() {
178 return {178 return {
179 isWorking: false,179 isWorking: false,
180 activeTurnStartedAt: null,
180 listRef: createRef<LegendListRef | null>(),181 listRef: createRef<LegendListRef | null>(),
181 latestTurn: null,182 latestTurn: null,
182 runningTurnId: null,183 runningTurnId: null,
...@@ -674,6 +675,7 @@ describe("MessagesTimeline", () => {...@@ -674,6 +675,7 @@ describe("MessagesTimeline", () => {
674 <MessagesTimeline675 <MessagesTimeline
675 {...buildProps()}676 {...buildProps()}
676 isWorking677 isWorking
678 activeTurnStartedAt={MESSAGE_CREATED_AT}
677 latestTurn={{679 latestTurn={{
678 turnId,680 turnId,
679 state: "running",681 state: "running",
...@@ -1013,6 +1015,7 @@ describe("MessagesTimeline", () => {...@@ -1013,6 +1015,7 @@ describe("MessagesTimeline", () => {
1013 );1015 );
10141016
1015 expect(markup).toContain("Context compacted");1017 expect(markup).toContain("Context compacted");
1018 expect(markup).toContain("Work Log");
1016 });1019 });
10171020
1018 it("summarizes changed files in one line", () => {1021 it("summarizes changed files in one line", () => {
...@@ -1169,7 +1172,7 @@ describe("MessagesTimeline", () => {...@@ -1169,7 +1172,7 @@ describe("MessagesTimeline", () => {
1169 />,1172 />,
1170 );1173 );
11711174
1172 expect(markup).toContain("Ran 2 commands and received 1 update");1175 expect(markup).toContain("+2 previous log entries");
1173 expect(markup).not.toContain('aria-label="Hidden work includes a failure"');1176 expect(markup).not.toContain('aria-label="Hidden work includes a failure"');
1174 });1177 });
11751178
...@@ -1179,6 +1182,7 @@ describe("MessagesTimeline", () => {...@@ -1179,6 +1182,7 @@ describe("MessagesTimeline", () => {
1179 <MessagesTimeline1182 <MessagesTimeline
1180 {...buildProps()}1183 {...buildProps()}
1181 isWorking1184 isWorking
1185 activeTurnStartedAt={MESSAGE_CREATED_AT}
1182 latestTurn={{1186 latestTurn={{
1183 turnId,1187 turnId,
1184 state: "running",1188 state: "running",
...@@ -1207,6 +1211,7 @@ describe("MessagesTimeline", () => {...@@ -1207,6 +1211,7 @@ describe("MessagesTimeline", () => {
1207 />,1211 />,
1208 );1212 );
12091213
1214 expect(markup).toContain("Working for");
1210 expect(markup).toContain("Running pnpm");1215 expect(markup).toContain("Running pnpm");
1211 expect(markup).toContain("live-activity-focus");1216 expect(markup).toContain("live-activity-focus");
1212 });1217 });
...@@ -1217,6 +1222,7 @@ describe("MessagesTimeline", () => {...@@ -1217,6 +1222,7 @@ describe("MessagesTimeline", () => {
1217 <MessagesTimeline1222 <MessagesTimeline
1218 {...buildProps()}1223 {...buildProps()}
1219 isWorking1224 isWorking
1225 activeTurnStartedAt={MESSAGE_CREATED_AT}
1220 latestTurn={{1226 latestTurn={{
1221 turnId,1227 turnId,
1222 state: "running",1228 state: "running",
...@@ -1271,6 +1277,7 @@ describe("MessagesTimeline", () => {...@@ -1271,6 +1277,7 @@ describe("MessagesTimeline", () => {
1271 <MessagesTimeline1277 <MessagesTimeline
1272 {...buildProps()}1278 {...buildProps()}
1273 isWorking1279 isWorking
1280 activeTurnStartedAt={MESSAGE_CREATED_AT}
1274 latestTurn={{1281 latestTurn={{
1275 turnId,1282 turnId,
1276 state: "running",1283 state: "running",
...@@ -1303,6 +1310,21 @@ describe("MessagesTimeline", () => {...@@ -1303,6 +1310,21 @@ describe("MessagesTimeline", () => {
1303 expect(markup).toContain("tool call failed");1310 expect(markup).toContain("tool call failed");
1304 });1311 });
13051312
1313 it("aligns the iconless Thinking row with the working timer", () => {
1314 const markup = renderToStaticMarkup(
1315 <MessagesTimeline
1316 {...buildProps()}
1317 isWorking
1318 activeTurnStartedAt={MESSAGE_CREATED_AT}
1319 timelineEntries={[]}
1320 />,
1321 );
1322
1323 expect(markup).toContain("Working for");
1324 expect(markup).toContain("Thinking");
1325 expect(markup).toContain("gap-1.5 py-0.5 px-1");
1326 });
1327
1306 it("renders review comment contexts as structured cards instead of raw tags", () => {1328 it("renders review comment contexts as structured cards instead of raw tags", () => {
1307 const markup = renderToStaticMarkup(1329 const markup = renderToStaticMarkup(
1308 <MessagesTimeline1330 <MessagesTimeline
...@@ -1380,7 +1402,7 @@ describe("MessagesTimeline", () => {...@@ -1380,7 +1402,7 @@ describe("MessagesTimeline", () => {
1380 expect(markup).not.toContain('data-testid="file-diff"');1402 expect(markup).not.toContain('data-testid="file-diff"');
1381 });1403 });
13821404
1383 it("keeps failed lifecycle entries discoverable in mixed activity summaries", () => {1405 it("renders a muted failure marker for failed tool lifecycle entries", () => {
1384 const markup = renderToStaticMarkup(1406 const markup = renderToStaticMarkup(
1385 <MessagesTimeline1407 <MessagesTimeline
1386 {...buildProps()}1408 {...buildProps()}
...@@ -1413,7 +1435,8 @@ describe("MessagesTimeline", () => {...@@ -1413,7 +1435,8 @@ describe("MessagesTimeline", () => {
1413 />,1435 />,
1414 );1436 );
14151437
1416 expect(markup).toContain('aria-label="Received 1 update and used 1 tool, tool call failed"');1438 expect(markup).toContain("lucide-x");
1439 expect(markup).toContain('aria-label="Tool call failed"');
1417 // Ordinary tool failures render muted, not red.1440 // Ordinary tool failures render muted, not red.
1418 expect(markup).not.toContain("text-destructive");1441 expect(markup).not.toContain("text-destructive");
1419 });1442 });
apps/web/src/components/chat/MessagesTimeline.tsx+164-22
...@@ -138,7 +138,7 @@ import {...@@ -138,7 +138,7 @@ import {
138// Context — shared state consumed by every row component via Context.138// Context — shared state consumed by every row component via Context.
139// Propagates through LegendList's memo boundaries for shared callbacks and139// Propagates through LegendList's memo boundaries for shared callbacks and
140// non-row-scoped state. `nowIso` is intentionally excluded — self-ticking140// non-row-scoped state. `nowIso` is intentionally excluded — self-ticking
141// components (LiveElapsed) handle it.141// components (WorkingTimer, LiveElapsed) handle it.
142// ---------------------------------------------------------------------------142// ---------------------------------------------------------------------------
143143
144interface TimelineRowSharedState {144interface TimelineRowSharedState {
...@@ -164,6 +164,8 @@ interface TimelineRowActivityState {...@@ -164,6 +164,8 @@ interface TimelineRowActivityState {
164 isWorking: boolean;164 isWorking: boolean;
165 isRevertingCheckpoint: boolean;165 isRevertingCheckpoint: boolean;
166 latestTurnId: TurnId | null;166 latestTurnId: TurnId | null;
167 /** Current plan step label for the working row, when the turn has a plan. */
168 workingStepLabel: string | null;
167}169}
168170
169const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);171const TimelineRowCtx = createContext<TimelineRowSharedState>(null!);
...@@ -216,6 +218,8 @@ interface MessagesTimelineProps {...@@ -216,6 +218,8 @@ interface MessagesTimelineProps {
216 agentPanelModel?: AgentPanelModel;218 agentPanelModel?: AgentPanelModel;
217 onOpenAgents?: () => void;219 onOpenAgents?: () => void;
218 isWorking: boolean;220 isWorking: boolean;
221 workingStepLabel?: string | null;
222 activeTurnStartedAt: string | null;
219 listRef: React.RefObject<LegendListRef | null>;223 listRef: React.RefObject<LegendListRef | null>;
220 timelineEntries: ReturnType<typeof deriveTimelineEntries>;224 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
221 latestTurn: TimelineLatestTurn | null;225 latestTurn: TimelineLatestTurn | null;
...@@ -258,6 +262,8 @@ interface MessagesTimelineProps {...@@ -258,6 +262,8 @@ interface MessagesTimelineProps {
258262
259export const MessagesTimeline = memo(function MessagesTimeline({263export const MessagesTimeline = memo(function MessagesTimeline({
260 isWorking,264 isWorking,
265 workingStepLabel = null,
266 activeTurnStartedAt,
261 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,267 agentPanelModel = EMPTY_AGENT_PANEL_MODEL,
262 onOpenAgents = NOOP_OPEN_AGENTS,268 onOpenAgents = NOOP_OPEN_AGENTS,
263 listRef,269 listRef,
...@@ -420,6 +426,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -420,6 +426,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
420 expandedTurnIds,426 expandedTurnIds,
421 expandedWorkGroupIds,427 expandedWorkGroupIds,
422 isWorking,428 isWorking,
429 activeTurnStartedAt,
423 turnDiffSummaryByAssistantMessageId,430 turnDiffSummaryByAssistantMessageId,
424 revertTurnCountByUserMessageId,431 revertTurnCountByUserMessageId,
425 }),432 }),
...@@ -430,6 +437,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -430,6 +437,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
430 expandedTurnIds,437 expandedTurnIds,
431 expandedWorkGroupIds,438 expandedWorkGroupIds,
432 isWorking,439 isWorking,
440 activeTurnStartedAt,
433 turnDiffSummaryByAssistantMessageId,441 turnDiffSummaryByAssistantMessageId,
434 revertTurnCountByUserMessageId,442 revertTurnCountByUserMessageId,
435 ],443 ],
...@@ -558,8 +566,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -558,8 +566,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
558 isWorking,566 isWorking,
559 isRevertingCheckpoint,567 isRevertingCheckpoint,
560 latestTurnId: latestTurn?.turnId ?? null,568 latestTurnId: latestTurn?.turnId ?? null,
569 workingStepLabel,
561 }),570 }),
562 [isRevertingCheckpoint, isWorking, latestTurn?.turnId],571 [isRevertingCheckpoint, isWorking, latestTurn?.turnId, workingStepLabel],
563 );572 );
564573
565 // Stable renderItem — no closure deps. Row components read shared state574 // Stable renderItem — no closure deps. Row components read shared state
...@@ -937,7 +946,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -937,7 +946,8 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
937 const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;946 const isExpandedToolGroupEntry = row.kind === "work" && row.isExpandedToolGroupEntry;
938 const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;947 const isLastExpandedToolGroupEntry = row.kind === "work" && row.isLastExpandedToolGroupEntry;
939 const isExpandedToolGroupHeader =948 const isExpandedToolGroupHeader =
940 (row.kind === "work-toggle" && row.expanded) || (row.kind === "work-live" && row.expanded);949 (row.kind === "work-toggle" && row.summary !== null && row.onlyToolEntries && row.expanded) ||
950 (row.kind === "work-live" && row.expanded);
941951
942 return (952 return (
943 <div953 <div
...@@ -950,7 +960,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -950,7 +960,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
950 : "pb-0"960 : "pb-0"
951 : isExpandedToolGroupHeader961 : isExpandedToolGroupHeader
952 ? "pb-0"962 ? "pb-0"
953 : row.kind === "turn-fold"963 : row.kind === "turn-fold" || row.kind === "working"
954 ? "pb-1.5"964 ? "pb-1.5"
955 : (row.kind === "message" &&965 : (row.kind === "message" &&
956 row.message.role === "assistant" &&966 row.message.role === "assistant" &&
...@@ -983,6 +993,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time...@@ -983,6 +993,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
983 ) : null}993 ) : null}
984 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}994 {row.kind === "proposed-plan" ? <ProposedPlanTimelineRow row={row} /> : null}
985 {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null}995 {row.kind === "turn-plan" ? <TurnPlanTimelineRow row={row} /> : null}
996 {row.kind === "working" ? <WorkingTimelineRow row={row} /> : null}
986 </div>997 </div>
987 );998 );
988});999});
...@@ -1266,7 +1277,7 @@ function ProposedPlanTimelineRow({...@@ -1266,7 +1277,7 @@ function ProposedPlanTimelineRow({
12661277
1267/**1278/**
1268 * Inline folded plan chip: one row per turn that produced plan/todo steps.1279 * Inline folded plan chip: one row per turn that produced plan/todo steps.
1269 * Collapsed by default, with a segment bar and the current step label,1280 * Collapsed by default — a segment bar plus the in-progress step label —
1270 * and expands in place to the full step list. Replaces the old plan sidebar.1281 * and expands in place to the full step list. Replaces the old plan sidebar.
1271 */1282 */
1272const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({1283const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
...@@ -1288,19 +1299,15 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({...@@ -1288,19 +1299,15 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
1288 const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon;1299 const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon;
12891300
1290 return (1301 return (
1291 <div className="min-w-0 py-0.5">1302 <div className="min-w-0 px-1 py-0.5">
1292 <button1303 <button
1293 type="button"1304 type="button"
1294 className="flex min-h-6 w-full min-w-0 cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"1305 className="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1295 aria-expanded={expanded}1306 aria-expanded={expanded}
1296 onClick={() => setExpanded((value) => !value)}1307 onClick={() => setExpanded((value) => !value)}
1297 >1308 >
1298 <span className="flex h-5 w-6 shrink-0 items-center justify-center text-muted-foreground/65">1309 <Chevron className="size-3.5 shrink-0 text-muted-foreground/65" />
1299 <Chevron1310 {steps.length > 1 ? (
1300 className={cn("size-4 shrink-0", expanded ? "-translate-x-0.5" : "-translate-x-1")}
1301 />
1302 </span>
1303 {steps.length > 1 && steps.length <= 10 ? (
1304 <span aria-hidden className="flex shrink-0 items-center gap-0.5">1311 <span aria-hidden className="flex shrink-0 items-center gap-0.5">
1305 {steps.map((step) => (1312 {steps.map((step) => (
1306 <span1313 <span
...@@ -1332,7 +1339,7 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({...@@ -1332,7 +1339,7 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
1332 ) : null}1339 ) : null}
1333 </button>1340 </button>
1334 {expanded ? (1341 {expanded ? (
1335 <div className="mt-0.5 space-y-px pl-8">1342 <div className="mt-0.5 space-y-px pl-6">
1336 {steps.map((step) => (1343 {steps.map((step) => (
1337 <div key={step.step} className="flex items-baseline gap-2 text-[12px] leading-5">1344 <div key={step.step} className="flex items-baseline gap-2 text-[12px] leading-5">
1338 <span1345 <span
...@@ -1368,6 +1375,61 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({...@@ -1368,6 +1375,61 @@ const TurnPlanTimelineRow = memo(function TurnPlanTimelineRow({
1368 );1375 );
1369});1376});
13701377
1378function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "working" }> }) {
1379 const { workingStepLabel } = use(TimelineRowActivityCtx);
1380 return (
1381 <div>
1382 <div className="border-b border-border/60 pb-2 pt-1">
1383 <div className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
1384 {row.createdAt ? (
1385 <>
1386 Working for <WorkingTimer createdAt={row.createdAt} />
1387 </>
1388 ) : (
1389 "Working..."
1390 )}
1391 {workingStepLabel ? (
1392 <span className="ml-2 text-muted-foreground/55">· {workingStepLabel}</span>
1393 ) : null}
1394 </div>
1395 </div>
1396 {row.showThinking ? (
1397 <div className="mt-1">
1398 <ThinkingActivityRow />
1399 </div>
1400 ) : null}
1401 </div>
1402 );
1403}
1404
1405// ---------------------------------------------------------------------------
1406// Self-ticking labels — update their own text nodes so elapsed-time display
1407// does not create a React commit every second while a response is streaming.
1408// ---------------------------------------------------------------------------
1409
1410/** Live "Working for Xs" label. */
1411function WorkingTimer({ createdAt }: { createdAt: string }) {
1412 const textRef = useRef<HTMLSpanElement>(null);
1413 const initialText = formatWorkingTimerNow(createdAt);
1414
1415 useEffect(() => {
1416 const updateText = () => {
1417 if (textRef.current) {
1418 textRef.current.textContent = formatWorkingTimerNow(createdAt);
1419 }
1420 };
1421 updateText();
1422 const id = setInterval(updateText, 1000);
1423 return () => clearInterval(id);
1424 }, [createdAt]);
1425
1426 return (
1427 <span ref={textRef} className="tabular-nums">
1428 {initialText}
1429 </span>
1430 );
1431}
1432
1371// ---------------------------------------------------------------------------1433// ---------------------------------------------------------------------------
1372// Extracted row sections — own their state / store subscriptions so changes1434// Extracted row sections — own their state / store subscriptions so changes
1373// re-render only the affected row, not the entire list.1435// re-render only the affected row, not the entire list.
...@@ -1387,6 +1449,12 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1387,6 +1449,12 @@ const WorkGroupSection = memo(function WorkGroupSection({
1387 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),1449 groupedEntries.filter((entry) => workEntryIsVisibleInGroup(entry, isExpandedToolGroupEntry)),
1388 [groupedEntries, isExpandedToolGroupEntry],1450 [groupedEntries, isExpandedToolGroupEntry],
1389 );1451 );
1452 const onlyToolEntries = nonEmptyEntries.every((entry) => workLogEntryIsToolLike(entry));
1453 const groupLabel = onlyToolEntries
1454 ? nonEmptyEntries.length === 1
1455 ? "1 tool call"
1456 : `${nonEmptyEntries.length} tool calls`
1457 : "Work Log";
1390 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";1458 const GroupContainer = isExpandedToolGroupEntry ? "div" : "section";
13911459
1392 if (nonEmptyEntries.length === 0) return null;1460 if (nonEmptyEntries.length === 0) return null;
...@@ -1394,8 +1462,11 @@ const WorkGroupSection = memo(function WorkGroupSection({...@@ -1394,8 +1462,11 @@ const WorkGroupSection = memo(function WorkGroupSection({
1394 return (1462 return (
1395 <GroupContainer1463 <GroupContainer
1396 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}1464 className={cn("-mx-1 px-1", isExpandedToolGroupEntry ? "py-0" : "space-y-0.5 py-0.5")}
1397 aria-label={isExpandedToolGroupEntry ? undefined : "Activity"}1465 aria-label={isExpandedToolGroupEntry ? undefined : groupLabel}
1398 >1466 >
1467 {!onlyToolEntries && (
1468 <p className="px-0.5 pb-0.5 font-medium text-secondary-label text-[11px]">{groupLabel}</p>
1469 )}
1399 <div className="space-y-px">1470 <div className="space-y-px">
1400 {nonEmptyEntries.map((workEntry) => (1471 {nonEmptyEntries.map((workEntry) => (
1401 <SimpleWorkEntryRow1472 <SimpleWorkEntryRow
...@@ -1441,6 +1512,10 @@ function LiveActivityRow({...@@ -1441,6 +1512,10 @@ function LiveActivityRow({
1441 );1512 );
1442}1513}
14431514
1515function ThinkingActivityRow() {
1516 return <LiveActivityRow label="Thinking" />;
1517}
1518
1444function LiveActivityContent({1519function LiveActivityContent({
1445 label,1520 label,
1446 iconName,1521 iconName,
...@@ -1524,8 +1599,8 @@ function toolGroupSummaryIconName(...@@ -1524,8 +1599,8 @@ function toolGroupSummaryIconName(
1524 return "bot";1599 return "bot";
1525 case "tone-tool":1600 case "tone-tool":
1526 return "zap";1601 return "zap";
1527 case "update":
1528 case "mixed":1602 case "mixed":
1603 case null:
1529 return "hammer";1604 return "hammer";
1530 }1605 }
1531}1606}
...@@ -1536,21 +1611,61 @@ function WorkGroupToggleTimelineRow({...@@ -1536,21 +1611,61 @@ function WorkGroupToggleTimelineRow({
1536 row: Extract<TimelineRow, { kind: "work-toggle" }>;1611 row: Extract<TimelineRow, { kind: "work-toggle" }>;
1537}) {1612}) {
1538 const ctx = use(TimelineRowCtx);1613 const ctx = use(TimelineRowCtx);
1614 if (row.onlyToolEntries && row.summary) {
1615 return (
1616 <button
1617 type="button"
1618 className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1619 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}
1620 aria-expanded={row.expanded}
1621 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1622 >
1623 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1624 <WorkEntryIconSvg
1625 name={toolGroupSummaryIconName(row.summaryKind)}
1626 className="size-4 shrink-0 stroke-[1.8] opacity-70"
1627 />
1628 </span>
1629 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>
1630 </button>
1631 );
1632 }
1633 const labelNoun = row.onlyToolEntries
1634 ? row.hiddenCount === 1
1635 ? "tool call"
1636 : "tool calls"
1637 : row.hiddenCount === 1
1638 ? "log entry"
1639 : "log entries";
1539 return (1640 return (
1540 <button1641 <button
1541 type="button"1642 type="button"
1542 className="group/tool-group flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"1643 className="flex min-h-6 w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-sm leading-relaxed transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1543 aria-label={row.hasFailure ? `${row.summary}, tool call failed` : undefined}1644 aria-label={
1645 row.hasFailure && !row.expanded
1646 ? `+${row.hiddenCount} previous ${labelNoun}, includes a failure`
1647 : undefined
1648 }
1544 aria-expanded={row.expanded}1649 aria-expanded={row.expanded}
1545 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}1650 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1546 >1651 >
1547 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">1652 <span className="flex size-6 shrink-0 items-center justify-center text-icon-muted">
1548 <WorkEntryIconSvg1653 <ChevronDownIcon
1549 name={toolGroupSummaryIconName(row.summaryKind)}1654 className={cn(
1550 className="size-4 shrink-0 stroke-[1.8] opacity-70"1655 "size-4 shrink-0 opacity-70 transition-transform duration-200",
1656 row.expanded && "rotate-180",
1657 )}
1551 />1658 />
1552 </span>1659 </span>
1553 <span className="min-w-0 flex-1 truncate text-secondary-label">{row.summary}</span>1660 {row.expanded ? (
1661 <span className="font-medium text-foreground">
1662 Show fewer {row.onlyToolEntries ? "tool calls" : "log entries"}
1663 </span>
1664 ) : (
1665 <span className="font-medium text-foreground">
1666 +{row.hiddenCount} previous {labelNoun}
1667 </span>
1668 )}
1554 </button>1669 </button>
1555 );1670 );
1556}1671}
...@@ -2048,6 +2163,33 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {...@@ -2048,6 +2163,33 @@ function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] {
2048// Pure helpers2163// Pure helpers
2049// ---------------------------------------------------------------------------2164// ---------------------------------------------------------------------------
20502165
2166function formatWorkingTimer(startIso: string, endIso: string): string | null {
2167 const startedAtMs = Date.parse(startIso);
2168 const endedAtMs = Date.parse(endIso);
2169 if (!Number.isFinite(startedAtMs) || !Number.isFinite(endedAtMs)) {
2170 return null;
2171 }
2172
2173 const elapsedSeconds = Math.max(0, Math.floor((endedAtMs - startedAtMs) / 1000));
2174 if (elapsedSeconds < 60) {
2175 return `${elapsedSeconds}s`;
2176 }
2177
2178 const hours = Math.floor(elapsedSeconds / 3600);
2179 const minutes = Math.floor((elapsedSeconds % 3600) / 60);
2180 const seconds = elapsedSeconds % 60;
2181
2182 if (hours > 0) {
2183 return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
2184 }
2185
2186 return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
2187}
2188
2189function formatWorkingTimerNow(startIso: string): string {
2190 return formatWorkingTimer(startIso, new Date().toISOString()) ?? "0s";
2191}
2192
2051type WorkEntryIconName =2193type WorkEntryIconName =
2052 | "bot"2194 | "bot"
2053 | "check"2195 | "check"
apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx created+22
...@@ -0,0 +1,22 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ThreadSyncStatusPill } from "./ThreadSyncStatusPill";
5
6describe("ThreadSyncStatusPill", () => {
7 it.each([
8 ["loading", "Loading messages..."],
9 ["syncing", "Syncing messages..."],
10 ] as const)("renders the %s message sync phase", (phase, label) => {
11 const markup = renderToStaticMarkup(<ThreadSyncStatusPill phase={phase} />);
12
13 expect(markup).toContain('role="status"');
14 expect(markup).toContain('data-thread-sync-drawer="true"');
15 expect(markup).toContain("chat-composer-drawer-surface");
16 expect(markup).toContain("chat-composer-drawer-attached");
17 expect(markup).toContain("chat-composer-drawer-slot");
18 expect(markup).toContain("pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)]");
19 expect(markup).toContain(label);
20 expect(markup).not.toContain("animate-");
21 });
22});
apps/web/src/components/chat/ThreadSyncStatusPill.tsx created+19
...@@ -0,0 +1,19 @@
1import { LoaderCircleIcon } from "lucide-react";
2
3import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
4
5export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhase }) {
6 const label = threadSyncLabel(phase);
7
8 return (
9 <div
10 aria-label={label}
11 className="chat-composer-drawer-surface chat-composer-drawer-attached chat-composer-drawer-slot pointer-events-none flex items-center gap-2 px-3 pt-2 pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)] text-foreground text-xs font-medium sm:px-4"
12 data-thread-sync-drawer="true"
13 role="status"
14 >
15 <LoaderCircleIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
16 <span className="truncate">{label}</span>
17 </div>
18 );
19}
apps/web/src/index.css+524
...@@ -763,6 +763,472 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -763,6 +763,472 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
763 outline: none;763 outline: none;
764 }764 }
765765
766 .chat-composer-shoulder-tab {
767 border-color: var(
768 --chat-composer-outline,
769 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
770 );
771 background: color-mix(
772 in srgb,
773 var(--chat-composer-glass-surface, var(--card)) var(--glass-opacity),
774 transparent
775 );
776 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
777 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
778 box-shadow: 0 -8px 24px -18px rgb(0 0 0 / 45%);
779
780 @variant dark {
781 box-shadow: 0 -10px 28px -18px rgb(0 0 0 / 80%);
782 }
783
784 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
785 background: var(--chat-composer-glass-surface, var(--card));
786 }
787 }
788
789 .chat-composer-banner-stack-cap {
790 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
791 --chat-composer-attached-outline: var(
792 --chat-composer-outline,
793 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
794 );
795 background: color-mix(
796 in srgb,
797 var(--chat-composer-attached-surface) var(--glass-opacity),
798 transparent
799 );
800 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
801 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
802
803 @variant dark {
804 --chat-composer-attached-surface: var(
805 --chat-composer-glass-surface,
806 color-mix(in srgb, var(--background) 96%, var(--color-white))
807 );
808 --chat-composer-attached-outline: var(
809 --chat-composer-outline,
810 color-mix(in srgb, var(--color-white) 5%, transparent)
811 );
812 }
813
814 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
815 background: var(--chat-composer-attached-surface);
816 }
817 }
818
819 /* Mirror the run-context strip above the composer. These surfaces deliberately
820 own no card fill or border themselves: a masked pseudo-element draws the
821 same inset glass, 16px outer corners, quiet outline, and open attachment
822 seam as the strip below. */
823 .chat-composer-drawer-surface,
824 .chat-composer-top-drawer {
825 --chat-composer-attachment-overlap: calc(1rem + 1px);
826 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
827 --chat-composer-attached-outline: var(
828 --chat-composer-outline,
829 color-mix(in srgb, var(--contrast-foreground) 8%, transparent)
830 );
831 --chat-composer-attached-tint: transparent;
832 position: relative;
833 isolation: isolate;
834 border: 0 !important;
835 background: transparent !important;
836 box-shadow: none;
837
838 @variant dark {
839 --chat-composer-attached-surface: var(
840 --chat-composer-glass-surface,
841 color-mix(in srgb, var(--background) 96%, var(--color-white))
842 );
843 --chat-composer-attached-outline: var(
844 --chat-composer-outline,
845 color-mix(in srgb, var(--color-white) 5%, transparent)
846 );
847
848 &::before {
849 background:
850 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
851 linear-gradient(
852 to top,
853 transparent 0 var(--chat-composer-attachment-overlap),
854 rgb(0 0 0 / 18%) var(--chat-composer-attachment-overlap),
855 transparent calc(var(--chat-composer-attachment-overlap) + 10px)
856 ),
857 color-mix(
858 in srgb,
859 var(--chat-composer-attached-surface) var(--glass-opacity),
860 transparent
861 );
862 box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
863 }
864 }
865 }
866
867 .chat-composer-drawer-attached,
868 .chat-composer-top-drawer {
869 padding-bottom: var(--chat-composer-attachment-overlap);
870 }
871
872 [data-chat-composer-form="true"],
873 .chat-composer-drawer-slot,
874 .chat-composer-top-drawer {
875 --chat-composer-drawer-inset: 1.375rem;
876 }
877
878 .chat-composer-drawer-slot,
879 .chat-composer-top-drawer {
880 --chat-composer-attachment-overlap: calc(1rem + 1px);
881 width: calc(100% - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
882 max-width: calc(48rem - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
883 margin-inline: auto;
884 margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap));
885 }
886
887 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)::before {
888 pointer-events: none;
889 position: absolute;
890 z-index: -1;
891 inset: 0;
892 border: 1px solid var(--chat-composer-attached-outline);
893 border-radius: 16px 16px 0 0;
894 background:
895 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
896 color-mix(in srgb, var(--chat-composer-attached-surface) var(--glass-opacity), transparent);
897 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
898 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
899 -webkit-mask-image: linear-gradient(
900 to top,
901 transparent 0 var(--chat-composer-attachment-overlap),
902 black var(--chat-composer-attachment-overlap)
903 );
904 mask-image: linear-gradient(
905 to top,
906 transparent 0 var(--chat-composer-attachment-overlap),
907 black var(--chat-composer-attachment-overlap)
908 );
909 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
910 content: "";
911
912 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
913 background:
914 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
915 var(--chat-composer-attached-surface) !important;
916 }
917 }
918
919 .chat-composer-drawer-slot + .chat-composer-drawer-slot::before,
920 .chat-composer-drawer-slot + :has(.chat-composer-top-drawer) .chat-composer-top-drawer::before {
921 border-top: 0;
922 border-radius: 0;
923 }
924
925 .chat-composer-top-drawer {
926 z-index: 0;
927 }
928
929 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="error"] {
930 --chat-composer-attached-outline: color-mix(in srgb, var(--error) 32%, transparent);
931 --chat-composer-attached-tint: color-mix(in srgb, var(--error) 8%, transparent);
932 }
933
934 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="info"] {
935 --chat-composer-attached-outline: color-mix(in srgb, var(--info) 32%, transparent);
936 --chat-composer-attached-tint: color-mix(in srgb, var(--info) 4%, transparent);
937 }
938
939 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="success"] {
940 --chat-composer-attached-outline: color-mix(in srgb, var(--success) 32%, transparent);
941 --chat-composer-attached-tint: color-mix(in srgb, var(--success) 4%, transparent);
942 }
943
944 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="warning"] {
945 --chat-composer-attached-outline: color-mix(in srgb, var(--warning) 28%, transparent);
946 --chat-composer-attached-tint: color-mix(in srgb, var(--warning) 8%, transparent);
947 }
948
949 .chat-composer-glass-shell {
950 --chat-composer-attachment-overlap: calc(1rem + 1px);
951 --chat-composer-glass-surface: var(--card);
952 --chat-composer-outline: rgb(0 0 0 / 8%);
953
954 isolation: isolate;
955
956 @variant dark {
957 --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white));
958 --chat-composer-outline: color-mix(in srgb, var(--color-white) 5%, transparent);
959 --chat-composer-highlight: rgb(255 255 255 / 3%);
960 }
961 }
962
963 .chat-composer-glass-shell::before {
964 pointer-events: none;
965 position: absolute;
966 z-index: 0;
967 inset: 0;
968 border-radius: 22px;
969 background: color-mix(
970 in srgb,
971 var(--chat-composer-glass-surface) var(--glass-opacity),
972 transparent
973 );
974 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
975 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
976 content: "";
977 }
978
979 .chat-composer-glass-shell-with-context {
980 --chat-composer-context-extension: 2.25rem;
981 }
982
983 .chat-composer-glass-shell-with-context::before {
984 border-radius: 0;
985 /*
986 * One continuous glass layer: a 22px composer joined to a 16px strip. The
987 * strip is inset 1.375rem per side, so the step-in positions and their
988 * curve controls stay in rem to keep tracking it at non-default interface
989 * font sizes; the composer's 22px top radius and the strip's 16px bottom
990 * radius are px by design.
991 */
992 clip-path: shape(
993 from 0 22px,
994 curve to 22px 0 with 0 9.85px / 9.85px 0,
995 line to calc(100% - 22px) 0,
996 curve to 100% 22px with calc(100% - 9.85px) 0 / 100% 9.85px,
997 line to 100% calc(100% - var(--chat-composer-context-extension) - 1.375rem),
998 curve to calc(100% - 1.375rem) calc(100% - var(--chat-composer-context-extension)) with 100%
999 calc(100% - var(--chat-composer-context-extension) - 0.6156rem) / calc(100% - 0.6156rem)
1000 calc(100% - var(--chat-composer-context-extension)),
1001 line to calc(100% - 1.375rem) calc(100% - 16px),
1002 curve to calc(100% - 1.375rem - 16px) 100% with calc(100% - 1.375rem) calc(100% - 7.16px) /
1003 calc(100% - 1.375rem - 7.16px) 100%,
1004 line to calc(1.375rem + 16px) 100%,
1005 curve to 1.375rem calc(100% - 16px) with calc(1.375rem + 7.16px) 100% / 1.375rem
1006 calc(100% - 7.16px),
1007 line to 1.375rem calc(100% - var(--chat-composer-context-extension)),
1008 curve to 0 calc(100% - var(--chat-composer-context-extension) - 1.375rem) with 0.6156rem
1009 calc(100% - var(--chat-composer-context-extension)) / 0
1010 calc(100% - var(--chat-composer-context-extension) - 0.6156rem),
1011 line to 0 22px,
1012 close
1013 );
1014 }
1015
1016 @media (min-width: 40rem) {
1017 .chat-composer-glass-shell-with-context {
1018 /* The xs toolbar controls shrink by 4px at Tailwind's sm breakpoint. */
1019 --chat-composer-context-extension: 2rem;
1020 }
1021 }
1022
1023 .chat-composer-glass-host {
1024 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1025
1026 @variant dark {
1027 box-shadow: none;
1028
1029 &::after {
1030 box-shadow: inset 0 1px var(--chat-composer-highlight);
1031 }
1032 }
1033 }
1034
1035 .chat-composer-glass-host::after {
1036 pointer-events: none;
1037 position: absolute;
1038 z-index: 1;
1039 inset: 0;
1040 border: 1px solid var(--chat-composer-outline);
1041 border-radius: inherit;
1042 content: "";
1043 }
1044
1045 /* A top attachment cannot share the shell's single clipped backdrop: it
1046 needs the composer surface and outline to paint in front of its overlap.
1047 Split the glass into exact surfaces while a drawer or tab is present; the
1048 default composer keeps the cheaper continuous layer. */
1049 .chat-composer-glass-shell:is(
1050 .chat-composer-glass-shell-attached,
1051 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1052 )::before,
1053 .chat-composer-glass-shell:is(
1054 .chat-composer-glass-shell-attached,
1055 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1056 )
1057 .chat-composer-glass-host::after {
1058 display: none;
1059 }
1060
1061 .chat-composer-glass-shell:is(
1062 .chat-composer-glass-shell-attached,
1063 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1064 )
1065 .chat-composer-glass-host {
1066 box-shadow: none;
1067 }
1068
1069 .chat-composer-glass-shell:is(
1070 .chat-composer-glass-shell-attached,
1071 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1072 )
1073 .chat-composer-glass-host
1074 [data-chat-composer-main-surface="true"] {
1075 background: color-mix(
1076 in srgb,
1077 var(--chat-composer-glass-surface) var(--glass-opacity),
1078 transparent
1079 );
1080 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1081 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1082 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1083
1084 &::after {
1085 pointer-events: none;
1086 position: absolute;
1087 z-index: 20;
1088 inset: 0;
1089 border: 1px solid var(--chat-composer-outline);
1090 border-radius: inherit;
1091 content: "";
1092 }
1093
1094 @variant dark {
1095 box-shadow: none;
1096
1097 &::after {
1098 box-shadow: inset 0 1px var(--chat-composer-highlight);
1099 }
1100 }
1101
1102 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
1103 background: var(--chat-composer-glass-surface);
1104 }
1105 }
1106
1107 .chat-composer-glass-shell:is(
1108 .chat-composer-glass-shell-attached,
1109 :has(.chat-composer-top-drawer)
1110 )
1111 .chat-composer-glass-host
1112 [data-chat-composer-mobile-collapsed="true"] {
1113 min-height: var(--chat-composer-attachment-overlap);
1114 }
1115
1116 .chat-composer-glass-shell-with-context .chat-composer-glass-host::after,
1117 .chat-composer-glass-shell-with-context:is(
1118 .chat-composer-glass-shell-attached,
1119 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1120 )
1121 [data-chat-composer-main-surface="true"]::after {
1122 /* Keep the attachment seam open; the strip continues the outer outline below. */
1123 clip-path: polygon(
1124 0 0,
1125 100% 0,
1126 100% 100%,
1127 calc(100% - 22px) 100%,
1128 calc(100% - 22px) calc(100% - 2px),
1129 22px calc(100% - 2px),
1130 22px 100%,
1131 0 100%
1132 );
1133 }
1134
1135 .chat-composer-context-strip {
1136 position: relative;
1137 isolation: isolate;
1138
1139 @variant dark {
1140 &::before {
1141 border-color: rgb(255 255 255 / 7%);
1142 background:
1143 linear-gradient(
1144 to bottom,
1145 transparent 0 1rem,
1146 rgb(0 0 0 / 18%) 1rem,
1147 transparent calc(1rem + 10px)
1148 ),
1149 rgb(255 255 255 / 2%);
1150 box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
1151 }
1152 }
1153 }
1154
1155 .chat-composer-context-strip::before {
1156 position: absolute;
1157 z-index: -1;
1158 inset: 0;
1159 border: 1px solid var(--chat-composer-outline);
1160 border-radius: 0 0 16px 16px;
1161 /* Start the strip outline exactly where the composer's 22px curve reaches its tangent. */
1162 -webkit-mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem);
1163 mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem);
1164 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1165 content: "";
1166 }
1167
1168 .chat-composer-glass-shell:is(
1169 .chat-composer-glass-shell-attached,
1170 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1171 )
1172 .chat-composer-context-strip {
1173 &::before {
1174 background: color-mix(
1175 in srgb,
1176 var(--chat-composer-glass-surface) var(--glass-opacity),
1177 transparent
1178 );
1179 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1180 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1181 }
1182
1183 @variant dark {
1184 &::before {
1185 background:
1186 linear-gradient(
1187 to bottom,
1188 transparent 0 1rem,
1189 rgb(0 0 0 / 18%) 1rem,
1190 transparent calc(1rem + 10px)
1191 ),
1192 linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)),
1193 color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent);
1194 }
1195 }
1196 }
1197
1198 @supports not (clip-path: shape(from 0 0, line to 1px 1px)) {
1199 .chat-composer-glass-shell-with-context::before {
1200 inset-block-end: var(--chat-composer-context-extension);
1201 border-radius: 22px;
1202 clip-path: none;
1203 }
1204
1205 .chat-composer-context-strip::before {
1206 background: color-mix(
1207 in srgb,
1208 var(--chat-composer-glass-surface) var(--glass-opacity),
1209 transparent
1210 );
1211 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1212 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1213 }
1214
1215 .chat-composer-context-strip {
1216 @variant dark {
1217 &::before {
1218 background:
1219 linear-gradient(
1220 to bottom,
1221 transparent 0 1rem,
1222 rgb(0 0 0 / 18%) 1rem,
1223 transparent calc(1rem + 10px)
1224 ),
1225 linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)),
1226 color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent);
1227 }
1228 }
1229 }
1230 }
1231
766 .settings-slider {1232 .settings-slider {
767 --settings-slider-progress: 0%;1233 --settings-slider-progress: 0%;
768 --settings-slider-fill-offset: 0.5rem;1234 --settings-slider-fill-offset: 0.5rem;
...@@ -877,6 +1343,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -877,6 +1343,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
877 all: revert;1343 all: revert;
878 }1344 }
879 }1345 }
1346
1347 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
1348 .chat-composer-glass-shell::before {
1349 background: var(--chat-composer-glass-surface);
1350 }
1351 }
880}1352}
8811353
882/* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering.1354/* Safe-area inset utilities for surfaces that opt into edge-to-edge rendering.
...@@ -1143,6 +1615,58 @@ html[data-theme-id]:not([data-theme-id=""]) {...@@ -1143,6 +1615,58 @@ html[data-theme-id]:not([data-theme-id=""]) {
1143 --terminal-selection-background: var(--app-theme-terminal-selection-background);1615 --terminal-selection-background: var(--app-theme-terminal-selection-background);
1144}1616}
11451617
1618/* T3 Chat's composer is a translucent lift over --chat-background. Route its
1619 measured flattened color through the raised-surface role instead of deriving
1620 another tint from the canvas, which made the dark composer too red. */
1621html[data-theme-id] .chat-composer-glass-shell {
1622 --chat-composer-glass-surface: var(--app-theme-surface-raised);
1623 --chat-composer-outline: var(--app-theme-toolbar-border);
1624
1625 @variant dark {
1626 --chat-composer-outline: color-mix(in srgb, var(--app-theme-input) 30%, var(--background));
1627 --chat-composer-highlight: color-mix(in srgb, var(--app-theme-input) 12%, transparent);
1628 }
1629}
1630
1631html[data-theme-id]
1632 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer) {
1633 --chat-composer-attached-surface: var(--app-theme-surface-raised);
1634}
1635
1636html[data-theme-id]
1637 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
1638 :not([data-variant]),
1639 [data-variant="default"]
1640 ) {
1641 --chat-composer-attached-outline: var(--chat-composer-outline, var(--app-theme-toolbar-border));
1642
1643 @variant dark {
1644 --chat-composer-attached-outline: var(
1645 --chat-composer-outline,
1646 color-mix(in srgb, var(--app-theme-input) 30%, var(--background))
1647 );
1648 }
1649}
1650
1651html[data-theme-id="t3-chat"] .chat-composer-glass-shell {
1652 /* T3 Chat's visible composer edge is a dark plum, not the stock translucent
1653 white outline. Its highlight is derived from --chat-input-gradient. */
1654 @variant dark {
1655 --chat-composer-outline: #241e28;
1656 --chat-composer-highlight: color-mix(in srgb, #432d48 12%, transparent);
1657 }
1658}
1659
1660html[data-theme-id="t3-chat"]
1661 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
1662 :not([data-variant]),
1663 [data-variant="default"]
1664 ) {
1665 @variant dark {
1666 --chat-composer-attached-outline: #241e28;
1667 }
1668}
1669
1146/* Theme-token dependency probes are restored synchronously, before paint. Keep1670/* Theme-token dependency probes are restored synchronously, before paint. Keep
1147 transitions from observing the temporary sentinel color in between. */1671 transitions from observing the temporary sentinel color in between. */
1148html[data-theme-token-probe],1672html[data-theme-token-probe],
apps/web/src/session-logic.test.ts-28
...@@ -915,34 +915,6 @@ describe("workEntryIndicatesToolFailure", () => {...@@ -915,34 +915,6 @@ describe("workEntryIndicatesToolFailure", () => {
915});915});
916916
917describe("deriveWorkLogEntries", () => {917describe("deriveWorkLogEntries", () => {
918 it("keeps the latest task progress without emitting plan-update log entries", () => {
919 const activities = [
920 makeActivity({ id: "before", kind: "tool.completed", summary: "Read files", sequence: 0 }),
921 makeActivity({
922 id: "plan-1",
923 kind: "turn.plan.updated",
924 summary: "Plan updated",
925 turnId: "turn-1",
926 sequence: 1,
927 payload: { plan: [{ step: "Verify the composer", status: "inProgress" }] },
928 }),
929 makeActivity({
930 id: "plan-2",
931 kind: "turn.plan.updated",
932 summary: "Plan updated",
933 turnId: "turn-1",
934 sequence: 2,
935 payload: { plan: [{ step: "Verify the composer", status: "completed" }] },
936 }),
937 makeActivity({ id: "after", kind: "tool.completed", summary: "Ran tests", sequence: 3 }),
938 ];
939 expect(deriveWorkLogEntries(activities).map((entry) => entry.id)).toEqual(["before", "after"]);
940 expect(deriveTurnPlans(activities)).toHaveLength(1);
941 expect(deriveTurnPlans(activities)[0]?.plan.steps).toMatchObject([
942 { step: "Verify the composer", status: "completed" },
943 ]);
944 });
945
946 it("omits tool started entries and keeps completed entries", () => {918 it("omits tool started entries and keeps completed entries", () => {
947 const activities: OrchestrationThreadActivity[] = [919 const activities: OrchestrationThreadActivity[] = [
948 makeActivity({920 makeActivity({
apps/web/src/session-logic.ts-1
...@@ -880,7 +880,6 @@ export function deriveWorkLogEntries(...@@ -880,7 +880,6 @@ export function deriveWorkLogEntries(
880 if (activity.kind === "task.updated") continue;880 if (activity.kind === "task.updated") continue;
881 if (activity.kind === "tool.progress") continue;881 if (activity.kind === "tool.progress") continue;
882 if (activity.kind === "context-window.updated") continue;882 if (activity.kind === "context-window.updated") continue;
883 if (activity.kind === "turn.plan.updated") continue;
884 if (activity.summary === "Checkpoint captured") continue;883 if (activity.summary === "Checkpoint captured") continue;
885 if (isNoContentRuntimeWarning(activity)) continue;884 if (isNoContentRuntimeWarning(activity)) continue;
886 if (isPlanBoundaryToolActivity(activity)) continue;885 if (isPlanBoundaryToolActivity(activity)) continue;
apps/web/src/versionSkew.test.ts-36
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
43
5// Pinned so the direction cases below read as fixed versions instead of4// Pinned so the direction cases below read as fixed versions instead of
...@@ -11,9 +10,7 @@ import { APP_VERSION } from "./branding";...@@ -11,9 +10,7 @@ import { APP_VERSION } from "./branding";
11import {10import {
12 appendVersionMismatchHint,11 appendVersionMismatchHint,
13 buildVersionMismatchDismissalKey,12 buildVersionMismatchDismissalKey,
14 dismissServerUpdateFailure,
15 dismissVersionMismatch,13 dismissVersionMismatch,
16 isServerUpdateFailureDismissed,
17 isVersionMismatchDismissed,14 isVersionMismatchDismissed,
18 resolveServerConfigVersionMismatch,15 resolveServerConfigVersionMismatch,
19 resolveServerSelfUpdateCapability,16 resolveServerSelfUpdateCapability,
...@@ -29,39 +26,6 @@ describe("versionSkew", () => {...@@ -29,39 +26,6 @@ describe("versionSkew", () => {
29 branding.APP_VERSION = "0.0.34";26 branding.APP_VERSION = "0.0.34";
30 });27 });
3128
32 it("dismisses only the current failed attempt without clearing its retry state", () => {
33 const failure = {
34 status: "failed",
35 stage: "downloading",
36 fromVersion: "0.0.33",
37 targetVersion: "0.0.34",
38 message: "Download failed.",
39 } as const satisfies ServerUpdateState;
40 const retryFailure = { ...failure };
41 const otherEnvironmentFailure = { ...failure };
42
43 dismissServerUpdateFailure(failure);
44
45 expect(isServerUpdateFailureDismissed(failure)).toBe(true);
46 expect(failure.status).toBe("failed");
47 expect(failure.message).toBe("Download failed.");
48 expect(isServerUpdateFailureDismissed(retryFailure)).toBe(false);
49 expect(isServerUpdateFailureDismissed(otherEnvironmentFailure)).toBe(false);
50 });
51
52 it("does not dismiss an update that is still running", () => {
53 const running = {
54 status: "running",
55 stage: "resuming",
56 fromVersion: "0.0.33",
57 targetVersion: "0.0.34",
58 } as const satisfies ServerUpdateState;
59
60 dismissServerUpdateFailure(running);
61
62 expect(isServerUpdateFailureDismissed(running)).toBe(false);
63 });
64
65 it("does not warn when versions match", () => {29 it("does not warn when versions match", () => {
66 expect(resolveVersionMismatch(APP_VERSION)).toBeNull();30 expect(resolveVersionMismatch(APP_VERSION)).toBeNull();
67 });31 });
apps/web/src/versionSkew.ts-13
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
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";
3import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";2import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
4import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
54
...@@ -14,18 +13,6 @@ export interface VersionMismatch {...@@ -14,18 +13,6 @@ export interface VersionMismatch {
1413
15export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1";14export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1";
1615
17// Runtime failures retain their identity until the next attempt. Dismiss only
18// that attempt, across chat remounts, without clearing the error in Settings.
19const dismissedServerUpdateFailures = new WeakSet<ServerUpdateState>();
20
21export function isServerUpdateFailureDismissed(state: ServerUpdateState): boolean {
22 return state.status === "failed" && dismissedServerUpdateFailures.has(state);
23}
24
25export function dismissServerUpdateFailure(state: ServerUpdateState): void {
26 if (state.status === "failed") dismissedServerUpdateFailures.add(state);
27}
28
29const VersionMismatchDismissalsSchema = Schema.Struct({16const VersionMismatchDismissalsSchema = Schema.Struct({
30 keys: Schema.Array(Schema.String),17 keys: Schema.Array(Schema.String),
31});18});