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