Skip to content

Commit a348bae

Browse files
committed
feat(web): attach composer state drawers
1 parent e4900e7 commit a348bae

19 files changed

Lines changed: 982 additions & 699 deletions

apps/web/src/components/ChatView.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4457,19 +4457,21 @@ 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
44654465
size="xs"
4466-
variant="outline"
4466+
variant="ghost-muted"
4467+
className="h-5 px-1.5 text-[11px]"
44674468
disabled={isStoppingBackgroundWork}
44684469
onClick={() => void handleStopBackgroundWork()}
44694470
>
44704471
{isStoppingBackgroundWork ? "Stopping..." : "Stop"}
44714472
</Button>
44724473
),
4474+
className: "px-3 pt-1.5 pb-[1.375rem] text-xs",
44734475
};
44744476
}, [
44754477
activeBackgroundLiveness,

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

Lines changed: 472 additions & 460 deletions
Large diffs are not rendered by default.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ 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");

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,13 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
9898
};
9999

100100
return (
101-
<div className={cn("group/banner-stack mx-auto mb-2 max-w-3xl", className)}>
101+
<div
102+
className={cn(
103+
"group/banner-stack mx-auto mb-[calc(-1rem-1px)] w-[calc(100%-2.75rem)] max-w-[calc(48rem-2.75rem)]",
104+
className,
105+
)}
106+
data-composer-banner-drawer="true"
107+
>
102108
<div
103109
className={cn(
104110
"relative flex flex-col-reverse",
@@ -108,7 +114,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
108114
{showCollapsedStackCap && firstStackedItem ? (
109115
<div
110116
className={cn(
111-
"pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-[22px]",
117+
"pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-2xl",
112118
"border border-b-0 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",
113119
stackCapBorderClass[firstStackedItem.variant],
114120
"transition-opacity duration-150 ease-out",
@@ -130,6 +136,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
130136
>
131137
<ComposerBannerStackAlert
132138
item={frontItem}
139+
attached
133140
exiting={exitingItemId === frontItem.id}
134141
onDismissRequest={() => requestDismiss(frontItem)}
135142
/>
@@ -162,6 +169,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
162169
>
163170
<ComposerBannerStackAlert
164171
item={item}
172+
attached={false}
165173
exiting={exitingItemId === item.id}
166174
onDismissRequest={() => requestDismiss(item)}
167175
/>
@@ -178,10 +186,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
178186

179187
function ComposerBannerStackAlert({
180188
item,
189+
attached,
181190
exiting,
182191
onDismissRequest,
183192
}: {
184193
readonly item: ComposerBannerStackItem;
194+
readonly attached: boolean;
185195
readonly exiting: boolean;
186196
readonly onDismissRequest: () => void;
187197
}) {
@@ -190,7 +200,12 @@ function ComposerBannerStackAlert({
190200
return (
191201
<Alert
192202
variant={item.variant}
193-
className={cn("alert-glass rounded-[22px]", item.className)}
203+
className={cn(
204+
attached
205+
? "chat-composer-drawer-surface chat-composer-drawer-attached px-3 pt-2 pb-6"
206+
: "alert-glass rounded-[22px]",
207+
item.className,
208+
)}
194209
data-variant={item.variant}
195210
>
196211
{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+
});

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

Lines changed: 33 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,12 @@ import {
44
type ServerProviderSkill,
55
type ServerProviderSlashCommand,
66
} from "@t3tools/contracts";
7-
import { BotIcon } from "lucide-react";
8-
import { memo, useLayoutEffect, useMemo, useRef } from "react";
7+
import { memo, useLayoutEffect, useRef } from "react";
98

109
import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
1110
import { formatProviderSkillInstallSource } from "~/providerSkillPresentation";
1211
import { cn } from "~/lib/utils";
13-
import {
14-
Command,
15-
CommandGroup,
16-
CommandGroupLabel,
17-
CommandItem,
18-
CommandList,
19-
CommandSeparator,
20-
} from "../ui/command";
12+
import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
2113
import { PierreEntryIcon } from "./PierreEntryIcon";
2214

2315
export type ComposerCommandItem =
@@ -53,73 +45,17 @@ export type ComposerCommandItem =
5345
description: string;
5446
};
5547

56-
type ComposerCommandGroup = {
57-
id: string;
58-
label: string | null;
59-
items: ComposerCommandItem[];
60-
};
61-
62-
function SkillGlyph(props: { className?: string }) {
63-
return (
64-
<svg
65-
viewBox="0 0 24 24"
66-
fill="none"
67-
stroke="currentColor"
68-
strokeWidth="1.85"
69-
strokeLinecap="round"
70-
strokeLinejoin="round"
71-
className={props.className}
72-
aria-hidden="true"
73-
>
74-
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
75-
<path d="m3.3 7 8.7 5 8.7-5" />
76-
<path d="M12 22V12" />
77-
</svg>
78-
);
79-
}
80-
81-
function groupCommandItems(
82-
items: ComposerCommandItem[],
83-
triggerKind: ComposerTriggerKind | null,
84-
groupSlashCommandSections: boolean,
85-
): ComposerCommandGroup[] {
86-
if (triggerKind === "skill") {
87-
return items.length > 0 ? [{ id: "skills", label: "Skills", items }] : [];
88-
}
89-
if (triggerKind !== "slash-command" || !groupSlashCommandSections) {
90-
return [{ id: "default", label: null, items }];
91-
}
92-
93-
const builtInItems = items.filter((item) => item.type === "slash-command");
94-
const providerItems = items.filter((item) => item.type === "provider-slash-command");
95-
96-
const groups: ComposerCommandGroup[] = [];
97-
if (builtInItems.length > 0) {
98-
groups.push({ id: "built-in", label: "Built-in", items: builtInItems });
99-
}
100-
if (providerItems.length > 0) {
101-
groups.push({ id: "provider", label: "Provider", items: providerItems });
102-
}
103-
return groups;
104-
}
105-
10648
export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
10749
items: ComposerCommandItem[];
10850
resolvedTheme: "light" | "dark";
10951
isLoading: boolean;
11052
triggerKind: ComposerTriggerKind | null;
111-
groupSlashCommandSections?: boolean;
11253
emptyStateText?: string;
11354
activeItemId: string | null;
11455
onHighlightedItemChange: (itemId: string | null) => void;
11556
onSelect: (item: ComposerCommandItem) => void;
11657
}) {
11758
const listRef = useRef<HTMLDivElement>(null);
118-
const groups = useMemo(
119-
() =>
120-
groupCommandItems(props.items, props.triggerKind, props.groupSlashCommandSections ?? true),
121-
[props.groupSlashCommandSections, props.items, props.triggerKind],
122-
);
12359

12460
useLayoutEffect(() => {
12561
if (!props.activeItemId || !listRef.current) return;
@@ -141,57 +77,38 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
14177
>
14278
<div
14379
ref={listRef}
144-
className="dropdown-glass relative w-full overflow-hidden rounded-[20px] shadow-[0_16px_40px_-18px_rgb(0_0_0/55%)] **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4 dark:shadow-[0_18px_44px_-18px_rgb(0_0_0/80%)]"
80+
className="chat-composer-drawer-surface chat-composer-drawer-attached relative w-full overflow-hidden **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"
81+
data-composer-command-drawer="true"
14582
>
14683
{props.items.length > 0 ? (
147-
<CommandList className="max-h-72 not-empty:py-3">
148-
{groups.map((group, groupIndex) => (
149-
<div key={group.id}>
150-
{groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null}
151-
<CommandGroup>
152-
{group.label ? (
153-
<CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-secondary-label">
154-
{group.label}
155-
</CommandGroupLabel>
156-
) : null}
157-
{group.items.map((item) => (
158-
<ComposerCommandMenuItem
159-
key={item.id}
160-
item={item}
161-
resolvedTheme={props.resolvedTheme}
162-
isActive={props.activeItemId === item.id}
163-
onHighlight={props.onHighlightedItemChange}
164-
onSelect={props.onSelect}
165-
/>
166-
))}
167-
</CommandGroup>
168-
</div>
169-
))}
84+
<CommandList className="max-h-72 scroll-pb-6 pb-6!">
85+
<CommandGroup>
86+
{props.items.map((item) => (
87+
<ComposerCommandMenuItem
88+
key={item.id}
89+
item={item}
90+
resolvedTheme={props.resolvedTheme}
91+
isActive={props.activeItemId === item.id}
92+
onHighlight={props.onHighlightedItemChange}
93+
onSelect={props.onSelect}
94+
/>
95+
))}
96+
</CommandGroup>
17097
</CommandList>
17198
) : (
172-
<div className="px-5 py-3.5">
173-
{props.triggerKind === "skill" ? (
174-
<CommandGroup>
175-
<CommandGroupLabel className="px-0 pt-0 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-secondary-label">
176-
Skills
177-
</CommandGroupLabel>
178-
<p className="text-secondary-label text-xs">
179-
{props.isLoading
180-
? "Searching workspace skills..."
181-
: (props.emptyStateText ??
182-
"No skills found. Try / to browse provider commands.")}
183-
</p>
184-
</CommandGroup>
185-
) : (
186-
<p className="text-secondary-label text-xs">
187-
{props.isLoading
188-
? "Searching workspace files..."
189-
: (props.emptyStateText ??
190-
(props.triggerKind === "path"
99+
<div className="px-5 pt-3.5 pb-7">
100+
<p className="text-secondary-label text-xs">
101+
{props.isLoading
102+
? props.triggerKind === "skill"
103+
? "Searching workspace skills..."
104+
: "Searching workspace files..."
105+
: (props.emptyStateText ??
106+
(props.triggerKind === "skill"
107+
? "No skills found. Try / to browse provider commands."
108+
: props.triggerKind === "path"
191109
? "No matching files or folders."
192110
: "No matching command."))}
193-
</p>
194-
)}
111+
</p>
195112
</div>
196113
)}
197114
</div>
@@ -214,7 +131,7 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
214131
value={props.item.id}
215132
data-composer-item-id={props.item.id}
216133
className={cn(
217-
"cursor-pointer select-none gap-2 hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit",
134+
"cursor-pointer select-none gap-3 rounded-lg px-3 py-2! hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit",
218135
props.isActive && "bg-accent! text-accent-foreground!",
219136
)}
220137
onMouseMove={() => {
@@ -234,22 +151,11 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
234151
theme={props.resolvedTheme}
235152
/>
236153
) : null}
237-
{props.item.type === "slash-command" ? (
238-
<BotIcon className="size-4 shrink-0 text-icon-muted" />
239-
) : null}
240-
{props.item.type === "provider-slash-command" ? (
241-
<span className="inline-flex size-4 shrink-0 items-center justify-center text-icon-muted">
242-
<SkillGlyph className="size-3.5" />
154+
<span className="flex min-w-0 flex-1 items-baseline gap-3">
155+
<span className="shrink-0 font-sans text-xs font-medium text-foreground">
156+
{props.item.label}
243157
</span>
244-
) : null}
245-
{props.item.type === "skill" ? (
246-
<span className="inline-flex size-4 shrink-0 items-center justify-center text-icon-muted">
247-
<SkillGlyph className="size-3.5" />
248-
</span>
249-
) : null}
250-
<span className="flex min-w-0 flex-1 items-center gap-2">
251-
<span className="shrink-0">{props.item.label}</span>
252-
<span className="min-w-0 flex-1 truncate text-secondary-label text-xs">
158+
<span className="min-w-0 flex-1 truncate text-right text-secondary-label text-xs">
253159
{props.item.description}
254160
</span>
255161
</span>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { ApprovalRequestId } from "@t3tools/contracts";
2+
import { renderToStaticMarkup } from "react-dom/server";
3+
import { describe, expect, it } from "vite-plus/test";
4+
5+
import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
6+
7+
describe("ComposerPendingApprovalActions", () => {
8+
it("states that the persistent approval lasts for this session", () => {
9+
const markup = renderToStaticMarkup(
10+
<ComposerPendingApprovalActions
11+
requestId={ApprovalRequestId.make("approval-1")}
12+
isResponding={false}
13+
onRespondToApproval={async () => undefined}
14+
/>,
15+
);
16+
17+
expect(markup).toContain(">Cancel<");
18+
expect(markup).toContain("Always allow this session");
19+
expect(markup).not.toContain(">Always allow<");
20+
});
21+
});

0 commit comments

Comments
 (0)