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