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

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


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

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