authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-30 21:37:12-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-30 21:37:12-07:00
log9842518c9a0af14fb3a3e90993692a6e34032682
treea02bf4d11a48cf8b605cd2f60b823a8bd438f875
parent30175a8af04c0daa359652b5e8dc8230b40b462a
signature Signed by PGP key B5690EEEBB952194

fix(web): address composer banner review follow-ups (#8850)


10 files changed, 77 insertions(+), 24 deletions(-)

apps/web/src/components/ChatView.tsx+5-3
......@@ -188,7 +188,6 @@ import {
188188 CheckCircle2Icon,
189189 ChevronDownIcon,
190190 GitBranchIcon,
191 InfoIcon,
192191 Minimize2Icon,
193192 PaperclipIcon,
194193 WifiOffIcon,
......@@ -410,7 +409,10 @@ import {
410409} from "./ui/alert-dialog";
411410import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
412411import { ServerUpdateAction } from "./ServerUpdateAction";
413import { ComposerServerUpdateStatus } from "./chat/ComposerServerUpdateStatus";
412import {
413 ComposerServerUpdateIcon,
414 ComposerServerUpdateStatus,
415} from "./chat/ComposerServerUpdateStatus";
414416import {
415417 buildVersionMismatchDismissalKey,
416418 dismissServerUpdateFailure,
......@@ -2274,7 +2276,7 @@ function ChatViewContent(props: ChatViewProps) {
22742276 variant: updateFailed ? "error" : "default",
22752277 // Prioritize update progress over passive notices, but keep activity attached.
22762278 priority: updateInProgress ? "urgent" : "notice",
2277 icon: <InfoIcon aria-hidden />,
2279 icon: <ComposerServerUpdateIcon status={serverUpdateState.status} />,
22782280 title:
22792281 updateInProgress || updateFailed ? (
22802282 <ComposerServerUpdateStatus
apps/web/src/components/chat/ChatComposer.tsx+4-5
......@@ -228,10 +228,9 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:
228228 const rect = (mainSurface ?? form ?? anchor).getBoundingClientRect();
229229 const rootFontSizePx =
230230 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 );
235234 const drawerInset = drawerInsetRem * rootFontSizePx;
236235 // One extra pixel prevents fractional layout coordinates from exposing
237236 // the canvas between the drawer mask and the composer's foreground edge.
......@@ -2899,7 +2898,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28992898 }
29002899 : null;
29012900 const bannerStackItems = activityStackItem
2902 ? [...props.bannerItems, activityStackItem]
2901 ? [activityStackItem, ...props.bannerItems]
29032902 : props.bannerItems;
29042903 useEffect(() => {
29052904 if (activeTasksProgress === null || activeTaskSteps === null) {
apps/web/src/components/chat/ComposerBanner.tsx+6-2
......@@ -105,7 +105,7 @@ function Attachment({ className, ...props }: ComponentProps<"div">) {
105105 <div
106106 data-slot="composer-banner-attachment"
107107 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))]",
109109 // Adjacent attachments share their outline, including notices outside the form.
110110 "[&+[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",
111111 "[&+: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
311311 return (
312312 <span
313313 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 )}
315319 >
316320 <ChevronDownIcon className={cn("size-3.5", !expanded && "rotate-180")} />
317321 </span>
apps/web/src/components/chat/ComposerBannerStack.tsx+34-8
......@@ -1,4 +1,4 @@
1import { useEffect, useId, useRef, useState, type ReactNode } from "react";
1import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
22
33import { cn } from "~/lib/utils";
44import { ComposerBanner, type ComposerBannerVariant } from "./ComposerBanner";
......@@ -46,6 +46,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
4646 const [stackExpanded, setStackExpanded] = useState(false);
4747 const noticesRef = useRef<HTMLDivElement>(null);
4848 const peekRef = useRef<HTMLButtonElement>(null);
49 const expandedItemsRef = useRef<HTMLDivElement>(null);
50 const pendingFocusRef = useRef<"peek" | "notice" | null>(null);
4951 const expandedItemsId = useId();
5052 const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null);
5153 const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
......@@ -66,6 +68,19 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
6668 if (items.length < 2) setStackExpanded(false);
6769 }, [items.length]);
6870
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
6984 if (items.length === 0) {
7085 return null;
7186 }
......@@ -130,14 +145,17 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
130145 {hasStack ? (
131146 <div
132147 ref={noticesRef}
133 className="relative z-20"
148 className={cn("relative z-20", stackExpanded && "min-h-3")}
134149 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);
136155 }}
137156 onPointerLeave={(event) => {
138157 if (!event.currentTarget.contains(document.activeElement)) setStackExpanded(false);
139158 }}
140 onFocusCapture={() => setStackExpanded(true)}
141159 onBlurCapture={(event) => {
142160 if (
143161 !event.currentTarget.contains(event.relatedTarget) &&
......@@ -147,9 +165,10 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
147165 }
148166 }}
149167 onKeyDown={(event) => {
150 if (event.key !== "Escape") return;
168 if (event.key !== "Escape" || !stackExpanded) return;
169 event.preventDefault();
151170 event.stopPropagation();
152 peekRef.current?.focus({ preventScroll: true });
171 pendingFocusRef.current = "peek";
153172 setStackExpanded(false);
154173 }}
155174 >
......@@ -160,18 +179,25 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
160179 aria-label="Show other notices"
161180 aria-expanded={stackExpanded}
162181 aria-controls={expandedItemsId}
182 aria-hidden={stackExpanded || undefined}
183 tabIndex={stackExpanded ? -1 : 0}
163184 onClick={(event) => {
164185 event.currentTarget.focus({ preventScroll: true });
186 pendingFocusRef.current = "notice";
165187 setStackExpanded(true);
166188 }}
167 className={cn(stackExpanded && "opacity-0")}
189 className={cn(stackExpanded && "pointer-events-none invisible opacity-0")}
168190 />
169191 ) : null}
170192 <div
171193 id={expandedItemsId}
194 ref={expandedItemsRef}
195 role="group"
196 aria-label="Other notices"
197 tabIndex={-1}
172198 data-composer-banner-stack-expanded-items="true"
173199 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",
175201 stackExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
176202 )}
177203 >
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+1-1
......@@ -201,7 +201,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
201201 </ComposerBanner.Actions>
202202 </CollapsibleTrigger>
203203 <CollapsiblePanel>
204 <ComposerBanner.Body>
204 <ComposerBanner.Body className="pe-1 pb-1">
205205 <p className="text-sm text-foreground/85">{activeQuestion.question}</p>
206206 {activeQuestion.multiSelect ? (
207207 <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 @@
11import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
2import { CircleAlertIcon, InfoIcon, LoaderCircleIcon } from "lucide-react";
23import { useId, useState } from "react";
34
45import { serverUpdateStageLabel } from "../ServerUpdateAction";
56import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
67import { ComposerBanner } from "./ComposerBanner";
78
9export 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
823/** One text line, clipped at the end so the error detail never squeezes its title. */
924export function ComposerServerUpdateStatus({
1025 state,
apps/web/src/components/chat/ComposerStashMenu.tsx+1-1
......@@ -130,7 +130,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
130130 </ComposerBanner.Actions>
131131 </ComposerBanner.Row>
132132 <ComposerBanner.Scroll>
133 <ComposerBanner.Children render={<ul />} aria-label="Stashed prompts">
133 <ComposerBanner.Children render={<ul role="list" />} aria-label="Stashed prompts">
134134 {entries.length === 0 ? (
135135 <ComposerBanner.Row render={<li />}>
136136 <ComposerBanner.Icon />
apps/web/src/components/chat/ComposerSurface.tsx+3-3
......@@ -14,7 +14,7 @@ function Shell({
1414 data-with-context={contextStrip || undefined}
1515 className={cn(
1616 "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%)]",
1818 "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)]",
1919 "[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)]",
2020 "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({
2626 "[--chat-composer-context-extension:2.25rem] sm:[--chat-composer-context-extension:2rem]",
2727 // Keep one continuous backdrop around the fixed-pixel corners and rem-sized strip inset.
2828 "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)]",
3030 "not-supports-[clip-path:shape(from_0_0,line_to_1px_1px)]:before:bottom-(--chat-composer-context-extension)",
3131 ],
3232 className,
......@@ -84,7 +84,7 @@ function ContextStrip({ className, ...props }: ComponentProps<"div">) {
8484 <div
8585 data-slot="composer-context-strip"
8686 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",
8888 "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%)]",
8989 "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%)]",
9090 "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({
153153 {expanded ? (
154154 <ComposerBanner.Scroll data-composer-tasks-scroll="true">
155155 <ComposerBanner.Children
156 render={<ul />}
156 render={<ul role="list" />}
157157 aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`}
158158 data-composer-tasks-list="true"
159159 >
docs/user/composer.md+7
......@@ -35,6 +35,13 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such
3535GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
3636when starting a thread or changing an existing thread's model.
3737
38## Notices above the composer
39
40On web and desktop, additional notices peek out above the attached banner. Hover over the peek
41to 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
43open the stack. Interacting with the attached banner or composer does not open the stack.
44
3845## Prompt stash
3946
4047Use the default shortcut, `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux, to stash the current