| author | |
| committer | |
| log | 9842518c9a0af14fb3a3e90993692a6e34032682 |
| tree | a02bf4d11a48cf8b605cd2f60b823a8bd438f875 |
| parent | 30175a8af04c0daa359652b5e8dc8230b40b462a |
| signature | Signed by PGP key B5690EEEBB952194 |
10 files changed, 77 insertions(+), 24 deletions(-)
apps/web/src/components/ChatView.tsx+5-3| ... | ... | @@ -188,7 +188,6 @@ import { |
| 188 | 188 | CheckCircle2Icon, |
| 189 | 189 | ChevronDownIcon, |
| 190 | 190 | GitBranchIcon, |
| 191 | InfoIcon, | |
| 192 | 191 | Minimize2Icon, |
| 193 | 192 | PaperclipIcon, |
| 194 | 193 | WifiOffIcon, |
| ... | ... | @@ -410,7 +409,10 @@ import { |
| 410 | 409 | } from "./ui/alert-dialog"; |
| 411 | 410 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 412 | 411 | import { ServerUpdateAction } from "./ServerUpdateAction"; |
| 413 | import { ComposerServerUpdateStatus } from "./chat/ComposerServerUpdateStatus"; | |
| 412 | import { | |
| 413 | ComposerServerUpdateIcon, | |
| 414 | ComposerServerUpdateStatus, | |
| 415 | } from "./chat/ComposerServerUpdateStatus"; | |
| 414 | 416 | import { |
| 415 | 417 | buildVersionMismatchDismissalKey, |
| 416 | 418 | dismissServerUpdateFailure, |
| ... | ... | @@ -2274,7 +2276,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 2274 | 2276 | variant: updateFailed ? "error" : "default", |
| 2275 | 2277 | // Prioritize update progress over passive notices, but keep activity attached. |
| 2276 | 2278 | priority: updateInProgress ? "urgent" : "notice", |
| 2277 | icon: <InfoIcon aria-hidden />, | |
| 2279 | icon: <ComposerServerUpdateIcon status={serverUpdateState.status} />, | |
| 2278 | 2280 | title: |
| 2279 | 2281 | updateInProgress || updateFailed ? ( |
| 2280 | 2282 | <ComposerServerUpdateStatus |
apps/web/src/components/chat/ChatComposer.tsx+4-5| ... | ... | @@ -228,10 +228,9 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: |
| 228 | 228 | const rect = (mainSurface ?? form ?? anchor).getBoundingClientRect(); |
| 229 | 229 | const rootFontSizePx = |
| 230 | 230 | Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16; |
| 231 | const drawerInsetRem = | |
| 232 | Number.parseFloat( | |
| 233 | window.getComputedStyle(form ?? anchor).getPropertyValue("--chat-composer-drawer-inset"), | |
| 234 | ) || 1.375; | |
| 231 | const drawerInsetRem = Number.parseFloat( | |
| 232 | window.getComputedStyle(form ?? anchor).getPropertyValue("--chat-composer-drawer-inset"), | |
| 233 | ); | |
| 235 | 234 | const drawerInset = drawerInsetRem * rootFontSizePx; |
| 236 | 235 | // One extra pixel prevents fractional layout coordinates from exposing |
| 237 | 236 | // the canvas between the drawer mask and the composer's foreground edge. |
| ... | ... | @@ -2899,7 +2898,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 2899 | 2898 | } |
| 2900 | 2899 | : null; |
| 2901 | 2900 | const bannerStackItems = activityStackItem |
| 2902 | ? [...props.bannerItems, activityStackItem] | |
| 2901 | ? [activityStackItem, ...props.bannerItems] | |
| 2903 | 2902 | : props.bannerItems; |
| 2904 | 2903 | useEffect(() => { |
| 2905 | 2904 | if (activeTasksProgress === null || activeTaskSteps === null) { |
apps/web/src/components/chat/ComposerBanner.tsx+6-2| ... | ... | @@ -105,7 +105,7 @@ function Attachment({ className, ...props }: ComponentProps<"div">) { |
| 105 | 105 | <div |
| 106 | 106 | data-slot="composer-banner-attachment" |
| 107 | 107 | className={cn( |
| 108 | "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2.75rem)] max-w-[45.25rem]", | |
| 108 | "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2*var(--chat-composer-drawer-inset))]", | |
| 109 | 109 | // Adjacent attachments share their outline, including notices outside the form. |
| 110 | 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 | 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", |
| ... | ... | @@ -311,7 +311,11 @@ function ToggleIcon({ expanded, className }: { expanded: boolean; className?: st |
| 311 | 311 | return ( |
| 312 | 312 | <span |
| 313 | 313 | aria-hidden |
| 314 | className={cn(buttonVariants({ size: "icon-xs", variant: "ghost" }), className)} | |
| 314 | className={cn( | |
| 315 | buttonVariants({ size: "icon-xs", variant: "ghost" }), | |
| 316 | "pointer-events-none", | |
| 317 | className, | |
| 318 | )} | |
| 315 | 319 | > |
| 316 | 320 | <ChevronDownIcon className={cn("size-3.5", !expanded && "rotate-180")} /> |
| 317 | 321 | </span> |
apps/web/src/components/chat/ComposerBannerStack.tsx+34-8| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { useEffect, useId, useRef, useState, type ReactNode } from "react"; | |
| 1 | import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react"; | |
| 2 | 2 | |
| 3 | 3 | import { cn } from "~/lib/utils"; |
| 4 | 4 | import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner"; |
| ... | ... | @@ -46,6 +46,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 46 | 46 | const [stackExpanded, setStackExpanded] = useState(false); |
| 47 | 47 | const noticesRef = useRef<HTMLDivElement>(null); |
| 48 | 48 | const peekRef = useRef<HTMLButtonElement>(null); |
| 49 | const expandedItemsRef = useRef<HTMLDivElement>(null); | |
| 50 | const pendingFocusRef = useRef<"peek" | "notice" | null>(null); | |
| 49 | 51 | const expandedItemsId = useId(); |
| 50 | 52 | const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null); |
| 51 | 53 | const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| ... | ... | @@ -66,6 +68,19 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 66 | 68 | if (items.length < 2) setStackExpanded(false); |
| 67 | 69 | }, [items.length]); |
| 68 | 70 | |
| 71 | useLayoutEffect(() => { | |
| 72 | if (stackExpanded && pendingFocusRef.current === "notice") { | |
| 73 | pendingFocusRef.current = null; | |
| 74 | const firstControl = expandedItemsRef.current?.querySelector<HTMLElement>( | |
| 75 | 'button:not(:disabled), a[href], input:not(:disabled), [tabindex="0"]', | |
| 76 | ); | |
| 77 | (firstControl ?? expandedItemsRef.current)?.focus({ preventScroll: true }); | |
| 78 | } else if (!stackExpanded && pendingFocusRef.current === "peek") { | |
| 79 | pendingFocusRef.current = null; | |
| 80 | peekRef.current?.focus({ preventScroll: true }); | |
| 81 | } | |
| 82 | }, [stackExpanded]); | |
| 83 | ||
| 69 | 84 | if (items.length === 0) { |
| 70 | 85 | return null; |
| 71 | 86 | } |
| ... | ... | @@ -130,14 +145,17 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 130 | 145 | {hasStack ? ( |
| 131 | 146 | <div |
| 132 | 147 | ref={noticesRef} |
| 133 | className="relative z-20" | |
| 148 | className={cn("relative z-20", stackExpanded && "min-h-3")} | |
| 134 | 149 | onPointerEnter={(event) => { |
| 135 | if (event.pointerType !== "touch") setStackExpanded(true); | |
| 150 | if (event.pointerType === "touch") return; | |
| 151 | if (document.activeElement === peekRef.current) { | |
| 152 | pendingFocusRef.current = "notice"; | |
| 153 | } | |
| 154 | setStackExpanded(true); | |
| 136 | 155 | }} |
| 137 | 156 | onPointerLeave={(event) => { |
| 138 | 157 | if (!event.currentTarget.contains(document.activeElement)) setStackExpanded(false); |
| 139 | 158 | }} |
| 140 | onFocusCapture={() => setStackExpanded(true)} | |
| 141 | 159 | onBlurCapture={(event) => { |
| 142 | 160 | if ( |
| 143 | 161 | !event.currentTarget.contains(event.relatedTarget) && |
| ... | ... | @@ -147,9 +165,10 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 147 | 165 | } |
| 148 | 166 | }} |
| 149 | 167 | onKeyDown={(event) => { |
| 150 | if (event.key !== "Escape") return; | |
| 168 | if (event.key !== "Escape" || !stackExpanded) return; | |
| 169 | event.preventDefault(); | |
| 151 | 170 | event.stopPropagation(); |
| 152 | peekRef.current?.focus({ preventScroll: true }); | |
| 171 | pendingFocusRef.current = "peek"; | |
| 153 | 172 | setStackExpanded(false); |
| 154 | 173 | }} |
| 155 | 174 | > |
| ... | ... | @@ -160,18 +179,25 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro |
| 160 | 179 | aria-label="Show other notices" |
| 161 | 180 | aria-expanded={stackExpanded} |
| 162 | 181 | aria-controls={expandedItemsId} |
| 182 | aria-hidden={stackExpanded || undefined} | |
| 183 | tabIndex={stackExpanded ? -1 : 0} | |
| 163 | 184 | onClick={(event) => { |
| 164 | 185 | event.currentTarget.focus({ preventScroll: true }); |
| 186 | pendingFocusRef.current = "notice"; | |
| 165 | 187 | setStackExpanded(true); |
| 166 | 188 | }} |
| 167 | className={cn(stackExpanded && "opacity-0")} | |
| 189 | className={cn(stackExpanded && "pointer-events-none invisible opacity-0")} | |
| 168 | 190 | /> |
| 169 | 191 | ) : null} |
| 170 | 192 | <div |
| 171 | 193 | id={expandedItemsId} |
| 194 | ref={expandedItemsRef} | |
| 195 | role="group" | |
| 196 | aria-label="Other notices" | |
| 197 | tabIndex={-1} | |
| 172 | 198 | data-composer-banner-stack-expanded-items="true" |
| 173 | 199 | className={cn( |
| 174 | "grid transition-[grid-template-rows] duration-150 ease-out", | |
| 200 | "grid transition-[grid-template-rows] duration-150 ease-out focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring", | |
| 175 | 201 | stackExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]", |
| 176 | 202 | )} |
| 177 | 203 | > |
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+1-1| ... | ... | @@ -201,7 +201,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( |
| 201 | 201 | </ComposerBanner.Actions> |
| 202 | 202 | </CollapsibleTrigger> |
| 203 | 203 | <CollapsiblePanel> |
| 204 | <ComposerBanner.Body> | |
| 204 | <ComposerBanner.Body className="pe-1 pb-1"> | |
| 205 | 205 | <p className="text-sm text-foreground/85">{activeQuestion.question}</p> |
| 206 | 206 | {activeQuestion.multiSelect ? ( |
| 207 | 207 | <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p> |
apps/web/src/components/chat/ComposerServerUpdateStatus.tsx+15| ... | ... | @@ -1,10 +1,25 @@ |
| 1 | 1 | import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; |
| 2 | import { CircleAlertIcon, InfoIcon, LoaderCircleIcon } from "lucide-react"; | |
| 2 | 3 | import { useId, useState } from "react"; |
| 3 | 4 | |
| 4 | 5 | import { serverUpdateStageLabel } from "../ServerUpdateAction"; |
| 5 | 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 6 | 7 | import { ComposerBanner } from "./ComposerBanner"; |
| 7 | 8 | |
| 9 | export function ComposerServerUpdateIcon({ | |
| 10 | status, | |
| 11 | }: { | |
| 12 | readonly status: ServerUpdateState["status"]; | |
| 13 | }) { | |
| 14 | if (status === "running") { | |
| 15 | return <LoaderCircleIcon aria-hidden className="motion-safe:animate-spin" />; | |
| 16 | } | |
| 17 | if (status === "failed") { | |
| 18 | return <CircleAlertIcon aria-hidden className="text-error" />; | |
| 19 | } | |
| 20 | return <InfoIcon aria-hidden />; | |
| 21 | } | |
| 22 | ||
| 8 | 23 | /** One text line, clipped at the end so the error detail never squeezes its title. */ |
| 9 | 24 | export function ComposerServerUpdateStatus({ |
| 10 | 25 | state, |
apps/web/src/components/chat/ComposerStashMenu.tsx+1-1| ... | ... | @@ -130,7 +130,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { |
| 130 | 130 | </ComposerBanner.Actions> |
| 131 | 131 | </ComposerBanner.Row> |
| 132 | 132 | <ComposerBanner.Scroll> |
| 133 | <ComposerBanner.Children render={<ul />} aria-label="Stashed prompts"> | |
| 133 | <ComposerBanner.Children render={<ul role="list" />} aria-label="Stashed prompts"> | |
| 134 | 134 | {entries.length === 0 ? ( |
| 135 | 135 | <ComposerBanner.Row render={<li />}> |
| 136 | 136 | <ComposerBanner.Icon /> |
apps/web/src/components/chat/ComposerSurface.tsx+3-3| ... | ... | @@ -14,7 +14,7 @@ function Shell({ |
| 14 | 14 | data-with-context={contextStrip || undefined} |
| 15 | 15 | className={cn( |
| 16 | 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%)]", | |
| 17 | "[--chat-composer-drawer-inset:1.375rem] [--chat-composer-glass-surface:var(--card)] [--chat-composer-outline:rgb(0_0_0/8%)]", | |
| 18 | 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 | 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 | 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))]", |
| ... | ... | @@ -26,7 +26,7 @@ function Shell({ |
| 26 | 26 | "[--chat-composer-context-extension:2.25rem] sm:[--chat-composer-context-extension:2rem]", |
| 27 | 27 | // Keep one continuous backdrop around the fixed-pixel corners and rem-sized strip inset. |
| 28 | 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)]", | |
| 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)-var(--chat-composer-drawer-inset)),curve_to_calc(100%-var(--chat-composer-drawer-inset))_calc(100%-var(--chat-composer-context-extension))_with_100%_calc(100%-var(--chat-composer-context-extension)-var(--chat-composer-drawer-inset)*0.4477)/calc(100%-var(--chat-composer-drawer-inset)*0.4477)_calc(100%-var(--chat-composer-context-extension)),line_to_calc(100%-var(--chat-composer-drawer-inset))_calc(100%-16px),curve_to_calc(100%-var(--chat-composer-drawer-inset)-16px)_100%_with_calc(100%-var(--chat-composer-drawer-inset))_calc(100%-7.16px)/calc(100%-var(--chat-composer-drawer-inset)-7.16px)_100%,line_to_calc(var(--chat-composer-drawer-inset)+16px)_100%,curve_to_var(--chat-composer-drawer-inset)_calc(100%-16px)_with_calc(var(--chat-composer-drawer-inset)+7.16px)_100%/var(--chat-composer-drawer-inset)_calc(100%-7.16px),line_to_var(--chat-composer-drawer-inset)_calc(100%-var(--chat-composer-context-extension)),curve_to_0_calc(100%-var(--chat-composer-context-extension)-var(--chat-composer-drawer-inset))_with_calc(var(--chat-composer-drawer-inset)*0.4477)_calc(100%-var(--chat-composer-context-extension))/0_calc(100%-var(--chat-composer-context-extension)-var(--chat-composer-drawer-inset)*0.4477),line_to_0_22px,close)]", | |
| 30 | 30 | "not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:bottom-(--chat-composer-context-extension)", |
| 31 | 31 | ], |
| 32 | 32 | className, |
| ... | ... | @@ -84,7 +84,7 @@ function ContextStrip({ className, ...props }: ComponentProps<"div">) { |
| 84 | 84 | <div |
| 85 | 85 | data-slot="composer-context-strip" |
| 86 | 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", | |
| 87 | "group/composer-context relative isolate mx-auto -mt-4 flex w-[calc(100%-2*var(--chat-composer-drawer-inset))] items-center gap-2 overflow-x-clip overflow-y-visible ps-1 pe-2 pt-5 pb-1", | |
| 88 | 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 | 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 | 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)", |
apps/web/src/components/chat/ComposerTasksBadge.tsx+1-1| ... | ... | @@ -153,7 +153,7 @@ export const ComposerTasksContent = memo(function ComposerTasksContent({ |
| 153 | 153 | {expanded ? ( |
| 154 | 154 | <ComposerBanner.Scroll data-composer-tasks-scroll="true"> |
| 155 | 155 | <ComposerBanner.Children |
| 156 | render={<ul />} | |
| 156 | render={<ul role="list" />} | |
| 157 | 157 | aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`} |
| 158 | 158 | data-composer-tasks-list="true" |
| 159 | 159 | > |
docs/user/composer.md+7| ... | ... | @@ -35,6 +35,13 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such |
| 35 | 35 | GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list |
| 36 | 36 | when starting a thread or changing an existing thread's model. |
| 37 | 37 | |
| 38 | ## Notices above the composer | |
| 39 | ||
| 40 | On web and desktop, additional notices peek out above the attached banner. Hover over the peek | |
| 41 | to reveal them, or focus **Show other notices** with `Tab` and press `Enter` or `Space`. Press | |
| 42 | `Escape` to close the stack and return focus to that control. On a touchscreen, tap the peek to | |
| 43 | open the stack. Interacting with the attached banner or composer does not open the stack. | |
| 44 | ||
| 38 | 45 | ## Prompt stash |
| 39 | 46 | |
| 40 | 47 | Use the default shortcut, `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux, to stash the current |