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