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