authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-19 20:20:34-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 20:20:34-04:00
log792a1404f6d25838597f8f31b0af144dc1beee12
tree5e5c223cee75ec4f104233bf5bdfa05e688c1343
parent105cd5e0c57ab8b6df3bd8af5b534a5bc682fdce
signature Signed by PGP key B5690EEEBB952194

feat(web): attach composer state drawers (#7150)


36 files changed, 2309 insertions(+), 869 deletions(-)

apps/mobile/src/components/AppSymbol.tsx+4
......@@ -2,6 +2,7 @@ import {
22 IconAdjustmentsHorizontal,
33 IconAlertCircle,
44 IconAlertTriangle,
5 IconApps,
56 IconArchive,
67 IconArrowBackUp,
78 IconArrowDownCircle,
......@@ -13,6 +14,7 @@ import {
1314 IconArrowsMaximize,
1415 IconBellRinging,
1516 IconBolt,
17 IconBox,
1618 IconCamera,
1719 IconChartBar,
1820 IconCheck,
......@@ -104,6 +106,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
104106 checkmark: IconCheck,
105107 "checkmark.circle": IconCircleCheck,
106108 clock: IconClock,
109 cube: IconBox,
107110 "chevron.down": IconChevronDown,
108111 "chevron.left": IconChevronLeft,
109112 "chevron.left.forwardslash.chevron.right": IconCode,
......@@ -141,6 +144,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
141144 "sidebar.right": IconLayoutSidebarRight,
142145 "slider.horizontal.3": IconAdjustmentsHorizontal,
143146 "square.and.pencil": IconEdit,
147 "square.grid.2x2": IconApps,
144148 "square.split.2x1": IconLayoutColumns,
145149 "sun.max": IconSun,
146150 "stop.fill": IconPlayerStopFilled,
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+18-5
......@@ -1,9 +1,13 @@
1import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
1import {
2 resolveProviderSkillSourceKind,
3 type ProviderSkillSourceKind,
4} from "@t3tools/client-runtime/providerSkills";
25import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";
3import { SymbolView } from "../../components/AppSymbol";
6import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
47import { memo } from "react";
58import { Pressable, ScrollView, View, type ViewStyle } from "react-native";
69
10import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
711import { AppText as Text } from "../../components/AppText";
812import { GlassSurface } from "../../components/GlassSurface";
913import { PierreEntryIcon } from "../../components/PierreEntryIcon";
......@@ -61,13 +65,22 @@ function PopoverSurface(props: { readonly children: React.ReactNode; readonly st
6165 );
6266}
6367
64function itemIcon(item: ComposerCommandItem) {
68const SKILL_SOURCE_SYMBOL_BY_KIND: Record<ProviderSkillSourceKind, AppSymbolName> = {
69 app: "square.grid.2x2",
70 repo: "folder",
71 project: "folder",
72 personal: "person.crop.circle",
73 system: "gearshape",
74 other: "cube",
75};
76
77function itemIcon(item: ComposerCommandItem): AppSymbolName | null {
6578 switch (item.type) {
6679 case "slash-command":
6780 case "provider-slash-command":
68 return "terminal" as const;
81 return "terminal";
6982 case "skill":
70 return "cube" as const;
83 return SKILL_SOURCE_SYMBOL_BY_KIND[resolveProviderSkillSourceKind(item.skill)];
7184 case "path":
7285 return null;
7386 }
apps/web/src/components/ChatView.tsx+18-5
......@@ -4183,6 +4183,14 @@ function ChatViewContent(props: ChatViewProps) {
41834183 // partition (same shell, same capability gate, same PR auto-settle input)
41844184 // so the banner and the sidebar row never disagree.
41854185 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);
4186 const activeComposerTasksProgress =
4187 activeLatestTurn !== null && !latestTurnSettled
4188 ? (activeThreadShell?.planProgress ?? null)
4189 : null;
4190 const activeComposerTaskSteps =
4191 activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId
4192 ? activePlan.steps
4193 : null;
41864194 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
41874195 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
41884196 const activeThreadPr = resolveDisplayedThreadPr({
......@@ -4519,13 +4527,13 @@ function ChatViewContent(props: ChatViewProps) {
45194527 ),
45204528 title: working
45214529 ? liveCount > 0
4522 ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background`
4523 : "Background work running"
4524 : "Monitoring in the background",
4530 ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working`
4531 : "Background work"
4532 : "Monitoring",
45254533 actions: (
45264534 <Button
45274535 size="xs"
4528 variant="outline"
4536 variant="ghost"
45294537 disabled={isStoppingBackgroundWork}
45304538 onClick={() => void handleStopBackgroundWork()}
45314539 >
......@@ -4687,7 +4695,6 @@ function ChatViewContent(props: ChatViewProps) {
46874695 systemComposerBannerItems,
46884696 wokeThreadBannerItem,
46894697 ]);
4690
46914698 useEffect(() => {
46924699 setPendingServerThreadEnvMode(null);
46934700 setPendingServerThreadBranch(undefined);
......@@ -6282,6 +6289,8 @@ function ChatViewContent(props: ChatViewProps) {
62826289 setDragActive: setIsWorkspaceFileDragActive,
62836290 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
62846291 });
6292 const externalComposerDrawerAttached =
6293 composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable);
62856294
62866295 return (
62876296 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
......@@ -6485,6 +6494,7 @@ function ChatViewContent(props: ChatViewProps) {
64856494 <div
64866495 className={cn(
64876496 "chat-composer-glass-shell relative mx-auto w-full max-w-3xl",
6497 externalComposerDrawerAttached && "chat-composer-glass-shell-attached",
64886498 showComposerContextStrip && "chat-composer-glass-shell-with-context",
64896499 )}
64906500 >
......@@ -6509,6 +6519,7 @@ function ChatViewContent(props: ChatViewProps) {
65096519 isSendBusy={isSendBusy}
65106520 sendDisabledReason={threadDetailLoading ? "Messages loading" : null}
65116521 isPreparingWorktree={isPreparingWorktree}
6522 externalDrawerAttached={externalComposerDrawerAttached}
65126523 environmentUnavailable={activeEnvironmentUnavailableState}
65136524 activePendingApproval={activePendingApproval}
65146525 pendingApprovals={pendingApprovals}
......@@ -6521,6 +6532,8 @@ function ChatViewContent(props: ChatViewProps) {
65216532 respondingRequestIds={respondingRequestIds}
65226533 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
65236534 activeProposedPlan={activeProposedPlan}
6535 activeTasksProgress={activeComposerTasksProgress}
6536 activeTaskSteps={activeComposerTaskSteps}
65246537 runtimeMode={runtimeMode}
65256538 interactionMode={interactionMode}
65266539 lockedProvider={lockedProvider}
apps/web/src/components/ComposerPromptEditor.tsx+3-3
......@@ -73,13 +73,13 @@ import { basenameOfPath } from "~/pierre-icons";
7373import {
7474 COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME,
7575 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
76 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
7677 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
77 COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME,
7878 SKILL_CHIP_ICON_SVG,
7979} from "./composerInlineChip";
8080import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
8181import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
82import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";
82import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
8383import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
8484import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
8585
......@@ -257,7 +257,7 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s
257257 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
258258 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
259259 />
260 <span className={COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME}>{props.skillLabel}</span>
260 <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{props.skillLabel}</span>
261261 </span>
262262 );
263263
apps/web/src/components/chat/ChatComposer.tsx+615-469
......@@ -10,6 +10,7 @@ import type {
1010 ScopedThreadRef,
1111 ServerProvider,
1212 ThreadId,
13 TurnId,
1314} from "@t3tools/contracts";
1415import {
1516 isProviderSendTurnSupportedImageMimeType,
......@@ -65,6 +66,12 @@ import {
6566} from "../../promptStashStore";
6667import { ComposerStashBadge } from "./ComposerStashBadge";
6768import { ComposerStashMenu } from "./ComposerStashMenu";
69import {
70 ComposerTasksBadge,
71 ComposerTasksDrawer,
72 type ComposerTaskStep,
73 type ComposerTasksProgress,
74} from "./ComposerTasksBadge";
6875import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression";
6976import { isCommandPaletteOpen } from "../../commandPaletteBus";
7077import { getTerminalFocusOwner } from "../../lib/terminalFocus";
......@@ -143,12 +150,27 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:
143150 }
144151
145152 const updatePosition = () => {
146 const rect = anchor.getBoundingClientRect();
153 const form = anchor.closest<HTMLElement>('[data-chat-composer-form="true"]');
154 const mainSurface = form?.querySelector<HTMLElement>(
155 '[data-chat-composer-main-surface="true"]',
156 );
157 const rect = (mainSurface ?? form ?? anchor).getBoundingClientRect();
158 const rootFontSizePx =
159 Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16;
160 const drawerInsetRem =
161 Number.parseFloat(
162 window.getComputedStyle(form ?? anchor).getPropertyValue("--chat-composer-drawer-inset"),
163 ) || 1.375;
164 const drawerInset = drawerInsetRem * rootFontSizePx;
165 // One extra pixel prevents fractional layout coordinates from exposing
166 // the canvas between the drawer mask and the composer's foreground edge.
167 // Mirrors --chat-composer-attachment-overlap: calc(1rem + 1px).
168 const composerOverlap = rootFontSizePx + 1;
147169 const next = {
148 bottom: window.innerHeight - rect.top + 8,
149 left: rect.left,
150 maxHeight: Math.max(96, rect.top - 24),
151 width: rect.width,
170 bottom: window.innerHeight - rect.top - composerOverlap,
171 left: rect.left + drawerInset,
172 maxHeight: Math.max(96, rect.top - 24 + composerOverlap),
173 width: Math.max(0, rect.width - drawerInset * 2),
152174 };
153175 setPosition((current) =>
154176 current && composerCommandMenuPositionsEqual(current, next) ? current : next,
......@@ -184,6 +206,7 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:
184206 return createPortal(
185207 <div
186208 className="pointer-events-auto fixed z-[70]"
209 data-composer-drawer-layer="true"
187210 style={{
188211 bottom: position.bottom,
189212 left: position.left,
......@@ -228,7 +251,7 @@ import type { SessionPhase, Thread } from "../../types";
228251import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
229252import type { PendingApproval, PendingUserInput } from "../../session-logic";
230253import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
231import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
254import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
232255import { searchProviderSkills } from "../../providerSkillSearch";
233256import { useMediaQuery } from "../../hooks/useMediaQuery";
234257import type { ReviewCommentContext } from "../../reviewCommentContext";
......@@ -261,6 +284,7 @@ const runtimeModeConfig: Record<
261284
262285const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
263286const COMPOSER_FLOATING_LAYER_SELECTOR = [
287 '[data-composer-drawer-layer="true"]',
264288 '[data-slot="popover-popup"]',
265289 '[data-slot="menu-popup"]',
266290 '[data-slot="select-popup"]',
......@@ -523,6 +547,7 @@ export interface ChatComposerProps {
523547 isSendBusy: boolean;
524548 sendDisabledReason: string | null;
525549 isPreparingWorktree: boolean;
550 externalDrawerAttached: boolean;
526551 environmentUnavailable: {
527552 readonly label: string;
528553 readonly connection: EnvironmentConnectionPresentation;
......@@ -548,6 +573,8 @@ export interface ChatComposerProps {
548573 // Plan
549574 showPlanFollowUpPrompt: boolean;
550575 activeProposedPlan: Thread["proposedPlans"][number] | null;
576 activeTasksProgress: ComposerTasksProgress | null;
577 activeTaskSteps: readonly ComposerTaskStep[] | null;
551578
552579 // Mode
553580 runtimeMode: RuntimeMode;
......@@ -642,6 +669,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
642669 respondingRequestIds,
643670 showPlanFollowUpPrompt,
644671 activeProposedPlan,
672 activeTasksProgress,
673 activeTaskSteps,
645674 runtimeMode,
646675 interactionMode,
647676 lockedProvider,
......@@ -960,6 +989,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
960989 );
961990 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
962991 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
992 const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);
993 const [dismissedTasksTurnId, setDismissedTasksTurnId] = useState<TurnId | null>(null);
963994 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
964995 key: 0,
965996 active: false,
......@@ -1146,10 +1177,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11461177
11471178 const isComposerApprovalState = activePendingApproval !== null;
11481179 const activePendingUserInput = pendingUserInputs[0] ?? null;
1149 const hasComposerHeader =
1180 const showComposerTopDrawer =
11501181 isComposerApprovalState ||
11511182 pendingUserInputs.length > 0 ||
1152 (showPlanFollowUpPrompt && activeProposedPlan !== null);
1183 (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);
11531184 const showCollapsedMobilePromptRow =
11541185 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
11551186
......@@ -2274,6 +2305,78 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
22742305 const toggleStashMenu = useCallback(() => {
22752306 setIsStashMenuOpen((open) => !open);
22762307 }, []);
2308 const toggleInlineStashMenu = useCallback(() => {
2309 if (isComposerCollapsedMobile) {
2310 expandMobileComposer();
2311 setIsStashMenuOpen(true);
2312 return;
2313 }
2314 toggleStashMenu();
2315 }, [expandMobileComposer, isComposerCollapsedMobile, toggleStashMenu]);
2316 const toggleTasksDrawer = useCallback(() => {
2317 setIsTasksDrawerOpen((open) => !open);
2318 }, []);
2319 const activeTasksTurnId = activeThread?.latestTurn?.turnId ?? null;
2320 const tasksDismissedForActiveTurn =
2321 activeTasksTurnId !== null && dismissedTasksTurnId === activeTasksTurnId;
2322 const visibleTasksProgress = tasksDismissedForActiveTurn ? null : activeTasksProgress;
2323 const visibleTaskSteps = tasksDismissedForActiveTurn ? null : activeTaskSteps;
2324 const hasBlockingComposerTopDrawer =
2325 activePendingApproval !== null || pendingUserInputs.length > 0;
2326 const dismissTasks = useCallback(() => {
2327 if (activeTasksTurnId !== null) {
2328 setDismissedTasksTurnId(activeTasksTurnId);
2329 }
2330 setIsTasksDrawerOpen(false);
2331 }, [activeTasksTurnId]);
2332 const showInlineStashBadge =
2333 stashQueue.length > 0 &&
2334 !isComposerApprovalState &&
2335 (props.externalDrawerAttached ||
2336 showComposerTopDrawer ||
2337 isTasksDrawerOpen ||
2338 isComposerCollapsedMobile);
2339 const inlineStashBadge = showInlineStashBadge ? (
2340 <ComposerStashBadge
2341 count={stashQueue.length}
2342 menuOpen={isStashMenuOpen}
2343 placement="inline"
2344 pulseKey={stashPulse.key}
2345 pulsing={stashPulse.active}
2346 onToggleMenu={toggleInlineStashMenu}
2347 />
2348 ) : null;
2349 const showInlineTasksBadge =
2350 visibleTasksProgress !== null &&
2351 visibleTaskSteps !== null &&
2352 !isTasksDrawerOpen &&
2353 !hasBlockingComposerTopDrawer &&
2354 (props.externalDrawerAttached || showComposerTopDrawer || isComposerCollapsedMobile);
2355 const inlineTasksBadge = showInlineTasksBadge ? (
2356 <ComposerTasksBadge
2357 expanded={false}
2358 onDismiss={dismissTasks}
2359 onToggle={toggleTasksDrawer}
2360 placement="inline"
2361 progress={visibleTasksProgress}
2362 steps={visibleTaskSteps}
2363 />
2364 ) : null;
2365 useEffect(() => {
2366 if (visibleTasksProgress === null || visibleTaskSteps === null) {
2367 setIsTasksDrawerOpen(false);
2368 }
2369 }, [visibleTaskSteps, visibleTasksProgress]);
2370
2371 useEffect(() => {
2372 if (hasBlockingComposerTopDrawer) {
2373 setIsTasksDrawerOpen(false);
2374 }
2375 }, [hasBlockingComposerTopDrawer]);
2376
2377 useEffect(() => {
2378 setIsTasksDrawerOpen(false);
2379 }, [activeThreadId]);
22772380
22782381 // Close the stash menu whenever the trigger-driven command menu opens so
22792382 // the two popovers never stack in the same layer, and when the user
......@@ -2301,13 +2404,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23012404 // even when the composer is in a state that can't stash.
23022405 event.preventDefault();
23032406 event.stopPropagation();
2304 if (
2305 isCommandPaletteOpen() ||
2306 isComposerApprovalState ||
2307 pendingUserInputs.length > 0 ||
2308 projectSelectionRequired ||
2309 activePendingProgress !== null
2310 ) {
2407 if (isCommandPaletteOpen()) {
2408 return;
2409 }
2410 if (pendingUserInputs.length > 0 && !isComposerApprovalState) {
2411 setIsStashMenuOpen((open) => !open);
2412 return;
2413 }
2414 if (isComposerApprovalState || projectSelectionRequired || activePendingProgress !== null) {
23112415 return;
23122416 }
23132417 void stashCurrentPrompt();
......@@ -2472,7 +2576,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
24722576 },
24732577 });
24742578
2475 const onComposerMentionDragLeaveCapture = (event: React.DragEvent<HTMLDivElement>) => {
2579 const onComposerMentionDragLeaveCapture = (event: React.DragEvent<HTMLFormElement>) => {
24762580 if (!dataTransferHasComposerMention(event.dataTransfer.types)) return;
24772581 event.stopPropagation();
24782582 const nextTarget = event.relatedTarget;
......@@ -2514,14 +2618,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25142618 return;
25152619 }
25162620 const composerSurface = composerSurfaceRef.current;
2621 const composerForm = composerFormRef.current;
25172622 const activeElement = document.activeElement;
25182623 if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) {
25192624 return;
25202625 }
25212626 if (
2522 composerSurface &&
25232627 activeElement instanceof Node &&
2524 composerSurface.contains(activeElement)
2628 ((composerSurface && composerSurface.contains(activeElement)) ||
2629 (composerForm && composerForm.contains(activeElement)))
25252630 ) {
25262631 return;
25272632 }
......@@ -2686,85 +2791,73 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
26862791 <form
26872792 ref={composerFormRef}
26882793 onSubmit={submitComposer}
2794 onFocusCapture={(event) => {
2795 const activeElement = event.target;
2796 if (
2797 isComposerCollapsedMobile &&
2798 activeElement instanceof HTMLElement &&
2799 activeElement.closest('[data-chat-composer-collapsed-controls="true"]')
2800 ) {
2801 return;
2802 }
2803 if (composerBlurFrameRef.current !== null) {
2804 window.cancelAnimationFrame(composerBlurFrameRef.current);
2805 composerBlurFrameRef.current = null;
2806 }
2807 setIsComposerFocused(true);
2808 }}
2809 onBlurCapture={() => {
2810 scheduleComposerCollapseCheck();
2811 }}
2812 onDragEnterCapture={composerMentionDragHandlers.onDragEnter}
2813 onDragOverCapture={composerMentionDragHandlers.onDragOver}
2814 onDragLeaveCapture={onComposerMentionDragLeaveCapture}
2815 onDropCapture={composerMentionDragHandlers.onDrop}
26892816 className="mx-auto w-full min-w-0 max-w-3xl"
26902817 data-chat-composer-form="true"
26912818 >
2692 <div
2693 className={cn(
2694 "group rounded-[22px] p-px transition-colors duration-200",
2695 composerProviderState.composerFrameClassName,
2696 )}
2697 onDragEnterCapture={composerMentionDragHandlers.onDragEnter}
2698 onDragOverCapture={composerMentionDragHandlers.onDragOver}
2699 onDragLeaveCapture={onComposerMentionDragLeaveCapture}
2700 onDropCapture={composerMentionDragHandlers.onDrop}
2701 >
2819 {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
27022820 <div
2703 ref={composerSurfaceRef}
2704 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
2705 className={cn(
2706 "rounded-[20px] transition-[background-color] duration-200",
2707 isDragOverComposer ? "bg-accent/45 ring-1 ring-primary/70" : null,
2708 projectSelectionRequired ? "opacity-75" : null,
2709 composerProviderState.composerSurfaceClassName,
2710 )}
2711 onFocusCapture={(event) => {
2712 const activeElement = event.target;
2713 if (
2714 isComposerCollapsedMobile &&
2715 activeElement instanceof HTMLElement &&
2716 activeElement.closest('[data-chat-composer-collapsed-controls="true"]')
2717 ) {
2718 return;
2719 }
2720 if (composerBlurFrameRef.current !== null) {
2721 window.cancelAnimationFrame(composerBlurFrameRef.current);
2722 composerBlurFrameRef.current = null;
2723 }
2724 setIsComposerFocused(true);
2725 }}
2726 onBlurCapture={() => {
2727 scheduleComposerCollapseCheck();
2728 }}
2821 className="chat-composer-top-drawer"
2822 data-chat-composer-top-drawer="true"
2823 data-variant={activePendingApproval ? "warning" : "info"}
27292824 >
2730 {!isComposerCollapsedMobile &&
2731 (activePendingApproval ? (
2732 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
2733 <ComposerPendingApprovalPanel
2734 approval={activePendingApproval}
2735 pendingCount={pendingApprovals.length}
2736 />
2737 </div>
2738 ) : pendingUserInputs.length > 0 ? (
2739 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
2740 <ComposerPendingUserInputPanel
2741 pendingUserInputs={pendingUserInputs}
2742 respondingRequestIds={respondingRequestIds}
2743 answers={activePendingDraftAnswers}
2744 questionIndex={activePendingQuestionIndex}
2745 onToggleOption={onSelectActivePendingUserInputOption}
2746 onAdvance={onAdvanceActivePendingUserInput}
2747 />
2748 </div>
2749 ) : showPlanFollowUpPrompt && activeProposedPlan ? (
2750 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">
2751 <ComposerPlanFollowUpBanner
2752 key={activeProposedPlan.id}
2753 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
2825 {!isComposerCollapsedMobile && activePendingApproval ? (
2826 <div className="flex min-w-0 flex-wrap items-center gap-1 px-3 py-1.5 sm:px-4">
2827 <ComposerPendingApprovalPanel
2828 approval={activePendingApproval}
2829 pendingCount={pendingApprovals.length}
2830 />
2831 <div className="flex min-w-0 flex-wrap items-center gap-0.5">
2832 <ComposerPendingApprovalActions
2833 requestId={activePendingApproval.requestId}
2834 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2835 onRespondToApproval={onRespondToApproval}
27542836 />
27552837 </div>
2756 ) : null)}
2757
2758 {isComposerCollapsedMobile && activePendingApproval ? (
2759 <div
2760 className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2761 data-chat-composer-collapsed-controls="true"
2762 >
2838 </div>
2839 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
2840 <ComposerPendingUserInputPanel
2841 pendingUserInputs={pendingUserInputs}
2842 respondingRequestIds={respondingRequestIds}
2843 answers={activePendingDraftAnswers}
2844 questionIndex={activePendingQuestionIndex}
2845 onToggleOption={onSelectActivePendingUserInputOption}
2846 onAdvance={onAdvanceActivePendingUserInput}
2847 />
2848 ) : !isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan ? (
2849 <ComposerPlanFollowUpBanner
2850 key={activeProposedPlan.id}
2851 planTitle={proposedPlanTitle(activeProposedPlan.planMarkdown) ?? null}
2852 />
2853 ) : isComposerCollapsedMobile && activePendingApproval ? (
2854 <div data-chat-composer-collapsed-controls="true">
27632855 <ComposerPendingApprovalPanel
27642856 approval={activePendingApproval}
27652857 pendingCount={pendingApprovals.length}
2858 className="px-3 pt-2 sm:px-4"
27662859 />
2767 <div className="flex flex-wrap items-center justify-end gap-2 px-3 pb-3 sm:px-4">
2860 <div className="flex flex-wrap items-center justify-end gap-1 px-3 pt-2 pb-3 sm:px-4">
27682861 <ComposerPendingApprovalActions
27692862 requestId={activePendingApproval.requestId}
27702863 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
......@@ -2773,10 +2866,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
27732866 </div>
27742867 </div>
27752868 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
2776 <div
2777 className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2778 data-chat-composer-collapsed-controls="true"
2779 >
2869 <div data-chat-composer-collapsed-controls="true">
27802870 <ComposerPendingUserInputPanel
27812871 pendingUserInputs={pendingUserInputs}
27822872 respondingRequestIds={respondingRequestIds}
......@@ -2806,6 +2896,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28062896 >
28072897 {activePendingProgress?.customAnswer || "Write custom answer"}
28082898 </button>
2899 {inlineTasksBadge}
2900 {inlineStashBadge}
28092901 {activePendingProgress?.activeQuestion?.multiSelect ? (
28102902 <ComposerPrimaryActions
28112903 compact
......@@ -2833,269 +2925,450 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28332925 </div>
28342926 </div>
28352927 ) : null}
2836
2837 {showCollapsedMobilePromptRow ? (
2838 <div className="flex items-center justify-between gap-2 px-3 py-2">
2839 <button
2840 type="button"
2841 className={cn(
2842 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
2843 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
2844 ? "text-foreground"
2845 : "text-placeholder",
2846 )}
2847 onPointerDown={(event) => event.preventDefault()}
2848 onClick={expandMobileComposer}
2849 aria-label="Expand composer"
2850 >
2851 {activePendingProgress
2852 ? activePendingProgress.customAnswer ||
2853 "Type your own answer, or leave this blank to use the selected option"
2854 : prompt.trim() ||
2855 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
2856 </button>
2857 <button
2858 type="button"
2859 className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
2860 disabled={collapsedComposerPrimaryActionDisabled}
2861 aria-label={collapsedComposerPrimaryActionLabel}
2862 onPointerDown={(event) => event.preventDefault()}
2863 onClick={(event) => {
2864 event.stopPropagation();
2865 submitComposer();
2866 }}
2867 >
2868 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
2869 <path
2870 d="M8 3L8 13M8 3L4 7M8 3L12 7"
2871 stroke="currentColor"
2872 strokeWidth="2"
2873 strokeLinecap="round"
2874 strokeLinejoin="round"
2875 />
2876 </svg>
2877 </button>
2878 </div>
2879 ) : null}
2880
2928 </div>
2929 ) : null}
2930 {isTasksDrawerOpen &&
2931 !hasBlockingComposerTopDrawer &&
2932 visibleTasksProgress &&
2933 visibleTaskSteps ? (
2934 <ComposerTasksDrawer
2935 onDismiss={dismissTasks}
2936 onCollapse={toggleTasksDrawer}
2937 progress={visibleTasksProgress}
2938 steps={visibleTaskSteps}
2939 />
2940 ) : null}
2941 <div className="relative">
2942 {visibleTasksProgress &&
2943 visibleTaskSteps &&
2944 !isTasksDrawerOpen &&
2945 !props.externalDrawerAttached &&
2946 !showComposerTopDrawer &&
2947 !isComposerCollapsedMobile ? (
2948 <ComposerTasksBadge
2949 expanded={false}
2950 hasTrailingShoulder={stashQueue.length > 0}
2951 onDismiss={dismissTasks}
2952 onToggle={toggleTasksDrawer}
2953 progress={visibleTasksProgress}
2954 steps={visibleTaskSteps}
2955 />
2956 ) : null}
2957 {!props.externalDrawerAttached &&
2958 !showComposerTopDrawer &&
2959 !isTasksDrawerOpen &&
2960 !isComposerCollapsedMobile ? (
2961 <ComposerStashBadge
2962 count={stashQueue.length}
2963 menuOpen={isStashMenuOpen}
2964 pulseKey={stashPulse.key}
2965 pulsing={stashPulse.active}
2966 onToggleMenu={toggleStashMenu}
2967 />
2968 ) : null}
2969 <div
2970 data-chat-composer-main-surface="true"
2971 className={cn(
2972 "group relative z-10 rounded-[22px] p-px transition-colors duration-200",
2973 composerProviderState.composerFrameClassName,
2974 )}
2975 >
28812976 <div
2882 ref={setComposerMenuAnchor}
2977 ref={composerSurfaceRef}
2978 data-chat-composer-surface="true"
2979 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
28832980 className={cn(
2884 "relative px-3 pb-2 sm:px-4",
2885 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",
2886 isComposerCollapsedMobile && "hidden",
2981 "rounded-[20px] transition-[background-color] duration-200",
2982 isDragOverComposer ? "bg-accent/45 ring-1 ring-primary/70" : null,
2983 projectSelectionRequired ? "opacity-75" : null,
2984 composerProviderState.composerSurfaceClassName,
28872985 )}
28882986 >
2889 <ComposerStashBadge
2890 count={stashQueue.length}
2891 pulseKey={stashPulse.key}
2892 pulsing={stashPulse.active}
2893 menuOpen={isStashMenuOpen}
2894 onToggleMenu={toggleStashMenu}
2895 />
2896
2897 {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && (
2898 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
2899 <ComposerStashMenu
2900 entries={stashQueue}
2901 onRestore={restoreStashEntry}
2902 onDelete={deleteStashEntry}
2903 onClose={() => setIsStashMenuOpen(false)}
2904 />
2905 </ComposerCommandMenuLayer>
2906 )}
2907
2908 {composerMenuOpen && !isComposerApprovalState && (
2909 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
2910 <ComposerCommandMenu
2911 items={composerMenuItems}
2912 resolvedTheme={resolvedTheme}
2913 isLoading={isComposerMenuLoading}
2914 triggerKind={composerTriggerKind}
2915 groupSlashCommandSections={
2916 composerTrigger?.kind === "slash-command" &&
2917 composerTrigger.query.trim().length === 0
2918 }
2919 emptyStateText={composerMenuEmptyState}
2920 activeItemId={activeComposerMenuItem?.id ?? null}
2921 onHighlightedItemChange={onComposerMenuItemHighlighted}
2922 onSelect={onSelectComposerItem}
2923 />
2924 </ComposerCommandMenuLayer>
2925 )}
2926
2927 {!isComposerCollapsedMobile &&
2928 !isComposerApprovalState &&
2929 pendingUserInputs.length === 0 &&
2930 composerPreviewAnnotations.length > 0 && (
2931 <ComposerPreviewAnnotationCards
2932 annotations={composerPreviewAnnotations}
2933 images={composerImages}
2934 onRemove={(annotationId) =>
2935 removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId)
2936 }
2937 onExpandImage={(imageId) => {
2938 const preview = buildExpandedImagePreview(composerImages, imageId);
2939 if (preview) onExpandImage(preview);
2987 {showCollapsedMobilePromptRow ? (
2988 <div className="flex items-center justify-between gap-2 px-3 py-2">
2989 <button
2990 type="button"
2991 className={cn(
2992 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
2993 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
2994 ? "text-foreground"
2995 : "text-placeholder",
2996 )}
2997 onPointerDown={(event) => event.preventDefault()}
2998 onClick={expandMobileComposer}
2999 aria-label="Expand composer"
3000 >
3001 {activePendingProgress
3002 ? activePendingProgress.customAnswer ||
3003 "Type your own answer, or leave this blank to use the selected option"
3004 : prompt.trim() ||
3005 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
3006 </button>
3007 {inlineTasksBadge}
3008 {inlineStashBadge}
3009 <button
3010 type="button"
3011 className="flex size-8 shrink-0 items-center justify-center rounded-full bg-message-action text-message-action-foreground hover:bg-message-action-hover disabled:opacity-30"
3012 disabled={collapsedComposerPrimaryActionDisabled}
3013 aria-label={collapsedComposerPrimaryActionLabel}
3014 onPointerDown={(event) => event.preventDefault()}
3015 onClick={(event) => {
3016 event.stopPropagation();
3017 submitComposer();
29403018 }}
2941 className="mb-3"
2942 />
3019 >
3020 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
3021 <path
3022 d="M8 3L8 13M8 3L4 7M8 3L12 7"
3023 stroke="currentColor"
3024 strokeWidth="2"
3025 strokeLinecap="round"
3026 strokeLinejoin="round"
3027 />
3028 </svg>
3029 </button>
3030 </div>
3031 ) : null}
3032
3033 <div
3034 ref={setComposerMenuAnchor}
3035 className={cn(
3036 "relative px-3 pb-2 sm:px-4",
3037 "pt-3.5 sm:pt-4",
3038 isComposerApprovalState && "pb-3 sm:pb-4",
3039 isComposerCollapsedMobile && "hidden",
3040 )}
3041 >
3042 {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && (
3043 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
3044 <ComposerStashMenu
3045 entries={stashQueue}
3046 onRestore={restoreStashEntry}
3047 onDelete={deleteStashEntry}
3048 onClose={() => setIsStashMenuOpen(false)}
3049 />
3050 </ComposerCommandMenuLayer>
29433051 )}
29443052
2945 {!isComposerCollapsedMobile &&
2946 !isComposerApprovalState &&
2947 pendingUserInputs.length === 0 &&
2948 composerReviewComments.length > 0 && (
2949 <ComposerPendingReviewComments
2950 comments={composerReviewComments}
2951 onRemove={(commentId) =>
2952 removeComposerDraftReviewComment(composerDraftTarget, commentId)
2953 }
2954 className="mb-3"
2955 />
3053 {composerMenuOpen && !isComposerApprovalState && (
3054 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
3055 <ComposerCommandMenu
3056 items={composerMenuItems}
3057 resolvedTheme={resolvedTheme}
3058 isLoading={isComposerMenuLoading}
3059 triggerKind={composerTriggerKind}
3060 emptyStateText={composerMenuEmptyState}
3061 activeItemId={activeComposerMenuItem?.id ?? null}
3062 onHighlightedItemChange={onComposerMenuItemHighlighted}
3063 onSelect={onSelectComposerItem}
3064 />
3065 </ComposerCommandMenuLayer>
29563066 )}
29573067
2958 {!isComposerCollapsedMobile &&
2959 !isComposerApprovalState &&
2960 pendingUserInputs.length === 0 &&
2961 composerElementContexts.length > 0 && (
2962 <ComposerPendingElementContexts
2963 contexts={composerElementContexts}
2964 onRemove={(contextId) =>
2965 removeComposerDraftElementContext(composerDraftTarget, contextId)
3068 {!isComposerCollapsedMobile &&
3069 !isComposerApprovalState &&
3070 pendingUserInputs.length === 0 &&
3071 composerPreviewAnnotations.length > 0 && (
3072 <ComposerPreviewAnnotationCards
3073 annotations={composerPreviewAnnotations}
3074 images={composerImages}
3075 onRemove={(annotationId) =>
3076 removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId)
3077 }
3078 onExpandImage={(imageId) => {
3079 const preview = buildExpandedImagePreview(composerImages, imageId);
3080 if (preview) onExpandImage(preview);
3081 }}
3082 className="mb-3"
3083 />
3084 )}
3085
3086 {!isComposerCollapsedMobile &&
3087 !isComposerApprovalState &&
3088 pendingUserInputs.length === 0 &&
3089 composerReviewComments.length > 0 && (
3090 <ComposerPendingReviewComments
3091 comments={composerReviewComments}
3092 onRemove={(commentId) =>
3093 removeComposerDraftReviewComment(composerDraftTarget, commentId)
3094 }
3095 className="mb-3"
3096 />
3097 )}
3098
3099 {!isComposerCollapsedMobile &&
3100 !isComposerApprovalState &&
3101 pendingUserInputs.length === 0 &&
3102 composerElementContexts.length > 0 && (
3103 <ComposerPendingElementContexts
3104 contexts={composerElementContexts}
3105 onRemove={(contextId) =>
3106 removeComposerDraftElementContext(composerDraftTarget, contextId)
3107 }
3108 className="mb-3"
3109 />
3110 )}
3111
3112 {!isComposerCollapsedMobile &&
3113 !isComposerApprovalState &&
3114 pendingUserInputs.length === 0 &&
3115 composerImages.some(
3116 (image) =>
3117 !composerPreviewAnnotations.some((annotation) => annotation.id === image.id),
3118 ) && (
3119 <div className="mb-3 flex flex-wrap gap-2">
3120 {composerImages
3121 .filter(
3122 (image) =>
3123 !composerPreviewAnnotations.some(
3124 (annotation) => annotation.id === image.id,
3125 ),
3126 )
3127 .map((image) => (
3128 <div
3129 key={image.id}
3130 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
3131 >
3132 {image.previewUrl ? (
3133 <button
3134 type="button"
3135 className="h-full w-full cursor-zoom-in"
3136 aria-label={`Preview ${image.name}`}
3137 onClick={() => {
3138 const preview = buildExpandedImagePreview(composerImages, image.id);
3139 if (!preview) return;
3140 onExpandImage(preview);
3141 }}
3142 >
3143 <img
3144 src={image.previewUrl}
3145 alt={image.name}
3146 className="h-full w-full object-cover"
3147 />
3148 </button>
3149 ) : (
3150 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-secondary-label">
3151 {image.name}
3152 </div>
3153 )}
3154 {nonPersistedComposerImageIdSet.has(image.id) && (
3155 <Tooltip>
3156 <TooltipTrigger
3157 render={
3158 <span
3159 role="img"
3160 aria-label="Draft attachment may not persist"
3161 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
3162 >
3163 <CircleAlertIcon className="size-3" />
3164 </span>
3165 }
3166 />
3167 <TooltipPopup
3168 side="top"
3169 className="max-w-64 whitespace-normal leading-tight"
3170 >
3171 Draft attachment could not be saved locally and may be lost on
3172 navigation.
3173 </TooltipPopup>
3174 </Tooltip>
3175 )}
3176 <Button
3177 variant="ghost"
3178 size="icon-xs"
3179 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
3180 onClick={() => removeComposerImage(image.id)}
3181 aria-label={`Remove ${image.name}`}
3182 >
3183 <XIcon />
3184 </Button>
3185 </div>
3186 ))}
3187 </div>
3188 )}
3189
3190 <div className="relative">
3191 <ComposerPromptEditor
3192 editorRef={composerEditorRef}
3193 value={
3194 isComposerApprovalState
3195 ? ""
3196 : activePendingProgress
3197 ? activePendingProgress.customAnswer
3198 : prompt
29663199 }
2967 className="mb-3"
3200 cursor={composerCursor}
3201 terminalContexts={
3202 !isComposerApprovalState && pendingUserInputs.length === 0
3203 ? composerTerminalContexts
3204 : []
3205 }
3206 skills={selectedProviderStatus?.skills ?? []}
3207 {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
3208 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
3209 onChange={onPromptChange}
3210 onCommandKeyDown={onComposerCommandKey}
3211 onPaste={onComposerPaste}
3212 placeholder={
3213 isComposerApprovalState
3214 ? (activePendingApproval?.detail ??
3215 "Resolve this approval request to continue")
3216 : activePendingProgress
3217 ? "Type your own answer, or leave this blank to use the selected option"
3218 : showPlanFollowUpPrompt && activeProposedPlan
3219 ? "Add feedback to refine the plan, or leave this blank to implement it"
3220 : projectSelectionRequired
3221 ? "Choose a project above to start a thread"
3222 : noProviderAvailable
3223 ? "Enable a provider in Settings to send a message"
3224 : phase === "disconnected"
3225 ? DISCONNECTED_COMPOSER_PLACEHOLDER
3226 : "Ask anything, @tag files/folders, $use skills, or / for commands"
3227 }
3228 disabled={isConnecting || isComposerApprovalState || projectSelectionRequired}
29683229 />
2969 )}
3230 {showMobilePendingAnswerActions ? (
3231 <div
3232 data-chat-composer-mobile-pending-actions="true"
3233 className="absolute bottom-0 right-0 flex items-center justify-end gap-1"
3234 >
3235 {inlineTasksBadge}
3236 {inlineStashBadge}
3237 <ComposerPrimaryActions
3238 compact
3239 pendingAction={pendingPrimaryAction}
3240 isRunning={false}
3241 showPlanFollowUpPrompt={false}
3242 promptHasText={false}
3243 isSendBusy={isSendBusy}
3244 sendDisabledReason={sendDisabledReason}
3245 isConnecting={isConnecting}
3246 isEnvironmentUnavailable={
3247 environmentUnavailable !== null ||
3248 noProviderAvailable ||
3249 projectSelectionRequired
3250 }
3251 isPreparingWorktree={false}
3252 hasSendableContent={false}
3253 preserveComposerFocusOnPointerDown
3254 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3255 onInterrupt={handleInterruptPrimaryAction}
3256 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3257 />
3258 </div>
3259 ) : null}
3260 </div>
3261 </div>
29703262
2971 {!isComposerCollapsedMobile &&
2972 !isComposerApprovalState &&
2973 pendingUserInputs.length === 0 &&
2974 composerImages.some(
2975 (image) =>
2976 !composerPreviewAnnotations.some((annotation) => annotation.id === image.id),
2977 ) && (
2978 <div className="mb-3 flex flex-wrap gap-2">
2979 {composerImages
2980 .filter(
2981 (image) =>
2982 !composerPreviewAnnotations.some(
2983 (annotation) => annotation.id === image.id,
2984 ),
2985 )
2986 .map((image) => (
2987 <div
2988 key={image.id}
2989 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"
2990 >
2991 {image.previewUrl ? (
2992 <button
2993 type="button"
2994 className="h-full w-full cursor-zoom-in"
2995 aria-label={`Preview ${image.name}`}
2996 onClick={() => {
2997 const preview = buildExpandedImagePreview(composerImages, image.id);
2998 if (!preview) return;
2999 onExpandImage(preview);
3000 }}
3001 >
3002 <img
3003 src={image.previewUrl}
3004 alt={image.name}
3005 className="h-full w-full object-cover"
3006 />
3007 </button>
3008 ) : (
3009 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-secondary-label">
3010 {image.name}
3011 </div>
3012 )}
3013 {nonPersistedComposerImageIdSet.has(image.id) && (
3014 <Tooltip>
3015 <TooltipTrigger
3016 render={
3017 <span
3018 role="img"
3019 aria-label="Draft attachment may not persist"
3020 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"
3021 >
3022 <CircleAlertIcon className="size-3" />
3023 </span>
3024 }
3025 />
3026 <TooltipPopup
3027 side="top"
3028 className="max-w-64 whitespace-normal leading-tight"
3029 >
3030 Draft attachment could not be saved locally and may be lost on
3031 navigation.
3032 </TooltipPopup>
3033 </Tooltip>
3034 )}
3035 <Button
3036 variant="ghost"
3037 size="icon-xs"
3038 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"
3039 onClick={() => removeComposerImage(image.id)}
3040 aria-label={`Remove ${image.name}`}
3041 >
3042 <XIcon />
3043 </Button>
3044 </div>
3045 ))}
3263 <ComposerPromptLengthValidation
3264 message={providerInputSubmissionError ?? composerSubmissionError}
3265 />
3266
3267 {/* Bottom toolbar */}
3268 {isComposerCollapsedMobile || isComposerApprovalState ? null : (
3269 <div
3270 data-chat-composer-footer="true"
3271 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
3272 className={cn(
3273 "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-3 pb-3 sm:px-4 sm:pb-4",
3274 pendingUserInputs.length > 0 && "pt-2",
3275 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
3276 showMobilePendingAnswerActions && "hidden sm:flex",
3277 )}
3278 >
3279 <div className="-m-1 -ms-3.5 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 ps-3.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
3280 {noProviderAvailable ? (
3281 <Button
3282 type="button"
3283 size="sm"
3284 variant="ghost"
3285 disabled
3286 data-chat-provider-unavailable="true"
3287 className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3"
3288 >
3289 <CircleAlertIcon className="size-4" />
3290 No provider available
3291 </Button>
3292 ) : (
3293 <ProviderModelPicker
3294 compact={isComposerFooterCompact}
3295 activeInstanceId={selectedInstanceId}
3296 model={selectedModelForPickerWithCustomFallback}
3297 lockedProvider={lockedProvider}
3298 lockedContinuationGroupKey={lockedContinuationGroupKey}
3299 instanceEntries={providerInstanceEntries}
3300 keybindings={keybindings}
3301 modelOptionsByInstance={modelOptionsByInstance}
3302 triggerClassName="-ms-2.5"
3303 terminalOpen={terminalOpen}
3304 open={isComposerModelPickerOpen}
3305 {...(composerProviderState.modelPickerIconClassName
3306 ? {
3307 activeProviderIconClassName:
3308 composerProviderState.modelPickerIconClassName,
3309 }
3310 : {})}
3311 onOpenChange={(open) => {
3312 setIsComposerModelPickerOpen(open);
3313 }}
3314 getModelDisabledReason={getModelDisabledReason}
3315 onInstanceModelChange={onProviderModelSelect}
3316 />
3317 )}
3318
3319 {isComposerFooterCompact ? (
3320 <CompactComposerControlsMenu
3321 interactionMode={interactionMode}
3322 runtimeMode={runtimeMode}
3323 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
3324 traitsMenuContent={providerTraitsMenuContent}
3325 onToggleInteractionMode={toggleInteractionMode}
3326 onRuntimeModeChange={handleRuntimeModeChange}
3327 />
3328 ) : (
3329 <>
3330 {providerTraitsPicker ? (
3331 <>
3332 <Separator
3333 orientation="vertical"
3334 className="mx-0.5 hidden h-4 sm:block"
3335 />
3336 {providerTraitsPicker}
3337 </>
3338 ) : null}
3339 <ComposerFooterModeControls
3340 showInteractionModeToggle={
3341 composerProviderControls.showInteractionModeToggle
3342 }
3343 interactionMode={interactionMode}
3344 runtimeMode={runtimeMode}
3345 onToggleInteractionMode={toggleInteractionMode}
3346 onRuntimeModeChange={handleRuntimeModeChange}
3347 />
3348 </>
3349 )}
30463350 </div>
3047 )}
30483351
3049 <div className="relative">
3050 <ComposerPromptEditor
3051 editorRef={composerEditorRef}
3052 value={
3053 isComposerApprovalState
3054 ? ""
3055 : activePendingProgress
3056 ? activePendingProgress.customAnswer
3057 : prompt
3058 }
3059 cursor={composerCursor}
3060 terminalContexts={
3061 !isComposerApprovalState && pendingUserInputs.length === 0
3062 ? composerTerminalContexts
3063 : []
3064 }
3065 skills={selectedProviderStatus?.skills ?? []}
3066 {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
3067 onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
3068 onChange={onPromptChange}
3069 onCommandKeyDown={onComposerCommandKey}
3070 onPaste={onComposerPaste}
3071 placeholder={
3072 isComposerApprovalState
3073 ? (activePendingApproval?.detail ?? "Resolve this approval request to continue")
3074 : activePendingProgress
3075 ? "Type your own answer, or leave this blank to use the selected option"
3076 : showPlanFollowUpPrompt && activeProposedPlan
3077 ? "Add feedback to refine the plan, or leave this blank to implement it"
3078 : projectSelectionRequired
3079 ? "Choose a project above to start a thread"
3080 : noProviderAvailable
3081 ? "Enable a provider in Settings to send a message"
3082 : phase === "disconnected"
3083 ? DISCONNECTED_COMPOSER_PLACEHOLDER
3084 : "Ask anything, @tag files/folders, $use skills, or / for commands"
3085 }
3086 disabled={isConnecting || isComposerApprovalState || projectSelectionRequired}
3087 />
3088 {showMobilePendingAnswerActions ? (
3352 {/* Right side: send / stop button */}
30893353 <div
3090 data-chat-composer-mobile-pending-actions="true"
3091 className="absolute bottom-0 right-0 flex justify-end"
3354 data-chat-composer-actions="right"
3355 data-chat-composer-primary-actions-compact={
3356 isComposerPrimaryActionsCompact ? "true" : "false"
3357 }
3358 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
30923359 >
3093 <ComposerPrimaryActions
3094 compact
3360 {showMobilePendingAnswerActions ? null : inlineTasksBadge}
3361 {showMobilePendingAnswerActions ? null : inlineStashBadge}
3362 <ComposerFooterPrimaryActions
3363 compact={isComposerPrimaryActionsCompact}
3364 activeContextWindow={activeContextWindow}
3365 activeThreadModelDisplayName={activeThreadModelDisplayName}
30953366 pendingAction={pendingPrimaryAction}
3096 isRunning={false}
3097 showPlanFollowUpPrompt={false}
3098 promptHasText={false}
3367 isRunning={phase === "running"}
3368 showPlanFollowUpPrompt={
3369 pendingUserInputs.length === 0 && showPlanFollowUpPrompt
3370 }
3371 promptHasText={prompt.trim().length > 0}
30993372 isSendBusy={isSendBusy}
31003373 sendDisabledReason={sendDisabledReason}
31013374 isConnecting={isConnecting}
......@@ -3104,145 +3377,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31043377 noProviderAvailable ||
31053378 projectSelectionRequired
31063379 }
3107 isPreparingWorktree={false}
3108 hasSendableContent={false}
3109 preserveComposerFocusOnPointerDown
3380 isPreparingWorktree={isPreparingWorktree}
3381 hasSendableContent={composerSendState.hasSendableContent}
3382 preserveComposerFocusOnPointerDown={isMobileViewport}
3383 showSendWhileRunning={isMobileViewport}
31103384 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
31113385 onInterrupt={handleInterruptPrimaryAction}
31123386 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
31133387 />
31143388 </div>
3115 ) : null}
3116 </div>
3117 </div>
3118
3119 <ComposerPromptLengthValidation
3120 message={providerInputSubmissionError ?? composerSubmissionError}
3121 />
3122
3123 {/* Bottom toolbar */}
3124 {isComposerCollapsedMobile ? null : activePendingApproval ? (
3125 <div className="flex flex-wrap items-center justify-end gap-2 px-3 pb-3 sm:px-4 sm:pb-4">
3126 <ComposerPendingApprovalActions
3127 requestId={activePendingApproval.requestId}
3128 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
3129 onRespondToApproval={onRespondToApproval}
3130 />
3131 </div>
3132 ) : (
3133 <div
3134 data-chat-composer-footer="true"
3135 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
3136 className={cn(
3137 "flex min-w-0 flex-nowrap items-center justify-between gap-2 overflow-visible px-3 pb-3 sm:px-4 sm:pb-4",
3138 pendingUserInputs.length > 0 && "pt-2",
3139 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
3140 showMobilePendingAnswerActions && "hidden sm:flex",
3141 )}
3142 >
3143 <div className="-m-1 -ms-3.5 flex min-w-0 flex-1 items-center gap-1 overflow-x-auto p-1 ps-3.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
3144 {noProviderAvailable ? (
3145 <Button
3146 type="button"
3147 size="sm"
3148 variant="ghost"
3149 disabled
3150 data-chat-provider-unavailable="true"
3151 className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3"
3152 >
3153 <CircleAlertIcon className="size-4" />
3154 No provider available
3155 </Button>
3156 ) : (
3157 <ProviderModelPicker
3158 compact={isComposerFooterCompact}
3159 activeInstanceId={selectedInstanceId}
3160 model={selectedModelForPickerWithCustomFallback}
3161 lockedProvider={lockedProvider}
3162 lockedContinuationGroupKey={lockedContinuationGroupKey}
3163 instanceEntries={providerInstanceEntries}
3164 keybindings={keybindings}
3165 modelOptionsByInstance={modelOptionsByInstance}
3166 triggerClassName="-ms-2.5"
3167 terminalOpen={terminalOpen}
3168 open={isComposerModelPickerOpen}
3169 {...(composerProviderState.modelPickerIconClassName
3170 ? {
3171 activeProviderIconClassName:
3172 composerProviderState.modelPickerIconClassName,
3173 }
3174 : {})}
3175 onOpenChange={(open) => {
3176 setIsComposerModelPickerOpen(open);
3177 }}
3178 getModelDisabledReason={getModelDisabledReason}
3179 onInstanceModelChange={onProviderModelSelect}
3180 />
3181 )}
3182
3183 {isComposerFooterCompact ? (
3184 <CompactComposerControlsMenu
3185 interactionMode={interactionMode}
3186 runtimeMode={runtimeMode}
3187 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
3188 traitsMenuContent={providerTraitsMenuContent}
3189 onToggleInteractionMode={toggleInteractionMode}
3190 onRuntimeModeChange={handleRuntimeModeChange}
3191 />
3192 ) : (
3193 <>
3194 {providerTraitsPicker ? (
3195 <>
3196 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3197 {providerTraitsPicker}
3198 </>
3199 ) : null}
3200 <ComposerFooterModeControls
3201 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
3202 interactionMode={interactionMode}
3203 runtimeMode={runtimeMode}
3204 onToggleInteractionMode={toggleInteractionMode}
3205 onRuntimeModeChange={handleRuntimeModeChange}
3206 />
3207 </>
3208 )}
3209 </div>
3210
3211 {/* Right side: send / stop button */}
3212 <div
3213 data-chat-composer-actions="right"
3214 data-chat-composer-primary-actions-compact={
3215 isComposerPrimaryActionsCompact ? "true" : "false"
3216 }
3217 className="flex shrink-0 flex-nowrap items-center justify-end gap-2"
3218 >
3219 <ComposerFooterPrimaryActions
3220 compact={isComposerPrimaryActionsCompact}
3221 activeContextWindow={activeContextWindow}
3222 activeThreadModelDisplayName={activeThreadModelDisplayName}
3223 pendingAction={pendingPrimaryAction}
3224 isRunning={phase === "running"}
3225 showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt}
3226 promptHasText={prompt.trim().length > 0}
3227 isSendBusy={isSendBusy}
3228 sendDisabledReason={sendDisabledReason}
3229 isConnecting={isConnecting}
3230 isEnvironmentUnavailable={
3231 environmentUnavailable !== null ||
3232 noProviderAvailable ||
3233 projectSelectionRequired
3234 }
3235 isPreparingWorktree={isPreparingWorktree}
3236 hasSendableContent={composerSendState.hasSendableContent}
3237 preserveComposerFocusOnPointerDown={isMobileViewport}
3238 showSendWhileRunning={isMobileViewport}
3239 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3240 onInterrupt={handleInterruptPrimaryAction}
3241 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3242 />
32433389 </div>
3244 </div>
3245 )}
3390 )}
3391 </div>
32463392 </div>
32473393 </div>
32483394 </form>
apps/web/src/components/chat/ComposerBannerStack.test.tsx+7-3
......@@ -36,7 +36,9 @@ describe("ComposerBannerStack", () => {
3636 const neutralBehind = renderToStaticMarkup(
3737 <ComposerBannerStack items={[banner("front", "default"), banner("stacked", "default")]} />,
3838 );
39 expect(neutralBehind).toContain("border-border");
39 expect(neutralBehind).toContain("chat-composer-banner-stack-cap");
40 expect(neutralBehind).toContain("border-[var(--chat-composer-attached-outline)]");
41 expect(neutralBehind).not.toContain("border-border");
4042 expect(neutralBehind).not.toContain("border-warning/24");
4143
4244 const warningBehind = renderToStaticMarkup(
......@@ -49,12 +51,14 @@ describe("ComposerBannerStack", () => {
4951 const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />);
5052
5153 expect(markup).not.toContain("data-composer-banner-stack-expanded-items");
52 expect(markup).toContain("alert-glass");
54 expect(markup).toContain("chat-composer-drawer-surface");
55 expect(markup).toContain("chat-composer-drawer-attached");
56 expect(markup).toContain("text-xs");
57 expect(markup).toContain('data-composer-banner-drawer="true"');
5358 expect(markup).toContain('data-variant="warning"');
5459 expect(markup).toContain("transform:none");
5560 expect(markup).not.toContain("will-change:transform");
5661 });
57
5862 it("applies item-specific surface and action layout classes", () => {
5963 const markup = renderToStaticMarkup(
6064 <ComposerBannerStack
apps/web/src/components/chat/ComposerBannerStack.tsx+17-5
......@@ -26,7 +26,7 @@ const exitTransitionStyle = {
2626// banners are stacked behind it, so its border must match the severity of the
2727// first hidden banner — a neutral banner must not masquerade as a warning.
2828const stackCapBorderClass: Record<ComposerBannerStackItem["variant"], string> = {
29 default: "border-border",
29 default: "border-[var(--chat-composer-attached-outline)]",
3030 error: "border-destructive/24",
3131 info: "border-info/24",
3232 success: "border-success/24",
......@@ -98,7 +98,10 @@ 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("group/banner-stack chat-composer-drawer-slot", className)}
103 data-composer-banner-drawer="true"
104 >
102105 <div
103106 className={cn(
104107 "relative flex flex-col-reverse",
......@@ -108,8 +111,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
108111 {showCollapsedStackCap && firstStackedItem ? (
109112 <div
110113 className={cn(
111 "pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-[22px]",
112 "border border-b-0 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",
114 "pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-2xl",
115 "chat-composer-banner-stack-cap border border-b-0 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",
113116 stackCapBorderClass[firstStackedItem.variant],
114117 "transition-opacity duration-150 ease-out",
115118 "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0",
......@@ -130,6 +133,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
130133 >
131134 <ComposerBannerStackAlert
132135 item={frontItem}
136 attached
133137 exiting={exitingItemId === frontItem.id}
134138 onDismissRequest={() => requestDismiss(frontItem)}
135139 />
......@@ -162,6 +166,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
162166 >
163167 <ComposerBannerStackAlert
164168 item={item}
169 attached={false}
165170 exiting={exitingItemId === item.id}
166171 onDismissRequest={() => requestDismiss(item)}
167172 />
......@@ -178,10 +183,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
178183
179184function ComposerBannerStackAlert({
180185 item,
186 attached,
181187 exiting,
182188 onDismissRequest,
183189}: {
184190 readonly item: ComposerBannerStackItem;
191 readonly attached: boolean;
185192 readonly exiting: boolean;
186193 readonly onDismissRequest: () => void;
187194}) {
......@@ -190,7 +197,12 @@ function ComposerBannerStackAlert({
190197 return (
191198 <Alert
192199 variant={item.variant}
193 className={cn("alert-glass rounded-[22px]", item.className)}
200 className={cn(
201 attached
202 ? "chat-composer-drawer-surface chat-composer-drawer-attached px-3 pt-2 pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)] text-xs sm:px-4"
203 : "alert-glass rounded-[22px]",
204 item.className,
205 )}
194206 data-variant={item.variant}
195207 >
196208 {item.icon}
apps/web/src/components/chat/ComposerCommandMenu.test.tsx created+89
......@@ -0,0 +1,89 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { ProviderDriverKind } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4
5import { ComposerCommandMenu } from "./ComposerCommandMenu";
6
7describe("ComposerCommandMenu", () => {
8 it("renders slash-command results as an attached composer drawer", () => {
9 const markup = renderToStaticMarkup(
10 <ComposerCommandMenu
11 items={[]}
12 resolvedTheme="dark"
13 isLoading={false}
14 triggerKind="slash-command"
15 activeItemId={null}
16 onHighlightedItemChange={() => {}}
17 onSelect={() => {}}
18 />,
19 );
20
21 expect(markup).toContain('data-composer-command-drawer="true"');
22 expect(markup).toContain("chat-composer-drawer-surface");
23 expect(markup).toContain("chat-composer-drawer-attached");
24 expect(markup).not.toContain("dropdown-glass");
25 });
26
27 it("renders commands without a category heading or invented icons", () => {
28 const markup = renderToStaticMarkup(
29 <ComposerCommandMenu
30 items={[
31 {
32 id: "slash:model",
33 type: "slash-command",
34 command: "model",
35 label: "/model",
36 description: "Switch response model for this thread",
37 },
38 ]}
39 resolvedTheme="dark"
40 isLoading={false}
41 triggerKind="slash-command"
42 activeItemId="slash:model"
43 onHighlightedItemChange={() => {}}
44 onSelect={() => {}}
45 />,
46 );
47
48 expect(markup).toContain("/model");
49 expect(markup).toContain("Switch response model for this thread");
50 expect(markup).not.toContain("Built-in");
51 expect(markup).not.toContain("<svg");
52 expect(markup).toContain("font-sans text-xs font-medium");
53 expect(markup).not.toContain("font-mono");
54 expect(markup).toContain("text-right");
55 });
56
57 it("renders a skill source icon with an accessible source label", () => {
58 const markup = renderToStaticMarkup(
59 <ComposerCommandMenu
60 items={[
61 {
62 id: "skill:codex:browser",
63 type: "skill",
64 provider: ProviderDriverKind.make("codex"),
65 skill: {
66 name: "browser",
67 path: "/Users/maria/.codex/plugins/browser/skills/browser/SKILL.md",
68 scope: "user",
69 enabled: true,
70 },
71 label: "Browser",
72 description: "Open and control the in-app browser",
73 },
74 ]}
75 resolvedTheme="dark"
76 isLoading={false}
77 triggerKind="skill"
78 activeItemId="skill:codex:browser"
79 onHighlightedItemChange={() => {}}
80 onSelect={() => {}}
81 />,
82 );
83
84 expect(markup).toContain("Browser");
85 expect(markup).toContain('<span class="sr-only">App skill</span>');
86 expect(markup).toContain("<svg");
87 expect(markup).toContain("text-icon-muted");
88 });
89});
apps/web/src/components/chat/ComposerCommandMenu.tsx+77-134
......@@ -1,23 +1,27 @@
1import {
2 resolveProviderSkillSourceKind,
3 type ProviderSkillSourceKind,
4} from "@t3tools/client-runtime/providerSkills";
15import {
26 type ProjectEntry,
37 type ProviderDriverKind,
48 type ServerProviderSkill,
59 type ServerProviderSlashCommand,
610} from "@t3tools/contracts";
7import { BotIcon } from "lucide-react";
8import { memo, useLayoutEffect, useMemo, useRef } from "react";
11import {
12 BlocksIcon,
13 FolderGit2Icon,
14 FolderIcon,
15 PackageIcon,
16 SettingsIcon,
17 UserRoundIcon,
18 type LucideIcon,
19} from "lucide-react";
20import { memo, useLayoutEffect, useRef } from "react";
921
1022import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
11import { formatProviderSkillInstallSource } from "~/providerSkillPresentation";
1223import { cn } from "~/lib/utils";
13import {
14 Command,
15 CommandGroup,
16 CommandGroupLabel,
17 CommandItem,
18 CommandList,
19 CommandSeparator,
20} from "../ui/command";
24import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
2125import { PierreEntryIcon } from "./PierreEntryIcon";
2226
2327export type ComposerCommandItem =
......@@ -53,73 +57,17 @@ export type ComposerCommandItem =
5357 description: string;
5458 };
5559
56type ComposerCommandGroup = {
57 id: string;
58 label: string | null;
59 items: ComposerCommandItem[];
60};
61
62function 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
81function 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
10660export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
10761 items: ComposerCommandItem[];
10862 resolvedTheme: "light" | "dark";
10963 isLoading: boolean;
11064 triggerKind: ComposerTriggerKind | null;
111 groupSlashCommandSections?: boolean;
11265 emptyStateText?: string;
11366 activeItemId: string | null;
11467 onHighlightedItemChange: (itemId: string | null) => void;
11568 onSelect: (item: ComposerCommandItem) => void;
11669}) {
11770 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 );
12371
12472 useLayoutEffect(() => {
12573 if (!props.activeItemId || !listRef.current) return;
......@@ -141,57 +89,38 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
14189 >
14290 <div
14391 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%)]"
92 className="chat-composer-drawer-surface chat-composer-drawer-attached relative w-full overflow-hidden **:data-[slot=scroll-area-scrollbar]:data-[orientation=vertical]:my-4"
93 data-composer-command-drawer="true"
14594 >
14695 {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 ))}
96 <CommandList className="max-h-72 scroll-pb-6">
97 <CommandGroup>
98 {props.items.map((item) => (
99 <ComposerCommandMenuItem
100 key={item.id}
101 item={item}
102 resolvedTheme={props.resolvedTheme}
103 isActive={props.activeItemId === item.id}
104 onHighlight={props.onHighlightedItemChange}
105 onSelect={props.onSelect}
106 />
107 ))}
108 </CommandGroup>
170109 </CommandList>
171110 ) : (
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"
111 <div className="px-5 pt-3.5 pb-7">
112 <p className="text-secondary-label text-xs">
113 {props.isLoading
114 ? props.triggerKind === "skill"
115 ? "Searching workspace skills..."
116 : "Searching workspace files..."
117 : (props.emptyStateText ??
118 (props.triggerKind === "skill"
119 ? "No skills found. Try / to browse provider commands."
120 : props.triggerKind === "path"
191121 ? "No matching files or folders."
192122 : "No matching command."))}
193 </p>
194 )}
123 </p>
195124 </div>
196125 )}
197126 </div>
......@@ -206,15 +135,15 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
206135 onHighlight: (itemId: string | null) => void;
207136 onSelect: (item: ComposerCommandItem) => void;
208137}) {
209 const skillSourceLabel =
210 props.item.type === "skill" ? formatProviderSkillInstallSource(props.item.skill) : null;
138 const skillSourceKind =
139 props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null;
211140
212141 return (
213142 <CommandItem
214143 value={props.item.id}
215144 data-composer-item-id={props.item.id}
216145 className={cn(
217 "cursor-pointer select-none gap-2 hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit",
146 "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",
218147 props.isActive && "bg-accent! text-accent-foreground!",
219148 )}
220149 onMouseMove={() => {
......@@ -233,29 +162,43 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
233162 kind={props.item.pathKind}
234163 theme={props.resolvedTheme}
235164 />
165 ) : skillSourceKind ? (
166 <SkillSourceIcon kind={skillSourceKind} />
236167 ) : 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" />
243 </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">
168 <span className="flex min-w-0 flex-1 items-baseline gap-3">
169 <span className="shrink-0 font-sans text-xs font-medium">{props.item.label}</span>
170 <span className="min-w-0 flex-1 truncate text-right text-secondary-label text-xs">
253171 {props.item.description}
254172 </span>
255173 </span>
256 {skillSourceLabel ? (
257 <span className="shrink-0 pl-2 text-secondary-label text-xs">{skillSourceLabel}</span>
258 ) : null}
259174 </CommandItem>
260175 );
261176});
177
178const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {
179 app: BlocksIcon,
180 repo: FolderGit2Icon,
181 project: FolderIcon,
182 personal: UserRoundIcon,
183 system: SettingsIcon,
184 other: PackageIcon,
185};
186
187const SKILL_SOURCE_LABEL_BY_KIND: Record<ProviderSkillSourceKind, string> = {
188 app: "App",
189 repo: "Repo",
190 project: "Project",
191 personal: "Personal",
192 system: "System",
193 other: "Other",
194};
195
196function SkillSourceIcon(props: { kind: ProviderSkillSourceKind }) {
197 const Icon = SKILL_SOURCE_ICON_BY_KIND[props.kind];
198 return (
199 <>
200 <Icon aria-hidden="true" className="size-4 shrink-0 text-icon-muted" />
201 <span className="sr-only">{SKILL_SOURCE_LABEL_BY_KIND[props.kind]} skill</span>
202 </>
203 );
204}
apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx created+24
......@@ -0,0 +1,24 @@
1import { ApprovalRequestId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";
4
5import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
6
7describe("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 expect(markup).toContain("h-5");
21 expect(markup).toContain("sm:text-[11px]");
22 expect(markup).not.toContain("sm:h-6");
23 });
24});
apps/web/src/components/chat/ComposerPendingApprovalActions.tsx+16-10
......@@ -11,6 +11,8 @@ interface ComposerPendingApprovalActionsProps {
1111 ) => Promise<unknown>;
1212}
1313
14const APPROVAL_ACTION_CLASS_NAME = "font-normal";
15
1416export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({
1517 requestId,
1618 isResponding,
......@@ -19,36 +21,40 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
1921 return (
2022 <>
2123 <Button
22 size="sm"
23 variant="ghost"
24 size="micro"
25 variant="ghost-muted"
26 className={APPROVAL_ACTION_CLASS_NAME}
2427 disabled={isResponding}
2528 onClick={() => void onRespondToApproval(requestId, "cancel")}
2629 >
27 Cancel turn
30 Cancel
2831 </Button>
2932 <Button
30 size="sm"
31 variant="destructive-outline"
33 size="micro"
34 variant="ghost-muted"
35 className={`${APPROVAL_ACTION_CLASS_NAME} text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground`}
3236 disabled={isResponding}
3337 onClick={() => void onRespondToApproval(requestId, "decline")}
3438 >
3539 Decline
3640 </Button>
3741 <Button
38 size="sm"
39 variant="outline"
42 size="micro"
43 variant="ghost-muted"
44 className={APPROVAL_ACTION_CLASS_NAME}
4045 disabled={isResponding}
4146 onClick={() => void onRespondToApproval(requestId, "acceptForSession")}
4247 >
4348 Always allow this session
4449 </Button>
4550 <Button
46 size="sm"
47 variant="default"
51 size="micro"
52 variant="ghost-muted"
53 className={`${APPROVAL_ACTION_CLASS_NAME} text-foreground`}
4854 disabled={isResponding}
4955 onClick={() => void onRespondToApproval(requestId, "accept")}
5056 >
51 Approve once
57 Approve
5258 </Button>
5359 </>
5460 );
apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx+25-3
......@@ -5,7 +5,7 @@ import { describe, expect, it } from "vite-plus/test";
55import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
66
77describe("ComposerPendingApprovalPanel", () => {
8 it("renders complete multiline command details without hover or truncation", () => {
8 it("keeps the complete command readable in the compact row", () => {
99 const detail = `bun run release -- ${"x".repeat(500)}\nsecond line`;
1010 const markup = renderToStaticMarkup(
1111 <ComposerPendingApprovalPanel
......@@ -21,11 +21,33 @@ describe("ComposerPendingApprovalPanel", () => {
2121
2222 expect(markup).toContain('data-approval-detail="complete"');
2323 expect(markup).toContain('aria-label="Command"');
24 expect(markup).toContain('role="group"');
25 expect(markup).toContain('tabindex="0"');
2426 expect(markup).toContain(detail);
27 expect(markup).toContain("max-h-20");
28 expect(markup).toContain("overflow-auto");
29 expect(markup).toContain("whitespace-pre");
30 expect(markup).toContain("[scrollbar-width:thin]");
31 expect(markup).toContain("[&amp;::-webkit-scrollbar]:h-1.5");
2532 expect(markup).not.toContain("truncate");
2633 expect(markup).not.toContain("line-clamp");
2734 expect(markup).toContain("min-w-0");
28 expect(markup).toContain("max-w-full");
29 expect(markup).toContain("[overflow-wrap:anywhere]");
35 expect(markup).not.toContain("Command approval requested");
36 });
37
38 it("falls back to the approval kind when the provider sends an empty detail", () => {
39 const markup = renderToStaticMarkup(
40 <ComposerPendingApprovalPanel
41 approval={{
42 requestId: ApprovalRequestId.make("approval-2"),
43 requestKind: "file-read",
44 createdAt: "2026-07-18T00:00:00.000Z",
45 detail: "",
46 }}
47 pendingCount={1}
48 />,
49 );
50
51 expect(markup).toContain("File read approval");
3052 });
3153});
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+25-24
......@@ -1,22 +1,25 @@
11import { memo } from "react";
22import { type PendingApproval } from "../../session-logic";
3import { cn } from "~/lib/utils";
34
45interface ComposerPendingApprovalPanelProps {
56 approval: PendingApproval;
67 pendingCount: number;
8 className?: string;
79}
810
911export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprovalPanel({
1012 approval,
1113 pendingCount,
14 className,
1215}: ComposerPendingApprovalPanelProps) {
13 const approvalSummary =
16 const fallbackLabel =
1417 approval.requestKind === "command"
15 ? "Command approval requested"
18 ? "Command approval"
1619 : approval.requestKind === "file-read"
17 ? "File-read approval requested"
18 : "File-change approval requested";
19 const detailLabel =
20 ? "File read approval"
21 : "File change approval";
22 const detailAriaLabel =
2023 approval.requestKind === "command"
2124 ? "Command"
2225 : approval.requestKind === "file-read"
......@@ -24,25 +27,23 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
2427 : "File change";
2528
2629 return (
27 <div className="min-w-0 px-4 py-3.5 sm:px-5 sm:py-4">
28 <div className="flex flex-wrap items-center gap-2">
29 <span className="uppercase text-sm tracking-[0.2em]">PENDING APPROVAL</span>
30 <span className="text-sm font-medium">{approvalSummary}</span>
31 {pendingCount > 1 ? (
32 <span className="text-xs text-muted-foreground">1/{pendingCount}</span>
33 ) : null}
34 </div>
35 {approval.detail ? (
36 <div className="mt-3 min-w-0 max-w-full rounded-lg border border-border/65 bg-background/70 p-3">
37 <p className="text-xs font-medium text-muted-foreground">{detailLabel}</p>
38 <pre
39 aria-label={detailLabel}
40 className="mt-2 min-w-0 max-w-full max-h-40 overflow-auto whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-relaxed text-foreground"
41 data-approval-detail="complete"
42 >
43 {approval.detail}
44 </pre>
45 </div>
30 <div
31 aria-label={fallbackLabel}
32 className={cn("flex min-w-0 flex-1 items-center gap-2", className)}
33 role="group"
34 >
35 <code
36 aria-label={detailAriaLabel}
37 className="block max-h-20 min-w-0 flex-1 overflow-auto whitespace-pre font-mono text-[11px] text-foreground/85 [scrollbar-width:thin] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70 [&::-webkit-scrollbar]:h-1.5"
38 data-approval-detail="complete"
39 tabIndex={0}
40 >
41 {approval.detail || fallbackLabel}
42 </code>
43 {pendingCount > 1 ? (
44 <span className="shrink-0 text-[10px] font-medium text-muted-foreground tabular-nums">
45 1/{pendingCount}
46 </span>
4647 ) : null}
4748 </div>
4849 );
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+13-14
......@@ -167,7 +167,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
167167
168168 return (
169169 <Collapsible
170 className="py-3"
170 className="py-2"
171171 open={!isCollapsed}
172172 onOpenChange={(open) => {
173173 setCollapsedQuestionId(open ? null : activeQuestion.id);
......@@ -179,19 +179,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
179179 header label and the chevron still line up with the left and right
180180 edges of the question text below. The negative block margin keeps the
181181 taller hit area from pushing the panel down. */}
182 <div className="px-1.5 sm:px-2.5">
182 <div className="flex items-center gap-1 px-1 sm:px-2">
183183 <CollapsibleTrigger
184184 title={
185185 isCollapsed ? "Show the question and its options" : "Hide the question and its options"
186186 }
187187 data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}
188 className="group -my-1 flex w-full items-center gap-3 rounded-md px-2.5 py-1.5 text-left outline-none transition-colors duration-150 hover:bg-muted/40 focus-visible:ring-1 focus-visible:ring-primary/25"
188 className="group -my-1 flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors duration-150 hover:bg-muted/35 focus-visible:ring-1 focus-visible:ring-primary/25"
189189 >
190 <span className="text-secondary-label text-[11px] font-semibold tracking-widest uppercase group-hover:text-foreground">
190 <span className="text-xs font-medium text-muted-foreground group-hover:text-foreground/85">
191191 {activeQuestion.header}
192192 </span>
193193 {prompt.questions.length > 1 ? (
194 <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-secondary-label text-[10px] font-medium tabular-nums">
194 <span className="text-[10px] font-medium text-muted-foreground tabular-nums">
195195 {questionIndex + 1}/{prompt.questions.length}
196196 </span>
197197 ) : null}
......@@ -217,13 +217,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
217217 {/* The panel carries the horizontal padding itself: it clips its content
218218 while the height animates, so the option buttons have to sit inside
219219 that padding or their focus rings get shaved off at the edges. */}
220 <CollapsiblePanel className="px-4 sm:px-5">
220 <CollapsiblePanel className="px-3 sm:px-4">
221221 <div className="pt-2 pb-0.5">
222 <p className="text-sm text-foreground/90">{activeQuestion.question}</p>
222 <p className="text-sm text-foreground/85">{activeQuestion.question}</p>
223223 {activeQuestion.multiSelect ? (
224224 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
225225 ) : null}
226 <div className="mt-3 space-y-1.5">
226 <div className="mt-2 space-y-0.5">
227227 {activeQuestion.options.map((option, index) => {
228228 const isOptimisticallySelected =
229229 optimisticSingleSelect?.questionId === activeQuestion.id &&
......@@ -233,10 +233,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
233233 (!customAnswerActive && progress.selectedOptionLabels.includes(option.label));
234234 const shortcutKey = index < 9 ? index + 1 : null;
235235 const className = cn(
236 "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25",
236 "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-primary/25",
237237 isSelected
238 ? "border-primary/30 bg-primary/8 text-foreground"
239 : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34",
238 ? "bg-muted/55 text-foreground"
239 : "bg-transparent text-foreground/85 hover:bg-muted/30",
240240 isResponding && "opacity-50 cursor-not-allowed",
241241 !isResponding && "cursor-pointer",
242242 );
......@@ -245,7 +245,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
245245 <div className="min-w-0 flex-1 flex flex-col gap-0.5">
246246 <span className="text-sm font-medium">{option.label}</span>
247247 {option.description && option.description !== option.label ? (
248 <span className="text-secondary-label text-xs">{option.description}</span>
248 <span className="text-secondary-label text-[11px]">{option.description}</span>
249249 ) : null}
250250 </div>
251251 {isSelected ? (
......@@ -253,8 +253,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
253253 ) : shortcutKey !== null ? (
254254 <kbd
255255 className={cn(
256 "flex size-5 shrink-0 items-center justify-center rounded border border-border/50 text-[11px] font-medium tabular-nums transition-colors duration-150",
257 "bg-background/35 text-secondary-label group-hover:border-border/70 group-hover:text-foreground",
256 "flex size-5 shrink-0 items-center justify-center text-[10px] font-medium text-muted-foreground tabular-nums",
258257 )}
259258 >
260259 {shortcutKey}
apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx+3-10
......@@ -1,5 +1,4 @@
11import { memo } from "react";
2import { Badge } from "../ui/badge";
32
43export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({
54 planTitle,
......@@ -7,17 +6,11 @@ export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBann
76 planTitle: string | null;
87}) {
98 return (
10 <div className="px-4 py-3.5 sm:px-5 sm:py-4">
9 <div className="px-3 py-2 sm:px-4">
1110 <div className="flex flex-wrap items-center gap-2">
12 <Badge
13 variant="info"
14 size="sm"
15 className="rounded-md px-1.5 py-0 font-semibold tracking-wide uppercase"
16 >
17 Plan Ready
18 </Badge>
11 <span className="text-xs font-medium text-muted-foreground">Plan ready</span>
1912 {planTitle ? (
20 <span className="min-w-0 flex-1 truncate text-sm font-medium">{planTitle}</span>
13 <span className="min-w-0 flex-1 truncate text-xs text-foreground/85">{planTitle}</span>
2114 ) : null}
2215 </div>
2316 {/* <div className="mt-2 text-xs text-muted-foreground">
apps/web/src/components/chat/ComposerStashBadge.test.tsx created+68
......@@ -0,0 +1,68 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerStashBadge } from "./ComposerStashBadge";
5
6describe("ComposerStashBadge", () => {
7 it("renders as an attached composer tab instead of a floating pill", () => {
8 const markup = renderToStaticMarkup(
9 <ComposerStashBadge
10 count={3}
11 menuOpen={false}
12 pulseKey={0}
13 pulsing={false}
14 onToggleMenu={() => {}}
15 />,
16 );
17
18 expect(markup).toContain("chat-composer-stash-tab");
19 expect(markup).toContain("rounded-t-xl");
20 expect(markup).toContain("border-b-0");
21 expect(markup).toContain("items-center");
22 expect(markup).not.toContain("items-start");
23 expect(markup).not.toContain("rounded-full");
24 expect(markup).not.toContain("opacity-70");
25 expect(markup).not.toContain("border-border");
26 expect(markup).toContain('aria-expanded="false"');
27 });
28
29 it("reports when the stash drawer is open", () => {
30 const markup = renderToStaticMarkup(
31 <ComposerStashBadge
32 count={3}
33 menuOpen
34 pulseKey={0}
35 pulsing={false}
36 onToggleMenu={() => {}}
37 />,
38 );
39
40 expect(markup).toContain('aria-expanded="true"');
41 expect(markup).toContain("text-foreground");
42 expect(markup).toContain("pointer-events-none");
43 expect(markup).not.toContain("invisible");
44 });
45
46 it("keeps a compact inline entry point when a drawer occupies the tab", () => {
47 const markup = renderToStaticMarkup(
48 <ComposerStashBadge
49 count={3}
50 menuOpen
51 placement="inline"
52 pulseKey={0}
53 pulsing={false}
54 onToggleMenu={() => {}}
55 />,
56 );
57
58 expect(markup).toContain('data-slot="button"');
59 expect(markup).toContain("rounded-sm");
60 expect(markup).toContain("focus-visible:ring-2");
61 expect(markup).toContain("pointer-coarse:after:min-h-11");
62 expect(markup).toContain("Stashed prompts: 3. Open stash.");
63 expect(markup).toContain('aria-expanded="true"');
64 expect(markup).not.toContain("chat-composer-stash-tab");
65 expect(markup).not.toContain("rounded-t-xl");
66 expect(markup).not.toContain(" pointer-events-none ");
67 });
68});
apps/web/src/components/chat/ComposerStashBadge.tsx+49-19
......@@ -2,10 +2,12 @@ import { BookmarkIcon } from "lucide-react";
22import { memo } from "react";
33
44import { cn } from "~/lib/utils";
5import { Button } from "../ui/button";
56
67/**
7 * Bookmark pill perched on the composer's top-right shoulder. Shows the
8 * stash count and doubles as the click target for opening the stash menu.
8 * Bookmark control that shows the stash count and opens the stash menu. It
9 * sits behind the composer's shoulder unless another drawer needs that space,
10 * then moves into the composer's existing controls.
911 *
1012 * On save the badge gives one quiet acknowledgement: it lifts to full
1113 * opacity and the count ticks over. `pulseKey` changes per stash, remounting
......@@ -13,12 +15,49 @@ import { cn } from "~/lib/utils";
1315 */
1416export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
1517 count: number;
18 menuOpen: boolean;
19 placement?: "inline" | "tab";
1620 pulseKey: number;
1721 pulsing: boolean;
18 menuOpen: boolean;
1922 onToggleMenu: () => void;
2023}) {
2124 if (props.count === 0) return null;
25 const inline = props.placement === "inline";
26 const count = (
27 <span
28 key={props.pulseKey}
29 className={cn(
30 "text-[10px] font-medium leading-none tabular-nums",
31 props.pulsing
32 ? "animate-[prompt-stash-count-enter_180ms_ease-out_both] text-primary motion-reduce:animate-none"
33 : "text-muted-foreground",
34 )}
35 >
36 {props.count}
37 </span>
38 );
39
40 if (inline) {
41 return (
42 <Button
43 size="micro"
44 variant="ghost-muted"
45 data-prompt-stash-badge="true"
46 aria-label={`Stashed prompts: ${props.count}. Open stash.`}
47 aria-expanded={props.menuOpen}
48 className={cn(
49 "shrink-0 gap-1 px-1.5",
50 (props.menuOpen || props.pulsing) &&
51 "[--control-icon-color:currentColor] text-foreground",
52 )}
53 onPointerDown={(event) => event.preventDefault()}
54 onClick={props.onToggleMenu}
55 >
56 <BookmarkIcon className="size-3 shrink-0" aria-hidden="true" />
57 {count}
58 </Button>
59 );
60 }
2261
2362 return (
2463 <button
......@@ -27,11 +66,12 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
2766 aria-label={`Stashed prompts: ${props.count}. Open stash.`}
2867 aria-expanded={props.menuOpen}
2968 className={cn(
30 "absolute -top-3 right-4 z-10 inline-flex cursor-pointer items-center gap-1.5 rounded-full border bg-popover px-2.5 py-0.5 text-xs shadow-sm",
31 "transition-[color,border-color,opacity] duration-200",
69 "chat-composer-shoulder-tab chat-composer-stash-tab absolute -top-7 right-4 z-0 inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-t-xl border border-b-0 px-3 pb-1 text-xs leading-none",
70 "transition-[color,border-color] duration-200",
71 props.menuOpen && "pointer-events-none",
3272 props.menuOpen || props.pulsing
33 ? "border-border text-foreground opacity-100"
34 : "border-border/70 text-muted-foreground opacity-70 hover:opacity-100 hover:text-foreground",
73 ? "text-foreground"
74 : "text-muted-foreground hover:text-foreground",
3575 )}
3676 onPointerDown={(event) => {
3777 // Keep composer focus so Escape/typing flows stay intact.
......@@ -39,19 +79,9 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
3979 }}
4080 onClick={props.onToggleMenu}
4181 >
42 <BookmarkIcon className="size-3" aria-hidden="true" />
82 <BookmarkIcon className="size-3 shrink-0" aria-hidden="true" />
4383 Stash
44 <span
45 key={props.pulseKey}
46 className={cn(
47 "rounded-full px-1.5 text-[10px] font-medium tabular-nums",
48 props.pulsing
49 ? "animate-[prompt-stash-count-enter_180ms_ease-out_both] bg-primary text-primary-foreground motion-reduce:animate-none"
50 : "bg-muted text-muted-foreground",
51 )}
52 >
53 {props.count}
54 </span>
84 {count}
5585 </button>
5686 );
5787});
apps/web/src/components/chat/ComposerStashMenu.test.tsx created+74
......@@ -0,0 +1,74 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerStashMenu } from "./ComposerStashMenu";
5
6describe("ComposerStashMenu", () => {
7 it("renders saved prompts as an attached composer drawer", () => {
8 const markup = renderToStaticMarkup(
9 <ComposerStashMenu
10 entries={[]}
11 onRestore={() => {}}
12 onDelete={() => {}}
13 onClose={() => {}}
14 />,
15 );
16
17 expect(markup).toContain('data-composer-stash-drawer="true"');
18 expect(markup).toContain("chat-composer-drawer-surface");
19 expect(markup).toContain("chat-composer-drawer-attached");
20 expect(markup).toContain('aria-label="Close stash"');
21 expect(markup).not.toContain("dropdown-glass");
22 expect(markup).not.toContain("Stashed prompts");
23 });
24
25 it("shows saved image thumbnails and incomplete image states", () => {
26 const markup = renderToStaticMarkup(
27 <ComposerStashMenu
28 entries={[
29 {
30 id: "with-images",
31 createdAt: new Date(0).toISOString(),
32 prompt: "Compare these screenshots",
33 attachments: [
34 {
35 id: "image-one",
36 name: "before.png",
37 mimeType: "image/png",
38 sizeBytes: 128,
39 dataUrl: "data:image/png;base64,AA==",
40 },
41 ],
42 droppedImageNames: ["after.png"],
43 unreadableImageNames: [],
44 pendingImageCount: 0,
45 },
46 {
47 id: "saving-images",
48 createdAt: new Date(0).toISOString(),
49 prompt: "Save this image",
50 attachments: [],
51 droppedImageNames: [],
52 unreadableImageNames: [],
53 pendingImageCount: 1,
54 },
55 ]}
56 onRestore={() => {}}
57 onDelete={() => {}}
58 onClose={() => {}}
59 />,
60 );
61
62 expect(markup).toContain('src="data:image/png;base64,AA=="');
63 expect(markup).toContain("1 image dropped");
64 expect(markup).toContain("saving 1 image");
65 expect(markup).not.toContain("absolute top-1/2 right-2");
66 expect(markup).toContain("pointer-events-none");
67 expect(markup).toContain("pointer-coarse:pointer-events-auto");
68 expect(markup).toContain("pointer-coarse:opacity-100");
69 expect(markup).not.toContain("bg-popover!");
70 expect(markup).toContain("[--control-icon-color:currentColor]");
71 expect(markup).toContain("size-3.5 stroke-2");
72 expect(markup).not.toContain("bg-background/90");
73 });
74});
apps/web/src/components/chat/ComposerStashMenu.tsx+56-30
......@@ -1,10 +1,10 @@
1import { BookmarkIcon, XIcon } from "lucide-react";
2import { memo, useEffect, useState } from "react";
1import { XIcon } from "lucide-react";
2import { memo, useEffect, useRef, useState } from "react";
33
44import { formatRelativeTimeLabel } from "../../timestampFormat";
55import { cn } from "~/lib/utils";
66import { type PromptStashEntry } from "../../promptStashStore";
7import { Command, CommandGroup, CommandGroupLabel, CommandItem, CommandList } from "../ui/command";
7import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
88import { Button } from "../ui/button";
99
1010const SNIPPET_MAX_CHARS = 90;
......@@ -36,10 +36,27 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
3636 onClose: () => void;
3737}) {
3838 const { entries, onRestore, onDelete, onClose } = props;
39 const drawerRef = useRef<HTMLDivElement>(null);
3940 const [highlightedId, setHighlightedId] = useState<string | null>(entries[0]?.id ?? null);
4041
4142 const highlightedEntry = entries.find((entry) => entry.id === highlightedId) ?? entries[0];
4243
44 useEffect(() => {
45 const closeOnOutsidePointer = (event: PointerEvent) => {
46 const drawer = drawerRef.current;
47 if (
48 (drawer && event.composedPath().includes(drawer)) ||
49 (event.target instanceof Element &&
50 event.target.closest('[data-prompt-stash-badge="true"]'))
51 ) {
52 return;
53 }
54 onClose();
55 };
56 document.addEventListener("pointerdown", closeOnOutsidePointer, true);
57 return () => document.removeEventListener("pointerdown", closeOnOutsidePointer, true);
58 }, [onClose]);
59
4360 useEffect(() => {
4461 if (entries.length === 0) return;
4562 if (!entries.some((entry) => entry.id === highlightedId)) {
......@@ -91,15 +108,26 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
91108
92109 return (
93110 <Command autoHighlight={false} mode="none">
94 <div className="dropdown-glass relative w-full overflow-hidden rounded-[20px] shadow-[0_16px_40px_-18px_rgb(0_0_0/55%)] dark:shadow-[0_18px_44px_-18px_rgb(0_0_0/80%)]">
95 <CommandList className="max-h-72">
111 <div
112 ref={drawerRef}
113 className="chat-composer-drawer-surface chat-composer-drawer-attached relative w-full overflow-hidden"
114 data-composer-stash-drawer="true"
115 >
116 <div className="flex h-7 items-center justify-end px-2 pt-1">
117 <Button
118 variant="ghost-muted"
119 size="icon-micro"
120 aria-label="Close stash"
121 onPointerDown={(event) => event.preventDefault()}
122 onClick={onClose}
123 >
124 <XIcon className="size-3" />
125 </Button>
126 </div>
127 <CommandList className="max-h-64 scroll-pb-6">
96128 <CommandGroup>
97 <CommandGroupLabel className="flex items-center gap-1.5 px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-secondary-label">
98 <BookmarkIcon className="size-3" aria-hidden="true" />
99 Stashed prompts
100 </CommandGroupLabel>
101129 {entries.length === 0 ? (
102 <p className="px-3 pb-3 pt-1 text-secondary-label text-xs">
130 <p className="px-3 py-1.5 text-secondary-label text-xs">
103131 Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it.
104132 </p>
105133 ) : (
......@@ -108,7 +136,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
108136 key={entry.id}
109137 value={entry.id}
110138 className={cn(
111 "group/stash cursor-pointer select-none gap-2 hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit",
139 "group/stash relative cursor-pointer select-none gap-3 rounded-lg px-3 py-1.5! hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit",
112140 highlightedId === entry.id && "bg-accent! text-accent-foreground!",
113141 )}
114142 onMouseMove={() => {
......@@ -121,22 +149,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
121149 onRestore(entry);
122150 }}
123151 >
124 {entry.attachments.length > 0 ? (
125 <span className="flex shrink-0 items-center -space-x-1.5">
126 {entry.attachments.slice(0, 3).map((attachment) => (
127 <img
128 key={attachment.id}
129 src={attachment.dataUrl}
130 alt=""
131 aria-hidden="true"
132 className="size-5 rounded border border-border/70 object-cover"
133 />
134 ))}
135 </span>
136 ) : (
137 <BookmarkIcon className="size-4 shrink-0 text-icon-muted" />
138 )}
139 <span className="min-w-0 flex-1 truncate text-sm">
152 <span className="min-w-0 flex-1 truncate text-xs">
140153 {stashEntrySnippet(entry)}
141154 </span>
142155 {entry.pendingImageCount ? (
......@@ -150,20 +163,33 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
150163 {missingImageCount(entry) === 1 ? "" : "s"} dropped
151164 </span>
152165 ) : null}
153 <span className="shrink-0 text-secondary-label text-xs">
166 {entry.attachments.length > 0 ? (
167 <span className="flex shrink-0 items-center -space-x-1.5">
168 {entry.attachments.slice(0, 3).map((attachment) => (
169 <img
170 key={attachment.id}
171 src={attachment.dataUrl}
172 alt=""
173 aria-hidden="true"
174 className="size-5 rounded border border-border/70 object-cover"
175 />
176 ))}
177 </span>
178 ) : null}
179 <span className="shrink-0 text-secondary-label text-xs max-sm:hidden">
154180 {formatRelativeTimeLabel(entry.createdAt)}
155181 </span>
156182 <Button
157183 variant="ghost"
158184 size="icon-xs"
159 className="shrink-0 opacity-0 transition-opacity group-hover/stash:opacity-100"
185 className="pointer-events-none shrink-0 [--control-icon-color:currentColor] opacity-0 shadow-none! transition-opacity pointer-coarse:pointer-events-auto pointer-coarse:opacity-100 group-hover/stash:pointer-events-auto group-hover/stash:opacity-100 group-focus-within/stash:pointer-events-auto group-focus-within/stash:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
160186 aria-label="Delete stashed prompt"
161187 onClick={(event) => {
162188 event.stopPropagation();
163189 onDelete(entry);
164190 }}
165191 >
166 <XIcon />
192 <XIcon className="size-3.5 stroke-2" />
167193 </Button>
168194 </CommandItem>
169195 ))
apps/web/src/components/chat/ComposerTasksBadge.test.tsx created+130
......@@ -0,0 +1,130 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerTasksBadge, ComposerTasksDrawer } from "./ComposerTasksBadge";
5
6const progress = {
7 step: "Attach task progress",
8 completedSteps: 1,
9 totalSteps: 3,
10};
11const steps = [
12 { durationMs: 4_000, step: "Inspect the composer", status: "completed" as const },
13 { step: "Attach task progress", status: "inProgress" as const },
14 { step: "Verify the result", status: "pending" as const },
15];
16
17describe("ComposerTasksBadge", () => {
18 it("renders active progress as an attached composer tab", () => {
19 const markup = renderToStaticMarkup(
20 <ComposerTasksBadge
21 expanded={false}
22 onDismiss={() => undefined}
23 onToggle={() => undefined}
24 progress={progress}
25 steps={steps}
26 />,
27 );
28
29 expect(markup).toContain('data-composer-tasks-badge="true"');
30 expect(markup).toContain('aria-expanded="false"');
31 expect(markup).toContain("chat-composer-shoulder-tab");
32 expect(markup).toContain("chat-composer-tasks-tab");
33 expect(markup).toContain("rounded-t-xl");
34 expect(markup).toContain("border-b-0");
35 expect(markup).toContain("left-4");
36 expect(markup).toContain("right-4");
37 expect(markup).toContain('data-composer-task-current="true"');
38 expect(markup).toContain("min-w-0 flex-1 truncate");
39 expect(markup).toContain("w-20");
40 expect(markup).toContain("Tasks");
41 expect(markup).toContain("Attach task progress");
42 expect(markup).not.toContain("·");
43 expect(markup).toContain("1/3");
44 expect(markup).toContain("Current task: Attach task progress");
45 expect(markup).toContain("lucide-list-todo");
46 expect(markup).toContain('aria-label="Dismiss tasks for this turn"');
47 expect(markup).toContain("lucide-x");
48 expect(markup).not.toContain("lucide-chevron");
49 expect(markup).toContain("bg-success");
50 expect(markup).toContain("bg-primary");
51 expect(markup).toContain("bg-muted-foreground/25");
52 });
53
54 it("leaves room for the stash tab when both shoulders are present", () => {
55 const markup = renderToStaticMarkup(
56 <ComposerTasksBadge
57 expanded={false}
58 hasTrailingShoulder
59 onDismiss={() => undefined}
60 onToggle={() => undefined}
61 progress={progress}
62 steps={steps}
63 />,
64 );
65
66 expect(markup).toContain("right-28");
67 expect(markup).not.toContain("right-4");
68 });
69
70 it("has a compact inline fallback for occupied composer shoulders", () => {
71 const markup = renderToStaticMarkup(
72 <ComposerTasksBadge
73 expanded={false}
74 onDismiss={() => undefined}
75 onToggle={() => undefined}
76 placement="inline"
77 progress={progress}
78 steps={steps}
79 />,
80 );
81
82 expect(markup).toContain("rounded-sm");
83 expect(markup).toContain("1/3");
84 expect(markup).not.toContain("chat-composer-shoulder-tab");
85 expect(markup).not.toContain("rounded-t-xl");
86 });
87
88 it("expands into a read-only attached task list", () => {
89 const markup = renderToStaticMarkup(
90 <ComposerTasksDrawer
91 onCollapse={() => undefined}
92 onDismiss={() => undefined}
93 progress={progress}
94 steps={steps}
95 />,
96 );
97
98 expect(markup).toContain('data-chat-composer-tasks-drawer="true"');
99 expect(markup).not.toContain("data-variant");
100 expect(markup).toContain('aria-expanded="true"');
101 expect(markup).toContain('role="list"');
102 expect(markup).toContain("Inspect the composer");
103 expect(markup).toContain('data-composer-task-duration="true"');
104 expect(markup).toContain("ml-auto w-10");
105 expect(markup).toContain("4.0s");
106 expect(markup).toContain("now");
107 expect(markup).toContain("Attach task progress");
108 expect(markup).toContain("Verify the result");
109 expect(markup).toContain("lucide-list-todo");
110 expect(markup).toContain('aria-label="Dismiss tasks for this turn"');
111 expect(markup).not.toContain("lucide-chevron");
112 expect(markup).not.toContain("bg-success");
113 expect(markup).not.toContain("bg-primary");
114 expect(markup).not.toContain("bg-muted-foreground/25");
115 });
116
117 it("does not render an empty task count", () => {
118 const markup = renderToStaticMarkup(
119 <ComposerTasksBadge
120 expanded={false}
121 onDismiss={() => undefined}
122 onToggle={() => undefined}
123 progress={{ ...progress, totalSteps: 0 }}
124 steps={steps}
125 />,
126 );
127
128 expect(markup).toBe("");
129 });
130});
apps/web/src/components/chat/ComposerTasksBadge.tsx created+246
......@@ -0,0 +1,246 @@
1import { ListTodoIcon, XIcon } from "lucide-react";
2import { memo } from "react";
3
4import { formatDuration } from "../../session-logic";
5import { cn } from "~/lib/utils";
6import { Button } from "../ui/button";
7
8export interface ComposerTasksProgress {
9 readonly step: string;
10 readonly completedSteps: number;
11 readonly totalSteps: number;
12}
13
14export interface ComposerTaskStep {
15 readonly durationMs?: number;
16 readonly step: string;
17 readonly status: "pending" | "inProgress" | "completed";
18}
19
20function keyedTaskSteps(steps: readonly ComposerTaskStep[]) {
21 const occurrences = new Map<string, number>();
22 return steps.map((step) => {
23 const occurrence = occurrences.get(step.step) ?? 0;
24 occurrences.set(step.step, occurrence + 1);
25 return { key: `${step.step}:${occurrence}`, step };
26 });
27}
28
29function TaskSegments({
30 className,
31 steps,
32}: {
33 readonly className?: string;
34 readonly steps: readonly ComposerTaskStep[];
35}) {
36 if (steps.length <= 1) return null;
37
38 return (
39 <span aria-hidden className={cn("flex w-10 shrink-0 items-center gap-0.5", className)}>
40 {keyedTaskSteps(steps).map(({ key, step }) => (
41 <span
42 key={key}
43 className={cn(
44 "h-[3px] min-w-0 flex-1 rounded-full",
45 step.status === "completed"
46 ? "bg-success"
47 : step.status === "inProgress"
48 ? "bg-primary"
49 : "bg-muted-foreground/25",
50 )}
51 />
52 ))}
53 </span>
54 );
55}
56
57export const ComposerTasksBadge = memo(function ComposerTasksBadge({
58 expanded,
59 hasTrailingShoulder = false,
60 onDismiss,
61 onToggle,
62 placement = "tab",
63 progress,
64 steps,
65}: {
66 readonly expanded: boolean;
67 readonly hasTrailingShoulder?: boolean;
68 readonly onDismiss: () => void;
69 readonly onToggle: () => void;
70 readonly placement?: "inline" | "tab";
71 readonly progress: ComposerTasksProgress;
72 readonly steps: readonly ComposerTaskStep[];
73}) {
74 if (progress.totalSteps <= 0) return null;
75
76 const allDone = progress.completedSteps >= progress.totalSteps;
77 const label = `Tasks: ${progress.completedSteps} of ${progress.totalSteps} complete. Current task: ${progress.step}`;
78 if (placement === "inline") {
79 return (
80 <span className="inline-flex shrink-0 items-center gap-0.5" data-composer-tasks-badge="true">
81 <Button
82 size="micro"
83 variant="ghost-muted"
84 aria-expanded={expanded}
85 aria-label={label}
86 className="shrink-0 gap-1 px-1.5"
87 onClick={onToggle}
88 onPointerDown={(event) => event.preventDefault()}
89 >
90 <ListTodoIcon aria-hidden className="size-3 shrink-0" />
91 <span>Tasks</span>
92 <TaskSegments steps={steps} />
93 <span
94 className={cn(
95 "font-medium tabular-nums",
96 allDone ? "text-success" : "text-muted-foreground",
97 )}
98 >
99 {progress.completedSteps}/{progress.totalSteps}
100 </span>
101 </Button>
102 <Button
103 size="icon-micro"
104 variant="ghost-muted"
105 aria-label="Dismiss tasks for this turn"
106 className="shrink-0"
107 onClick={onDismiss}
108 onPointerDown={(event) => event.preventDefault()}
109 >
110 <XIcon aria-hidden className="size-2.5" />
111 </Button>
112 </span>
113 );
114 }
115
116 return (
117 <div
118 className={cn(
119 "chat-composer-shoulder-tab chat-composer-tasks-tab absolute -top-7 left-4 z-0 flex h-8 items-center gap-1 rounded-t-xl border border-b-0 px-2 pb-1 text-xs leading-none text-muted-foreground",
120 hasTrailingShoulder ? "right-28" : "right-4",
121 allDone && "text-foreground",
122 )}
123 data-composer-tasks-badge="true"
124 >
125 <button
126 type="button"
127 aria-expanded={expanded}
128 aria-label={label}
129 className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 self-stretch text-muted-foreground hover:text-foreground"
130 onClick={onToggle}
131 onPointerDown={(event) => event.preventDefault()}
132 >
133 <ListTodoIcon aria-hidden className="size-3.5 shrink-0" />
134 <span className="shrink-0">Tasks</span>
135 <span
136 className="min-w-0 flex-1 truncate text-left font-medium text-foreground/80"
137 data-composer-task-current="true"
138 >
139 {progress.step}
140 </span>
141 <span
142 className={cn(
143 "shrink-0 font-medium tabular-nums",
144 allDone ? "text-success" : "text-muted-foreground",
145 )}
146 >
147 {progress.completedSteps}/{progress.totalSteps}
148 </span>
149 <TaskSegments className="w-20" steps={steps} />
150 </button>
151 <Button
152 size="icon-micro"
153 variant="ghost-muted"
154 aria-label="Dismiss tasks for this turn"
155 className="shrink-0"
156 onClick={onDismiss}
157 onPointerDown={(event) => event.preventDefault()}
158 >
159 <XIcon aria-hidden className="size-3" />
160 </Button>
161 </div>
162 );
163});
164
165export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
166 onDismiss,
167 onCollapse,
168 progress,
169 steps,
170}: {
171 readonly onDismiss: () => void;
172 readonly onCollapse: () => void;
173 readonly progress: ComposerTasksProgress;
174 readonly steps: readonly ComposerTaskStep[];
175}) {
176 return (
177 <div className="chat-composer-top-drawer" data-chat-composer-tasks-drawer="true">
178 <div className="flex items-center gap-1 px-3 py-1.5 sm:px-4">
179 <button
180 type="button"
181 aria-expanded="true"
182 className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 self-stretch text-left text-xs text-muted-foreground hover:text-foreground"
183 onClick={onCollapse}
184 onPointerDown={(event) => event.preventDefault()}
185 >
186 <ListTodoIcon aria-hidden className="size-3.5 shrink-0" />
187 <span className="font-medium text-foreground">Tasks</span>
188 <span className="tabular-nums">
189 {progress.completedSteps}/{progress.totalSteps}
190 </span>
191 </button>
192 <Button
193 size="icon-micro"
194 variant="ghost-muted"
195 aria-label="Dismiss tasks for this turn"
196 className="shrink-0"
197 onClick={onDismiss}
198 onPointerDown={(event) => event.preventDefault()}
199 >
200 <XIcon aria-hidden className="size-3" />
201 </Button>
202 </div>
203 <div className="space-y-px px-3 pb-4 sm:px-4" role="list">
204 {keyedTaskSteps(steps).map(({ key, step }) => (
205 <div key={key} className="flex items-baseline gap-2 text-xs leading-5" role="listitem">
206 <span
207 aria-hidden
208 className={cn(
209 "w-3 shrink-0 text-center font-mono text-[10px]",
210 step.status === "completed"
211 ? "text-success"
212 : step.status === "inProgress"
213 ? "text-primary"
214 : "text-muted-foreground/40",
215 )}
216 >
217 {step.status === "completed" ? "✓" : step.status === "inProgress" ? "●" : "○"}
218 </span>
219 <span
220 className={cn(
221 "min-w-0 flex-1",
222 step.status === "completed"
223 ? "text-muted-foreground/55"
224 : step.status === "inProgress"
225 ? "text-foreground/90"
226 : "text-muted-foreground/70",
227 )}
228 >
229 {step.step}
230 </span>
231 <span
232 className="ml-auto w-10 shrink-0 text-right text-[10px] text-muted-foreground/45 tabular-nums"
233 data-composer-task-duration="true"
234 >
235 {step.durationMs !== undefined
236 ? formatDuration(step.durationMs)
237 : step.status === "inProgress"
238 ? "now"
239 : null}
240 </span>
241 </div>
242 ))}
243 </div>
244 </div>
245 );
246});
apps/web/src/components/chat/SkillInlineText.tsx+1-1
......@@ -1,7 +1,7 @@
11import { Children, cloneElement, isValidElement, type ReactNode } from "react";
22import type { ServerProviderSkill } from "@t3tools/contracts";
3import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
34
4import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
55import {
66 CHAT_INLINE_CHIP_CLASS_NAME,
77 CHAT_INLINE_CHIP_LABEL_CLASS_NAME,
apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx+5
......@@ -11,6 +11,11 @@ describe("ThreadSyncStatusPill", () => {
1111 const markup = renderToStaticMarkup(<ThreadSyncStatusPill phase={phase} />);
1212
1313 expect(markup).toContain('role="status"');
14 expect(markup).toContain('data-thread-sync-drawer="true"');
15 expect(markup).toContain("chat-composer-drawer-surface");
16 expect(markup).toContain("chat-composer-drawer-attached");
17 expect(markup).toContain("chat-composer-drawer-slot");
18 expect(markup).toContain("pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)]");
1419 expect(markup).toContain(label);
1520 expect(markup).not.toContain("animate-");
1621 });
apps/web/src/components/chat/ThreadSyncStatusPill.tsx+2-1
......@@ -8,7 +8,8 @@ export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhas
88 return (
99 <div
1010 aria-label={label}
11 className="pointer-events-none mx-auto mb-2 flex w-fit max-w-full items-center gap-2 rounded-full border border-border/60 bg-card/95 px-3 py-1.5 text-foreground text-xs font-medium shadow-sm"
11 className="chat-composer-drawer-surface chat-composer-drawer-attached chat-composer-drawer-slot pointer-events-none flex items-center gap-2 px-3 pt-2 pb-[calc(var(--chat-composer-attachment-overlap)_+_0.375rem)] text-foreground text-xs font-medium sm:px-4"
12 data-thread-sync-drawer="true"
1213 role="status"
1314 >
1415 <LoaderCircleIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
apps/web/src/components/composerInlineChip.ts+9-10
......@@ -1,8 +1,10 @@
11// Chip metrics are in em so the pills scale with the text they sit in (the
22// composer honors the prompt font-size preference). The chat variant pins the
33// original 12px, where every em value resolves to the same pixels as before.
4const INLINE_CHIP_CLASS_NAME =
5 "inline-flex max-w-full items-center gap-[0.33em] rounded-[0.5em] border border-border/70 bg-accent/40 px-[0.5em] py-[0.08em] font-medium leading-[1.1] text-foreground align-middle";
4const INLINE_CHIP_GEOMETRY_CLASS_NAME =
5 "inline-flex h-[1.41em] max-w-full items-center gap-[0.33em] rounded-[0.5em] px-[0.5em] font-medium leading-none align-middle";
6
7const INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_GEOMETRY_CLASS_NAME} border border-border/70 bg-accent/40 text-foreground`;
68
79export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`;
810
......@@ -11,18 +13,15 @@ export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[
1113export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME =
1214 "relative inline-flex align-[-0.125em] leading-none data-[composer-chip-selected]:after:pointer-events-none data-[composer-chip-selected]:after:absolute data-[composer-chip-selected]:after:inset-0 data-[composer-chip-selected]:after:rounded-[6px] data-[composer-chip-selected]:after:bg-[Highlight] data-[composer-chip-selected]:after:opacity-30 data-[composer-chip-selected]:after:content-['']";
1315
14export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME = "size-[1.17em] shrink-0 opacity-85";
16export const COMPOSER_INLINE_CHIP_ICON_CLASS_NAME =
17 "block size-[1.17em] shrink-0 self-center opacity-85 [&>svg]:block";
1518
1619export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";
1720
18export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = `${CHAT_INLINE_CHIP_LABEL_CLASS_NAME} select-none`;
19
20// The skill label is smaller than the surrounding prompt text; offset its
21// glyphs without moving the pill box or changing the editor's line height.
22export const COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME = `${COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME} relative top-[0.15em]`;
21export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME =
22 "block self-center truncate leading-tight select-none";
2323
24export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =
25 "inline-flex max-w-full select-none items-center gap-[0.33em] rounded-[0.5em] border border-fuchsia-500/25 bg-fuchsia-500/12 px-[0.5em] py-[0.08em] font-medium text-[0.86em] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300";
24export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = `${INLINE_CHIP_GEOMETRY_CLASS_NAME} select-none border border-fuchsia-500/25 bg-fuchsia-500/12 text-[0.86em] text-fuchsia-700 dark:text-fuchsia-300`;
2625
2726export const SKILL_CHIP_ICON_SVG = `<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><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"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`;
2827
apps/web/src/components/ui/button.test.tsx+8
......@@ -31,6 +31,11 @@ describe("button geometry tokens", () => {
3131
3232 it("owns shared compact and micro control geometry", () => {
3333 const compact = renderToStaticMarkup(<Button size="compact">Condition</Button>);
34 const microLabel = renderToStaticMarkup(
35 <Button size="micro">
36 <FlaskConicalIcon /> Run
37 </Button>,
38 );
3439 const micro = renderToStaticMarkup(
3540 <Button size="icon-micro" variant="ghost-muted" aria-label="Add action">
3641 <span>+</span>
......@@ -39,6 +44,9 @@ describe("button geometry tokens", () => {
3944
4045 expect(compact).toContain("h-7");
4146 expect(compact).toContain("rounded-md");
47 expect(microLabel).toContain("text-[11px]");
48 expect(microLabel).toContain("sm:text-[11px]");
49 expect(microLabel).toContain("sm:[&amp;_svg:not([class*=&#x27;size-&#x27;])]:size-3");
4250 expect(micro).toContain("size-5");
4351 expect(micro).toContain("rounded-sm");
4452 expect(micro).toContain("text-muted-foreground");
apps/web/src/components/ui/button.tsx+2
......@@ -29,6 +29,8 @@ const buttonVariants = cva(
2929 "icon-xs":
3030 "size-7 sm:size-6 not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-4 sm:not-in-data-[slot=input-group]:[&_svg:not([class*='size-'])]:size-3.5",
3131 lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9",
32 micro:
33 "h-5 gap-1 rounded-sm px-[calc(--spacing(1.5)-1px)] text-[11px] before:rounded-[calc(var(--radius-sm)-1px)] sm:text-[11px] [&_svg:not([class*='size-'])]:size-3 sm:[&_svg:not([class*='size-'])]:size-3",
3234 sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7",
3335 xl: "h-11 px-[calc(--spacing(4)-1px)] text-lg sm:h-10 sm:text-base [&_svg:not([class*='size-'])]:size-5 sm:[&_svg:not([class*='size-'])]:size-4.5",
3436 xs: "h-7 gap-1 px-[calc(--spacing(2)-1px)] text-sm sm:h-6 sm:text-xs [&_svg:not([class*='size-'])]:size-4 sm:[&_svg:not([class*='size-'])]:size-3.5",
apps/web/src/index.css+321-2
......@@ -680,7 +680,191 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
680680 outline: none;
681681 }
682682
683 .chat-composer-shoulder-tab {
684 border-color: var(
685 --chat-composer-outline,
686 color-mix(in srgb, var(--foreground) 8%, transparent)
687 );
688 background: color-mix(
689 in srgb,
690 var(--chat-composer-glass-surface, var(--card)) var(--glass-opacity),
691 transparent
692 );
693 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
694 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
695 box-shadow: 0 -8px 24px -18px rgb(0 0 0 / 45%);
696
697 @variant dark {
698 box-shadow: 0 -10px 28px -18px rgb(0 0 0 / 80%);
699 }
700
701 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
702 background: var(--chat-composer-glass-surface, var(--card));
703 }
704 }
705
706 .chat-composer-banner-stack-cap {
707 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
708 --chat-composer-attached-outline: var(
709 --chat-composer-outline,
710 color-mix(in srgb, var(--foreground) 8%, transparent)
711 );
712 background: color-mix(
713 in srgb,
714 var(--chat-composer-attached-surface) var(--glass-opacity),
715 transparent
716 );
717 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
718 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
719
720 @variant dark {
721 --chat-composer-attached-surface: var(
722 --chat-composer-glass-surface,
723 color-mix(in srgb, var(--background) 96%, var(--color-white))
724 );
725 --chat-composer-attached-outline: var(
726 --chat-composer-outline,
727 color-mix(in srgb, var(--color-white) 5%, transparent)
728 );
729 }
730
731 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
732 background: var(--chat-composer-attached-surface);
733 }
734 }
735
736 /* Mirror the run-context strip above the composer. These surfaces deliberately
737 own no card fill or border themselves: a masked pseudo-element draws the
738 same inset glass, 16px outer corners, quiet outline, and open attachment
739 seam as the strip below. */
740 .chat-composer-drawer-surface,
741 .chat-composer-top-drawer {
742 --chat-composer-attachment-overlap: calc(1rem + 1px);
743 --chat-composer-attached-surface: var(--chat-composer-glass-surface, var(--card));
744 --chat-composer-attached-outline: var(
745 --chat-composer-outline,
746 color-mix(in srgb, var(--foreground) 8%, transparent)
747 );
748 --chat-composer-attached-tint: transparent;
749 position: relative;
750 isolation: isolate;
751 border: 0 !important;
752 background: transparent !important;
753 box-shadow: none;
754
755 @variant dark {
756 --chat-composer-attached-surface: var(
757 --chat-composer-glass-surface,
758 color-mix(in srgb, var(--background) 96%, var(--color-white))
759 );
760 --chat-composer-attached-outline: var(
761 --chat-composer-outline,
762 color-mix(in srgb, var(--color-white) 5%, transparent)
763 );
764
765 &::before {
766 background:
767 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
768 linear-gradient(
769 to top,
770 transparent 0 var(--chat-composer-attachment-overlap),
771 rgb(0 0 0 / 18%) var(--chat-composer-attachment-overlap),
772 transparent calc(var(--chat-composer-attachment-overlap) + 10px)
773 ),
774 color-mix(
775 in srgb,
776 var(--chat-composer-attached-surface) var(--glass-opacity),
777 transparent
778 );
779 box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%);
780 }
781 }
782 }
783
784 .chat-composer-drawer-attached,
785 .chat-composer-top-drawer {
786 padding-bottom: var(--chat-composer-attachment-overlap);
787 }
788
789 [data-chat-composer-form="true"],
790 .chat-composer-drawer-slot,
791 .chat-composer-top-drawer {
792 --chat-composer-drawer-inset: 1.375rem;
793 }
794
795 .chat-composer-drawer-slot,
796 .chat-composer-top-drawer {
797 --chat-composer-attachment-overlap: calc(1rem + 1px);
798 width: calc(100% - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
799 max-width: calc(48rem - var(--chat-composer-drawer-inset) - var(--chat-composer-drawer-inset));
800 margin-inline: auto;
801 margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap));
802 }
803
804 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)::before {
805 pointer-events: none;
806 position: absolute;
807 z-index: -1;
808 inset: 0;
809 border: 1px solid var(--chat-composer-attached-outline);
810 border-radius: 16px 16px 0 0;
811 background:
812 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
813 color-mix(in srgb, var(--chat-composer-attached-surface) var(--glass-opacity), transparent);
814 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
815 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
816 -webkit-mask-image: linear-gradient(
817 to top,
818 transparent 0 var(--chat-composer-attachment-overlap),
819 black var(--chat-composer-attachment-overlap)
820 );
821 mask-image: linear-gradient(
822 to top,
823 transparent 0 var(--chat-composer-attachment-overlap),
824 black var(--chat-composer-attachment-overlap)
825 );
826 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
827 content: "";
828
829 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
830 background:
831 linear-gradient(var(--chat-composer-attached-tint), var(--chat-composer-attached-tint)),
832 var(--chat-composer-attached-surface) !important;
833 }
834 }
835
836 .chat-composer-drawer-slot + .chat-composer-drawer-slot::before,
837 .chat-composer-drawer-slot + :has(.chat-composer-top-drawer) .chat-composer-top-drawer::before {
838 border-top: 0;
839 border-radius: 0;
840 }
841
842 .chat-composer-top-drawer {
843 z-index: 0;
844 }
845
846 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="error"] {
847 --chat-composer-attached-outline: color-mix(in srgb, var(--error) 32%, transparent);
848 --chat-composer-attached-tint: color-mix(in srgb, var(--error) 8%, transparent);
849 }
850
851 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="info"] {
852 --chat-composer-attached-outline: color-mix(in srgb, var(--info) 32%, transparent);
853 --chat-composer-attached-tint: color-mix(in srgb, var(--info) 4%, transparent);
854 }
855
856 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="success"] {
857 --chat-composer-attached-outline: color-mix(in srgb, var(--success) 32%, transparent);
858 --chat-composer-attached-tint: color-mix(in srgb, var(--success) 4%, transparent);
859 }
860
861 :is(.chat-composer-drawer-surface, .chat-composer-top-drawer)[data-variant="warning"] {
862 --chat-composer-attached-outline: color-mix(in srgb, var(--warning) 28%, transparent);
863 --chat-composer-attached-tint: color-mix(in srgb, var(--warning) 8%, transparent);
864 }
865
683866 .chat-composer-glass-shell {
867 --chat-composer-attachment-overlap: calc(1rem + 1px);
684868 --chat-composer-glass-surface: var(--card);
685869 --chat-composer-outline: rgb(0 0 0 / 8%);
686870
......@@ -775,7 +959,83 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
775959 content: "";
776960 }
777961
778 .chat-composer-glass-shell-with-context .chat-composer-glass-host::after {
962 /* A top attachment cannot share the shell's single clipped backdrop: it
963 needs the composer surface and outline to paint in front of its overlap.
964 Split the glass into exact surfaces while a drawer or tab is present; the
965 default composer keeps the cheaper continuous layer. */
966 .chat-composer-glass-shell:is(
967 .chat-composer-glass-shell-attached,
968 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
969 )::before,
970 .chat-composer-glass-shell:is(
971 .chat-composer-glass-shell-attached,
972 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
973 )
974 .chat-composer-glass-host::after {
975 display: none;
976 }
977
978 .chat-composer-glass-shell:is(
979 .chat-composer-glass-shell-attached,
980 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
981 )
982 .chat-composer-glass-host {
983 box-shadow: none;
984 }
985
986 .chat-composer-glass-shell:is(
987 .chat-composer-glass-shell-attached,
988 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
989 )
990 .chat-composer-glass-host
991 [data-chat-composer-main-surface="true"] {
992 background: color-mix(
993 in srgb,
994 var(--chat-composer-glass-surface) var(--glass-opacity),
995 transparent
996 );
997 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
998 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
999 box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%);
1000
1001 &::after {
1002 pointer-events: none;
1003 position: absolute;
1004 z-index: 20;
1005 inset: 0;
1006 border: 1px solid var(--chat-composer-outline);
1007 border-radius: inherit;
1008 content: "";
1009 }
1010
1011 @variant dark {
1012 box-shadow: none;
1013
1014 &::after {
1015 box-shadow: inset 0 1px var(--chat-composer-highlight);
1016 }
1017 }
1018
1019 @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
1020 background: var(--chat-composer-glass-surface);
1021 }
1022 }
1023
1024 .chat-composer-glass-shell:is(
1025 .chat-composer-glass-shell-attached,
1026 :has(.chat-composer-top-drawer)
1027 )
1028 .chat-composer-glass-host
1029 [data-chat-composer-mobile-collapsed="true"] {
1030 min-height: var(--chat-composer-attachment-overlap);
1031 }
1032
1033 .chat-composer-glass-shell-with-context .chat-composer-glass-host::after,
1034 .chat-composer-glass-shell-with-context:is(
1035 .chat-composer-glass-shell-attached,
1036 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1037 )
1038 [data-chat-composer-main-surface="true"]::after {
7791039 /* Keep the attachment seam open; the strip continues the outer outline below. */
7801040 clip-path: polygon(
7811041 0 0,
......@@ -822,6 +1082,36 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
8221082 content: "";
8231083 }
8241084
1085 .chat-composer-glass-shell:is(
1086 .chat-composer-glass-shell-attached,
1087 :has(:is(.chat-composer-top-drawer, .chat-composer-shoulder-tab))
1088 )
1089 .chat-composer-context-strip {
1090 &::before {
1091 background: color-mix(
1092 in srgb,
1093 var(--chat-composer-glass-surface) var(--glass-opacity),
1094 transparent
1095 );
1096 -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1097 backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation));
1098 }
1099
1100 @variant dark {
1101 &::before {
1102 background:
1103 linear-gradient(
1104 to bottom,
1105 transparent 0 1rem,
1106 rgb(0 0 0 / 18%) 1rem,
1107 transparent calc(1rem + 10px)
1108 ),
1109 linear-gradient(rgb(255 255 255 / 2%), rgb(255 255 255 / 2%)),
1110 color-mix(in srgb, var(--chat-composer-glass-surface) var(--glass-opacity), transparent);
1111 }
1112 }
1113 }
1114
8251115 @supports not (clip-path: shape(from 0 0, line to 1px 1px)) {
8261116 .chat-composer-glass-shell-with-context::before {
8271117 inset-block-end: var(--chat-composer-context-extension);
......@@ -1255,6 +1545,26 @@ html[data-theme-id] .chat-composer-glass-shell {
12551545 }
12561546}
12571547
1548html[data-theme-id]
1549 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer) {
1550 --chat-composer-attached-surface: var(--app-theme-surface-raised);
1551}
1552
1553html[data-theme-id]
1554 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
1555 :not([data-variant]),
1556 [data-variant="default"]
1557 ) {
1558 --chat-composer-attached-outline: var(--chat-composer-outline, var(--app-theme-toolbar-border));
1559
1560 @variant dark {
1561 --chat-composer-attached-outline: var(
1562 --chat-composer-outline,
1563 color-mix(in srgb, var(--app-theme-input) 30%, var(--background))
1564 );
1565 }
1566}
1567
12581568html[data-theme-id="t3-chat"] .chat-composer-glass-shell {
12591569 /* T3 Chat's visible composer edge is a dark plum, not the stock translucent
12601570 white outline. Its highlight is derived from --chat-input-gradient. */
......@@ -1264,6 +1574,16 @@ html[data-theme-id="t3-chat"] .chat-composer-glass-shell {
12641574 }
12651575}
12661576
1577html[data-theme-id="t3-chat"]
1578 :is(.chat-composer-banner-stack-cap, .chat-composer-drawer-surface, .chat-composer-top-drawer):is(
1579 :not([data-variant]),
1580 [data-variant="default"]
1581 ) {
1582 @variant dark {
1583 --chat-composer-attached-outline: #241e28;
1584 }
1585}
1586
12671587/* Theme-token dependency probes are restored synchronously, before paint. Keep
12681588 transitions from observing the temporary sentinel color in between. */
12691589html[data-theme-token-probe],
......@@ -1933,7 +2253,6 @@ code {
19332253 #ff6b6b 100%
19342254 );
19352255 position: relative;
1936 background: transparent;
19372256}
19382257
19392258.ultrathink-frame::before {
apps/web/src/providerSkillPresentation.test.ts deleted-57
......@@ -1,57 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 formatProviderSkillDisplayName,
5 formatProviderSkillInstallSource,
6} from "./providerSkillPresentation";
7
8describe("formatProviderSkillDisplayName", () => {
9 it("prefers the provider display name", () => {
10 expect(
11 formatProviderSkillDisplayName({
12 name: "review-follow-up",
13 displayName: "Review Follow-up",
14 }),
15 ).toBe("Review Follow-up");
16 });
17
18 it("falls back to a title-cased skill name", () => {
19 expect(
20 formatProviderSkillDisplayName({
21 name: "review-follow-up",
22 }),
23 ).toBe("Review Follow Up");
24 });
25});
26
27describe("formatProviderSkillInstallSource", () => {
28 it("marks plugin-backed skills as app installs", () => {
29 expect(
30 formatProviderSkillInstallSource({
31 path: "/Users/julius/.codex/plugins/cache/openai-curated/github/skills/gh-fix-ci/SKILL.md",
32 scope: "user",
33 }),
34 ).toBe("App");
35 });
36
37 it("maps standard scopes to user-facing labels", () => {
38 expect(
39 formatProviderSkillInstallSource({
40 path: "/Users/julius/.agents/skills/agent-browser/SKILL.md",
41 scope: "user",
42 }),
43 ).toBe("Personal");
44 expect(
45 formatProviderSkillInstallSource({
46 path: "/usr/local/share/codex/skills/imagegen/SKILL.md",
47 scope: "system",
48 }),
49 ).toBe("System");
50 expect(
51 formatProviderSkillInstallSource({
52 path: "/workspace/.codex/skills/review-follow-up/SKILL.md",
53 scope: "project",
54 }),
55 ).toBe("Project");
56 });
57});
apps/web/src/providerSkillPresentation.ts deleted-53
......@@ -1,53 +0,0 @@
1import type { ServerProviderSkill } from "@t3tools/contracts";
2
3function titleCaseWords(value: string): string {
4 const words: string[] = [];
5 for (const segment of value.split(/[\s:_-]+/)) {
6 if (segment.length === 0) continue;
7 words.push(segment.charAt(0).toUpperCase() + segment.slice(1));
8 }
9 return words.join(" ");
10}
11
12function normalizePathSeparators(pathValue: string): string {
13 return pathValue.replaceAll("\\", "/");
14}
15
16export function formatProviderSkillDisplayName(
17 skill: Pick<ServerProviderSkill, "name" | "displayName">,
18): string {
19 const displayName = skill.displayName?.trim();
20 if (displayName) {
21 return displayName;
22 }
23 return titleCaseWords(skill.name);
24}
25
26export function formatProviderSkillInstallSource(
27 skill: Pick<ServerProviderSkill, "path" | "scope">,
28): string | null {
29 const normalizedPath = normalizePathSeparators(skill.path);
30 if (normalizedPath.includes("/.codex/plugins/") || normalizedPath.includes("/.agents/plugins/")) {
31 return "App";
32 }
33
34 const normalizedScope = skill.scope?.trim().toLowerCase();
35 if (normalizedScope === "system") {
36 return "System";
37 }
38 if (
39 normalizedScope === "project" ||
40 normalizedScope === "workspace" ||
41 normalizedScope === "local"
42 ) {
43 return "Project";
44 }
45 if (normalizedScope === "user" || normalizedScope === "personal") {
46 return "Personal";
47 }
48 if (normalizedScope) {
49 return titleCaseWords(normalizedScope);
50 }
51
52 return null;
53}
apps/web/src/providerSkillSearch.ts+1-2
......@@ -1,12 +1,11 @@
11import type { ServerProviderSkill } from "@t3tools/contracts";
2import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
23import {
34 insertRankedSearchResult,
45 normalizeSearchQuery,
56 scoreQueryMatch,
67} from "@t3tools/shared/searchRanking";
78
8import { formatProviderSkillDisplayName } from "./providerSkillPresentation";
9
109function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null {
1110 const normalizedName = skill.name.toLowerCase();
1211 const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase();
apps/web/src/session-logic.test.ts+161-1
......@@ -408,6 +408,60 @@ describe("deriveActivePlanState", () => {
408408 steps: [{ step: "Write tests", status: "completed" }],
409409 });
410410 });
411
412 it("starts timing again after a plan is cleared and recreated", () => {
413 const activities: OrchestrationThreadActivity[] = [
414 makeActivity({
415 id: "plan-old-start",
416 createdAt: "2026-02-23T00:00:01.000Z",
417 kind: "turn.plan.updated",
418 summary: "Plan updated",
419 tone: "info",
420 turnId: "turn-1",
421 payload: { plan: [{ step: "Check", status: "inProgress" }] },
422 }),
423 makeActivity({
424 id: "plan-old-complete",
425 createdAt: "2026-02-23T00:00:05.000Z",
426 kind: "turn.plan.updated",
427 summary: "Plan updated",
428 tone: "info",
429 turnId: "turn-1",
430 payload: { plan: [{ step: "Check", status: "completed" }] },
431 }),
432 makeActivity({
433 id: "plan-clear",
434 createdAt: "2026-02-23T00:00:06.000Z",
435 kind: "turn.plan.updated",
436 summary: "Plan updated",
437 tone: "info",
438 turnId: "turn-1",
439 payload: { plan: [] },
440 }),
441 makeActivity({
442 id: "plan-new-start",
443 createdAt: "2026-02-23T00:00:10.000Z",
444 kind: "turn.plan.updated",
445 summary: "Plan updated",
446 tone: "info",
447 turnId: "turn-1",
448 payload: { plan: [{ step: "Check", status: "inProgress" }] },
449 }),
450 makeActivity({
451 id: "plan-new-complete",
452 createdAt: "2026-02-23T00:00:13.000Z",
453 kind: "turn.plan.updated",
454 summary: "Plan updated",
455 tone: "info",
456 turnId: "turn-1",
457 payload: { plan: [{ step: "Check", status: "completed" }] },
458 }),
459 ];
460
461 expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([
462 { durationMs: 3_000, step: "Check", status: "completed" },
463 ]);
464 });
411465});
412466
413467describe("deriveTurnPlans", () => {
......@@ -455,7 +509,9 @@ describe("deriveTurnPlans", () => {
455509 createdAt: "2026-02-23T00:00:01.000Z",
456510 turnId: "turn-1",
457511 });
458 expect(turnPlans[0]?.plan.steps).toEqual([{ step: "Inspect code", status: "completed" }]);
512 expect(turnPlans[0]?.plan.steps).toEqual([
513 { durationMs: 4_000, step: "Inspect code", status: "completed" },
514 ]);
459515 expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]);
460516 });
461517
......@@ -474,6 +530,110 @@ describe("deriveTurnPlans", () => {
474530 expect(deriveTurnPlans(activities)).toEqual([]);
475531 });
476532
533 it("tracks repeated step labels independently", () => {
534 const activities: OrchestrationThreadActivity[] = [
535 makeActivity({
536 id: "plan-1a",
537 createdAt: "2026-02-23T00:00:01.000Z",
538 kind: "turn.plan.updated",
539 summary: "Plan updated",
540 tone: "info",
541 turnId: "turn-1",
542 payload: {
543 plan: [
544 { step: "Check", status: "inProgress" },
545 { step: "Check", status: "pending" },
546 ],
547 },
548 }),
549 makeActivity({
550 id: "plan-1b",
551 createdAt: "2026-02-23T00:00:05.000Z",
552 kind: "turn.plan.updated",
553 summary: "Plan updated",
554 tone: "info",
555 turnId: "turn-1",
556 payload: {
557 plan: [
558 { step: "Check", status: "completed" },
559 { step: "Check", status: "inProgress" },
560 ],
561 },
562 }),
563 makeActivity({
564 id: "plan-1c",
565 createdAt: "2026-02-23T00:00:11.000Z",
566 kind: "turn.plan.updated",
567 summary: "Plan updated",
568 tone: "info",
569 turnId: "turn-1",
570 payload: {
571 plan: [
572 { step: "Check", status: "completed" },
573 { step: "Check", status: "completed" },
574 ],
575 },
576 }),
577 ];
578
579 expect(deriveTurnPlans(activities)[0]?.plan.steps).toEqual([
580 { durationMs: 4_000, step: "Check", status: "completed" },
581 { durationMs: 6_000, step: "Check", status: "completed" },
582 ]);
583 });
584
585 it("derives fallback durations in completion order", () => {
586 const activities: OrchestrationThreadActivity[] = [
587 makeActivity({
588 id: "plan-start",
589 createdAt: "2026-02-23T00:00:01.000Z",
590 kind: "turn.plan.updated",
591 summary: "Plan updated",
592 tone: "info",
593 turnId: "turn-1",
594 payload: {
595 plan: [
596 { step: "First", status: "pending" },
597 { step: "Second", status: "pending" },
598 ],
599 },
600 }),
601 makeActivity({
602 id: "plan-second-complete",
603 createdAt: "2026-02-23T00:00:06.000Z",
604 kind: "turn.plan.updated",
605 summary: "Plan updated",
606 tone: "info",
607 turnId: "turn-1",
608 payload: {
609 plan: [
610 { step: "First", status: "pending" },
611 { step: "Second", status: "completed" },
612 ],
613 },
614 }),
615 makeActivity({
616 id: "plan-first-complete",
617 createdAt: "2026-02-23T00:00:11.000Z",
618 kind: "turn.plan.updated",
619 summary: "Plan updated",
620 tone: "info",
621 turnId: "turn-1",
622 payload: {
623 plan: [
624 { step: "First", status: "completed" },
625 { step: "Second", status: "completed" },
626 ],
627 },
628 }),
629 ];
630
631 expect(deriveTurnPlans(activities)[0]?.plan.steps).toEqual([
632 { durationMs: 5_000, step: "First", status: "completed" },
633 { durationMs: 5_000, step: "Second", status: "completed" },
634 ]);
635 });
636
477637 it("drops a turn's chip when a later snapshot clears the plan", () => {
478638 const activities: OrchestrationThreadActivity[] = [
479639 makeActivity({
apps/web/src/session-logic.ts+85-8
......@@ -123,6 +123,7 @@ export interface ActivePlanState {
123123 turnId: TurnId | null;
124124 explanation?: string | null;
125125 steps: Array<{
126 durationMs?: number;
126127 step: string;
127128 status: "pending" | "inProgress" | "completed";
128129 }>;
......@@ -579,6 +580,64 @@ function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePla
579580 };
580581}
581582
583function addPlanStepDurations(
584 plan: ActivePlanState,
585 activities: ReadonlyArray<OrchestrationThreadActivity>,
586): ActivePlanState {
587 const timings = new Map<string, { completedAt?: number; startedAt?: number }>();
588 let planStartedAt: number | undefined;
589
590 const keyedSteps = (steps: ActivePlanState["steps"]) => {
591 const occurrences = new Map<string, number>();
592 return steps.map((step) => {
593 const occurrence = occurrences.get(step.step) ?? 0;
594 occurrences.set(step.step, occurrence + 1);
595 return { key: `${step.step}:${occurrence}`, step };
596 });
597 };
598
599 for (const activity of activities) {
600 const snapshot = planStateFromActivity(activity);
601 const activityAt = Date.parse(activity.createdAt);
602 if (!snapshot || Number.isNaN(activityAt)) continue;
603 planStartedAt ??= activityAt;
604
605 for (const { key, step } of keyedSteps(snapshot.steps)) {
606 const timing = timings.get(key) ?? {};
607 if (step.status === "inProgress" && timing.startedAt === undefined) {
608 timing.startedAt = activityAt;
609 }
610 if (step.status === "completed" && timing.completedAt === undefined) {
611 timing.completedAt = activityAt;
612 }
613 timings.set(key, timing);
614 }
615 }
616
617 const durationByKey = new Map<string, number>();
618 let previousCompletedAt = planStartedAt;
619 for (const [key, timing] of [...timings.entries()].toSorted(
620 (left, right) => (left[1].completedAt ?? Infinity) - (right[1].completedAt ?? Infinity),
621 )) {
622 const completedAt = timing.completedAt;
623 const startedAt = timing.startedAt ?? previousCompletedAt;
624 if (completedAt === undefined) continue;
625 if (startedAt !== undefined && completedAt > startedAt) {
626 durationByKey.set(key, completedAt - startedAt);
627 }
628 previousCompletedAt = completedAt;
629 }
630
631 return {
632 ...plan,
633 steps: keyedSteps(plan.steps).map(({ key, step }) => {
634 if (step.status !== "completed") return step;
635 const durationMs = durationByKey.get(key);
636 return durationMs === undefined ? step : { ...step, durationMs };
637 }),
638 };
639}
640
582641export function deriveActivePlanState(
583642 activities: ReadonlyArray<OrchestrationThreadActivity>,
584643 latestTurnId: TurnId | undefined,
......@@ -596,7 +655,15 @@ export function deriveActivePlanState(
596655 if (!latest) {
597656 return null;
598657 }
599 return planStateFromActivity(latest);
658 const plan = planStateFromActivity(latest);
659 if (!plan) return null;
660 const matchingActivities = allPlanActivities.filter(
661 (activity) => activity.turnId === latest.turnId,
662 );
663 const latestClearIndex = matchingActivities.findLastIndex(
664 (activity) => planStateFromActivity(activity) === null,
665 );
666 return addPlanStepDurations(plan, matchingActivities.slice(latestClearIndex + 1));
600667}
601668
602669export interface TurnPlanEntry {
......@@ -617,7 +684,10 @@ export function deriveTurnPlans(
617684 activities: ReadonlyArray<OrchestrationThreadActivity>,
618685): TurnPlanEntry[] {
619686 const ordered = [...activities].toSorted(compareActivitiesByOrder);
620 const byTurn = new Map<string, TurnPlanEntry>();
687 const byTurn = new Map<
688 string,
689 { activities: OrchestrationThreadActivity[]; entry: TurnPlanEntry }
690 >();
621691 for (const activity of ordered) {
622692 if (activity.kind !== "turn.plan.updated") {
623693 continue;
......@@ -632,17 +702,24 @@ export function deriveTurnPlans(
632702 }
633703 const existing = byTurn.get(key);
634704 if (existing) {
635 existing.plan = plan;
705 existing.entry.plan = plan;
706 existing.activities.push(activity);
636707 } else {
637708 byTurn.set(key, {
638 id: `turn-plan:${key}`,
639 createdAt: activity.createdAt,
640 turnId: activity.turnId,
641 plan,
709 activities: [activity],
710 entry: {
711 id: `turn-plan:${key}`,
712 createdAt: activity.createdAt,
713 turnId: activity.turnId,
714 plan,
715 },
642716 });
643717 }
644718 }
645 return [...byTurn.values()];
719 return [...byTurn.values()].map(({ activities: planActivities, entry }) => ({
720 ...entry,
721 plan: addPlanStepDurations(entry.plan, planActivities),
722 }));
646723}
647724
648725export function findLatestProposedPlan(
packages/client-runtime/package.json+4
......@@ -35,6 +35,10 @@
3535 "types": "./src/platform/index.ts",
3636 "default": "./src/platform/index.ts"
3737 },
38 "./providerSkills": {
39 "types": "./src/providerSkills.ts",
40 "default": "./src/providerSkills.ts"
41 },
3842 "./relay": {
3943 "types": "./src/relay/index.ts",
4044 "default": "./src/relay/index.ts"
packages/client-runtime/src/providerSkills.test.ts created+77
......@@ -0,0 +1,77 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 formatProviderSkillDisplayName,
5 resolveProviderSkillSourceKind,
6} from "./providerSkills.ts";
7
8describe("formatProviderSkillDisplayName", () => {
9 it("prefers the provider display name", () => {
10 expect(
11 formatProviderSkillDisplayName({
12 name: "review-follow-up",
13 displayName: "Review Follow-up",
14 }),
15 ).toBe("Review Follow-up");
16 });
17
18 it("falls back to a title-cased skill name", () => {
19 expect(
20 formatProviderSkillDisplayName({
21 name: "review-follow-up",
22 }),
23 ).toBe("Review Follow Up");
24 });
25});
26
27describe("resolveProviderSkillSourceKind", () => {
28 it("marks plugin-backed skills as app installs", () => {
29 expect(
30 resolveProviderSkillSourceKind({
31 path: "/Users/julius/.codex/plugins/cache/openai-curated/github/skills/gh-fix-ci/SKILL.md",
32 scope: "user",
33 }),
34 ).toBe("app");
35 });
36
37 it("maps standard scopes to source kinds", () => {
38 expect(
39 resolveProviderSkillSourceKind({
40 path: "/workspace/.codex/skills/review-follow-up/SKILL.md",
41 scope: "repo",
42 }),
43 ).toBe("repo");
44 expect(
45 resolveProviderSkillSourceKind({
46 path: "/workspace/.codex/skills/review-follow-up/SKILL.md",
47 scope: "project",
48 }),
49 ).toBe("project");
50 expect(
51 resolveProviderSkillSourceKind({
52 path: "/Users/julius/.agents/skills/agent-browser/SKILL.md",
53 scope: "user",
54 }),
55 ).toBe("personal");
56 expect(
57 resolveProviderSkillSourceKind({
58 path: "/usr/local/share/codex/skills/imagegen/SKILL.md",
59 scope: "system",
60 }),
61 ).toBe("system");
62 });
63
64 it("keeps unknown and missing scopes usable", () => {
65 expect(
66 resolveProviderSkillSourceKind({
67 path: "/opt/skills/team-review/SKILL.md",
68 scope: "team_shared",
69 }),
70 ).toBe("other");
71 expect(
72 resolveProviderSkillSourceKind({
73 path: "/opt/skills/team-review/SKILL.md",
74 }),
75 ).toBe("other");
76 });
77});
packages/client-runtime/src/providerSkills.ts created+56
......@@ -0,0 +1,56 @@
1import type { ServerProviderSkill } from "@t3tools/contracts";
2
3export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other";
4
5function titleCaseWords(value: string): string {
6 const words: string[] = [];
7 for (const segment of value.split(/[\s:_-]+/)) {
8 if (segment.length === 0) continue;
9 words.push(segment.charAt(0).toUpperCase() + segment.slice(1));
10 }
11 return words.join(" ");
12}
13
14function normalizePathSeparators(pathValue: string): string {
15 return pathValue.replaceAll("\\", "/");
16}
17
18export function formatProviderSkillDisplayName(
19 skill: Pick<ServerProviderSkill, "name" | "displayName">,
20): string {
21 const displayName = skill.displayName?.trim();
22 if (displayName) {
23 return displayName;
24 }
25 return titleCaseWords(skill.name);
26}
27
28export function resolveProviderSkillSourceKind(
29 skill: Pick<ServerProviderSkill, "path" | "scope">,
30): ProviderSkillSourceKind {
31 const normalizedPath = normalizePathSeparators(skill.path);
32 if (normalizedPath.includes("/.codex/plugins/") || normalizedPath.includes("/.agents/plugins/")) {
33 return "app";
34 }
35
36 const normalizedScope = skill.scope?.trim().toLowerCase();
37 switch (normalizedScope) {
38 case "repo":
39 case "repository":
40 return "repo";
41 case "project":
42 case "workspace":
43 case "local":
44 return "project";
45 case "user":
46 case "personal":
47 return "personal";
48 case "system":
49 return "system";
50 case undefined:
51 case "":
52 return "other";
53 default:
54 return "other";
55 }
56}