Skip to content

Commit deb6712

Browse files
committed
feat(web): attach composer state drawers
1 parent 31264f9 commit deb6712

20 files changed

Lines changed: 1057 additions & 705 deletions

apps/web/src/components/ChatView.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4457,19 +4457,20 @@ function ChatViewContent(props: ChatViewProps) {
44574457
),
44584458
title: working
44594459
? liveCount > 0
4460-
? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background`
4461-
: "Background work running"
4462-
: "Monitoring in the background",
4460+
? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working`
4461+
: "Background work"
4462+
: "Monitoring",
44634463
actions: (
44644464
<Button
4465-
size="xs"
4466-
variant="outline"
4465+
size="micro"
4466+
variant="ghost-muted"
44674467
disabled={isStoppingBackgroundWork}
44684468
onClick={() => void handleStopBackgroundWork()}
44694469
>
44704470
{isStoppingBackgroundWork ? "Stopping..." : "Stop"}
44714471
</Button>
44724472
),
4473+
className: "px-3 pt-1.5 pb-[1.375rem] text-xs",
44734474
};
44744475
}, [
44754476
activeBackgroundLiveness,
@@ -6398,10 +6399,18 @@ function ChatViewContent(props: ChatViewProps) {
63986399
activeProjectTitle={activeProject?.title ?? null}
63996400
/>
64006401
</div>
6401-
<ComposerBannerStack className="relative z-0" items={composerBannerItems} />
6402+
<ComposerBannerStack
6403+
attached={!threadSyncPhase || activeEnvironmentUnavailable}
6404+
className="relative z-0"
6405+
items={composerBannerItems}
6406+
/>
64026407
</div>
64036408
) : (
6404-
<ComposerBannerStack className="relative z-0" items={composerBannerItems} />
6409+
<ComposerBannerStack
6410+
attached={!threadSyncPhase || activeEnvironmentUnavailable}
6411+
className="relative z-0"
6412+
items={composerBannerItems}
6413+
/>
64056414
)}
64066415
{threadSyncPhase && !activeEnvironmentUnavailable ? (
64076416
<ThreadSyncStatusPill phase={threadSyncPhase} />

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

Lines changed: 478 additions & 461 deletions
Large diffs are not rendered by default.

apps/web/src/components/chat/ComposerBannerStack.test.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,24 @@ describe("ComposerBannerStack", () => {
4949
const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />);
5050

5151
expect(markup).not.toContain("data-composer-banner-stack-expanded-items");
52-
expect(markup).toContain("alert-glass");
52+
expect(markup).toContain("chat-composer-drawer-surface");
53+
expect(markup).toContain("chat-composer-drawer-attached");
54+
expect(markup).toContain('data-composer-banner-drawer="true"');
5355
expect(markup).toContain('data-variant="warning"');
5456
expect(markup).toContain("transform:none");
5557
expect(markup).not.toContain("will-change:transform");
5658
});
5759

60+
it("renders a detached banner when another composer status sits below it", () => {
61+
const markup = renderToStaticMarkup(
62+
<ComposerBannerStack attached={false} items={[banner("front")]} />,
63+
);
64+
65+
expect(markup).toContain("alert-glass");
66+
expect(markup).not.toContain("chat-composer-drawer-slot");
67+
expect(markup).not.toContain("chat-composer-drawer-attached");
68+
});
69+
5870
it("applies item-specific surface and action layout classes", () => {
5971
const markup = renderToStaticMarkup(
6072
<ComposerBannerStack

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

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,16 @@ export interface ComposerBannerStackItem {
5050
}
5151

5252
interface ComposerBannerStackProps {
53+
readonly attached?: boolean;
5354
readonly className?: string;
5455
readonly items: ReadonlyArray<ComposerBannerStackItem>;
5556
}
5657

57-
export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) {
58+
export function ComposerBannerStack({
59+
attached = true,
60+
className,
61+
items,
62+
}: ComposerBannerStackProps) {
5863
const [requestedExitingItemId, setExitingItemId] = useState<string | null>(null);
5964
const dismissTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
6065
const exitingItemId =
@@ -98,7 +103,14 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
98103
};
99104

100105
return (
101-
<div className={cn("group/banner-stack mx-auto mb-2 max-w-3xl", className)}>
106+
<div
107+
className={cn(
108+
"group/banner-stack max-w-[calc(48rem-2.75rem)]",
109+
attached ? "chat-composer-drawer-slot" : "mx-auto mb-2 w-[calc(100%-2.75rem)]",
110+
className,
111+
)}
112+
data-composer-banner-drawer="true"
113+
>
102114
<div
103115
className={cn(
104116
"relative flex flex-col-reverse",
@@ -108,7 +120,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
108120
{showCollapsedStackCap && firstStackedItem ? (
109121
<div
110122
className={cn(
111-
"pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-[22px]",
123+
"pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-2xl",
112124
"border border-b-0 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",
113125
stackCapBorderClass[firstStackedItem.variant],
114126
"transition-opacity duration-150 ease-out",
@@ -130,6 +142,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
130142
>
131143
<ComposerBannerStackAlert
132144
item={frontItem}
145+
attached={attached}
133146
exiting={exitingItemId === frontItem.id}
134147
onDismissRequest={() => requestDismiss(frontItem)}
135148
/>
@@ -162,6 +175,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
162175
>
163176
<ComposerBannerStackAlert
164177
item={item}
178+
attached={false}
165179
exiting={exitingItemId === item.id}
166180
onDismissRequest={() => requestDismiss(item)}
167181
/>
@@ -178,10 +192,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
178192

179193
function ComposerBannerStackAlert({
180194
item,
195+
attached,
181196
exiting,
182197
onDismissRequest,
183198
}: {
184199
readonly item: ComposerBannerStackItem;
200+
readonly attached: boolean;
185201
readonly exiting: boolean;
186202
readonly onDismissRequest: () => void;
187203
}) {
@@ -190,7 +206,12 @@ function ComposerBannerStackAlert({
190206
return (
191207
<Alert
192208
variant={item.variant}
193-
className={cn("alert-glass rounded-[22px]", item.className)}
209+
className={cn(
210+
attached
211+
? "chat-composer-drawer-surface chat-composer-drawer-attached px-3 pt-2"
212+
: "alert-glass rounded-[22px]",
213+
item.className,
214+
)}
194215
data-variant={item.variant}
195216
>
196217
{item.icon}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { renderToStaticMarkup } from "react-dom/server";
2+
import { describe, expect, it } from "vite-plus/test";
3+
4+
import { ComposerCommandMenu } from "./ComposerCommandMenu";
5+
6+
describe("ComposerCommandMenu", () => {
7+
it("renders slash-command results as an attached composer drawer", () => {
8+
const markup = renderToStaticMarkup(
9+
<ComposerCommandMenu
10+
items={[]}
11+
resolvedTheme="dark"
12+
isLoading={false}
13+
triggerKind="slash-command"
14+
activeItemId={null}
15+
onHighlightedItemChange={() => {}}
16+
onSelect={() => {}}
17+
/>,
18+
);
19+
20+
expect(markup).toContain('data-composer-command-drawer="true"');
21+
expect(markup).toContain("chat-composer-drawer-surface");
22+
expect(markup).toContain("chat-composer-drawer-attached");
23+
expect(markup).not.toContain("dropdown-glass");
24+
});
25+
26+
it("renders commands without a category heading or invented icons", () => {
27+
const markup = renderToStaticMarkup(
28+
<ComposerCommandMenu
29+
items={[
30+
{
31+
id: "slash:model",
32+
type: "slash-command",
33+
command: "model",
34+
label: "/model",
35+
description: "Switch response model for this thread",
36+
},
37+
]}
38+
resolvedTheme="dark"
39+
isLoading={false}
40+
triggerKind="slash-command"
41+
activeItemId="slash:model"
42+
onHighlightedItemChange={() => {}}
43+
onSelect={() => {}}
44+
/>,
45+
);
46+
47+
expect(markup).toContain("/model");
48+
expect(markup).toContain("Switch response model for this thread");
49+
expect(markup).not.toContain("Built-in");
50+
expect(markup).not.toContain("<svg");
51+
expect(markup).toContain("font-sans text-xs font-medium");
52+
expect(markup).not.toContain("font-mono");
53+
expect(markup).toContain("text-right");
54+
});
55+
});

0 commit comments

Comments
 (0)