Skip to content

Commit 7a63ebc

Browse files
juliusmarmingerynfar
authored andcommitted
fix(web): address composer banner review follow-ups (#8850)
(cherry picked from commit 9842518c9a0af14fb3a3e90993692a6e34032682)
1 parent b455a49 commit 7a63ebc

10 files changed

Lines changed: 77 additions & 24 deletions

apps/web/src/components/ChatView.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@ import {
206206
CheckCircle2Icon,
207207
ChevronDownIcon,
208208
GitBranchIcon,
209-
InfoIcon,
210209
Minimize2Icon,
211210
PaperclipIcon,
212211
WifiOffIcon,
@@ -449,7 +448,10 @@ import {
449448
} from "./ui/alert-dialog";
450449
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
451450
import { ServerUpdateAction } from "./ServerUpdateAction";
452-
import { ComposerServerUpdateStatus } from "./chat/ComposerServerUpdateStatus";
451+
import {
452+
ComposerServerUpdateIcon,
453+
ComposerServerUpdateStatus,
454+
} from "./chat/ComposerServerUpdateStatus";
453455
import {
454456
buildVersionMismatchDismissalKey,
455457
dismissServerUpdateFailure,
@@ -2396,7 +2398,7 @@ function ChatViewContent(props: ChatViewProps) {
23962398
variant: updateFailed ? "error" : "default",
23972399
// Prioritize update progress over passive notices, but keep activity attached.
23982400
priority: updateInProgress ? "urgent" : "notice",
2399-
icon: <InfoIcon aria-hidden />,
2401+
icon: <ComposerServerUpdateIcon status={serverUpdateState.status} />,
24002402
title:
24012403
updateInProgress || updateFailed ? (
24022404
<ComposerServerUpdateStatus

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -287,10 +287,9 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:
287287
const rect = (mainSurface ?? form ?? anchor).getBoundingClientRect();
288288
const rootFontSizePx =
289289
Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16;
290-
const drawerInsetRem =
291-
Number.parseFloat(
292-
window.getComputedStyle(form ?? anchor).getPropertyValue("--chat-composer-drawer-inset"),
293-
) || 1.375;
290+
const drawerInsetRem = Number.parseFloat(
291+
window.getComputedStyle(form ?? anchor).getPropertyValue("--chat-composer-drawer-inset"),
292+
);
294293
const drawerInset = drawerInsetRem * rootFontSizePx;
295294
// One extra pixel prevents fractional layout coordinates from exposing
296295
// the canvas between the drawer mask and the composer's foreground edge.
@@ -3615,7 +3614,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
36153614
}
36163615
: null;
36173616
const bannerStackItems = activityStackItem
3618-
? [...props.bannerItems, activityStackItem]
3617+
? [activityStackItem, ...props.bannerItems]
36193618
: props.bannerItems;
36203619
useEffect(() => {
36213620
if (activeTasksProgress === null || activeTaskSteps === null) {

apps/web/src/components/chat/ComposerBanner.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
22

33
import { cn } from "~/lib/utils";
44
import { 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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
2+
import { CircleAlertIcon, InfoIcon, LoaderCircleIcon } from "lucide-react";
23
import { useId, useState } from "react";
34

45
import { serverUpdateStageLabel } from "../ServerUpdateAction";
56
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
67
import { ComposerBanner } from "./ComposerBanner";
78

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+
823
/** One text line, clipped at the end so the error detail never squeezes its title. */
924
export function ComposerServerUpdateStatus({
1025
state,

apps/web/src/components/chat/ComposerStashMenu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ export const ComposerTasksContent = memo(function ComposerTasksContent({
264264
{expanded ? (
265265
<ComposerBanner.Scroll data-composer-tasks-scroll="true">
266266
<ComposerBanner.Children
267-
render={<ul />}
267+
render={<ul role="list" />}
268268
aria-label={`Task list. ${progress.completedSteps} of ${progress.totalSteps} complete.`}
269269
data-composer-tasks-list="true"
270270
>

docs/user/composer.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ On mobile, the model picker shows each OpenCode model's upstream provider, such
2727
GitHub Copilot, or OpenCode Zen, beneath its name. Search by that provider name to narrow the list
2828
when starting a thread or changing an existing thread's model.
2929

30+
## Notices above the composer
31+
32+
On web and desktop, additional notices peek out above the attached banner. Hover over the peek
33+
to reveal them, or focus **Show other notices** with `Tab` and press `Enter` or `Space`. Press
34+
`Escape` to close the stack and return focus to that control. On a touchscreen, tap the peek to
35+
open the stack. Interacting with the attached banner or composer does not open the stack.
36+
3037
## Prompt stash
3138

3239
Use the default shortcut, `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux, to stash the current

0 commit comments

Comments
 (0)