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