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 {...@@ -2,6 +2,7 @@ import {
2 IconAdjustmentsHorizontal,2 IconAdjustmentsHorizontal,
3 IconAlertCircle,3 IconAlertCircle,
4 IconAlertTriangle,4 IconAlertTriangle,
5 IconApps,
5 IconArchive,6 IconArchive,
6 IconArrowBackUp,7 IconArrowBackUp,
7 IconArrowDownCircle,8 IconArrowDownCircle,
...@@ -13,6 +14,7 @@ import {...@@ -13,6 +14,7 @@ import {
13 IconArrowsMaximize,14 IconArrowsMaximize,
14 IconBellRinging,15 IconBellRinging,
15 IconBolt,16 IconBolt,
17 IconBox,
16 IconCamera,18 IconCamera,
17 IconChartBar,19 IconChartBar,
18 IconCheck,20 IconCheck,
...@@ -104,6 +106,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -104,6 +106,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
104 checkmark: IconCheck,106 checkmark: IconCheck,
105 "checkmark.circle": IconCircleCheck,107 "checkmark.circle": IconCircleCheck,
106 clock: IconClock,108 clock: IconClock,
109 cube: IconBox,
107 "chevron.down": IconChevronDown,110 "chevron.down": IconChevronDown,
108 "chevron.left": IconChevronLeft,111 "chevron.left": IconChevronLeft,
109 "chevron.left.forwardslash.chevron.right": IconCode,112 "chevron.left.forwardslash.chevron.right": IconCode,
...@@ -141,6 +144,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {...@@ -141,6 +144,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
141 "sidebar.right": IconLayoutSidebarRight,144 "sidebar.right": IconLayoutSidebarRight,
142 "slider.horizontal.3": IconAdjustmentsHorizontal,145 "slider.horizontal.3": IconAdjustmentsHorizontal,
143 "square.and.pencil": IconEdit,146 "square.and.pencil": IconEdit,
147 "square.grid.2x2": IconApps,
144 "square.split.2x1": IconLayoutColumns,148 "square.split.2x1": IconLayoutColumns,
145 "sun.max": IconSun,149 "sun.max": IconSun,
146 "stop.fill": IconPlayerStopFilled,150 "stop.fill": IconPlayerStopFilled,
apps/mobile/src/features/threads/ComposerCommandPopover.tsx+18-5
...@@ -1,9 +1,13 @@...@@ -1,9 +1,13 @@
1import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";1import {
2 resolveProviderSkillSourceKind,
3 type ProviderSkillSourceKind,
4} from "@t3tools/client-runtime/providerSkills";
2import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";5import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts";
3import { SymbolView } from "../../components/AppSymbol";6import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
4import { memo } from "react";7import { memo } from "react";
5import { Pressable, ScrollView, View, type ViewStyle } from "react-native";8import { Pressable, ScrollView, View, type ViewStyle } from "react-native";
69
10import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
7import { AppText as Text } from "../../components/AppText";11import { AppText as Text } from "../../components/AppText";
8import { GlassSurface } from "../../components/GlassSurface";12import { GlassSurface } from "../../components/GlassSurface";
9import { PierreEntryIcon } from "../../components/PierreEntryIcon";13import { PierreEntryIcon } from "../../components/PierreEntryIcon";
...@@ -61,13 +65,22 @@ function PopoverSurface(props: { readonly children: React.ReactNode; readonly st...@@ -61,13 +65,22 @@ function PopoverSurface(props: { readonly children: React.ReactNode; readonly st
61 );65 );
62}66}
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 {
65 switch (item.type) {78 switch (item.type) {
66 case "slash-command":79 case "slash-command":
67 case "provider-slash-command":80 case "provider-slash-command":
68 return "terminal" as const;81 return "terminal";
69 case "skill":82 case "skill":
70 return "cube" as const;83 return SKILL_SOURCE_SYMBOL_BY_KIND[resolveProviderSkillSourceKind(item.skill)];
71 case "path":84 case "path":
72 return null;85 return null;
73 }86 }
apps/web/src/components/ChatView.tsx+18-5
...@@ -4183,6 +4183,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4183,6 +4183,14 @@ function ChatViewContent(props: ChatViewProps) {
4183 // partition (same shell, same capability gate, same PR auto-settle input)4183 // partition (same shell, same capability gate, same PR auto-settle input)
4184 // so the banner and the sidebar row never disagree.4184 // so the banner and the sidebar row never disagree.
4185 const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null);4185 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;
4186 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);4194 const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays);
4187 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);4195 const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge);
4188 const activeThreadPr = resolveDisplayedThreadPr({4196 const activeThreadPr = resolveDisplayedThreadPr({
...@@ -4519,13 +4527,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4519,13 +4527,13 @@ function ChatViewContent(props: ChatViewProps) {
4519 ),4527 ),
4520 title: working4528 title: working
4521 ? liveCount > 04529 ? liveCount > 0
4522 ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background`4530 ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working`
4523 : "Background work running"4531 : "Background work"
4524 : "Monitoring in the background",4532 : "Monitoring",
4525 actions: (4533 actions: (
4526 <Button4534 <Button
4527 size="xs"4535 size="xs"
4528 variant="outline"4536 variant="ghost"
4529 disabled={isStoppingBackgroundWork}4537 disabled={isStoppingBackgroundWork}
4530 onClick={() => void handleStopBackgroundWork()}4538 onClick={() => void handleStopBackgroundWork()}
4531 >4539 >
...@@ -4687,7 +4695,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4687,7 +4695,6 @@ function ChatViewContent(props: ChatViewProps) {
4687 systemComposerBannerItems,4695 systemComposerBannerItems,
4688 wokeThreadBannerItem,4696 wokeThreadBannerItem,
4689 ]);4697 ]);
4690
4691 useEffect(() => {4698 useEffect(() => {
4692 setPendingServerThreadEnvMode(null);4699 setPendingServerThreadEnvMode(null);
4693 setPendingServerThreadBranch(undefined);4700 setPendingServerThreadBranch(undefined);
...@@ -6282,6 +6289,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6282,6 +6289,8 @@ function ChatViewContent(props: ChatViewProps) {
6282 setDragActive: setIsWorkspaceFileDragActive,6289 setDragActive: setIsWorkspaceFileDragActive,
6283 addFiles: (files) => composerRef.current?.addDroppedFiles(files),6290 addFiles: (files) => composerRef.current?.addDroppedFiles(files),
6284 });6291 });
6292 const externalComposerDrawerAttached =
6293 composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable);
62856294
6286 return (6295 return (
6287 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">6296 <div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
...@@ -6485,6 +6494,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6485,6 +6494,7 @@ function ChatViewContent(props: ChatViewProps) {
6485 <div6494 <div
6486 className={cn(6495 className={cn(
6487 "chat-composer-glass-shell relative mx-auto w-full max-w-3xl",6496 "chat-composer-glass-shell relative mx-auto w-full max-w-3xl",
6497 externalComposerDrawerAttached && "chat-composer-glass-shell-attached",
6488 showComposerContextStrip && "chat-composer-glass-shell-with-context",6498 showComposerContextStrip && "chat-composer-glass-shell-with-context",
6489 )}6499 )}
6490 >6500 >
...@@ -6509,6 +6519,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6509,6 +6519,7 @@ function ChatViewContent(props: ChatViewProps) {
6509 isSendBusy={isSendBusy}6519 isSendBusy={isSendBusy}
6510 sendDisabledReason={threadDetailLoading ? "Messages loading" : null}6520 sendDisabledReason={threadDetailLoading ? "Messages loading" : null}
6511 isPreparingWorktree={isPreparingWorktree}6521 isPreparingWorktree={isPreparingWorktree}
6522 externalDrawerAttached={externalComposerDrawerAttached}
6512 environmentUnavailable={activeEnvironmentUnavailableState}6523 environmentUnavailable={activeEnvironmentUnavailableState}
6513 activePendingApproval={activePendingApproval}6524 activePendingApproval={activePendingApproval}
6514 pendingApprovals={pendingApprovals}6525 pendingApprovals={pendingApprovals}
...@@ -6521,6 +6532,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6521,6 +6532,8 @@ function ChatViewContent(props: ChatViewProps) {
6521 respondingRequestIds={respondingRequestIds}6532 respondingRequestIds={respondingRequestIds}
6522 showPlanFollowUpPrompt={showPlanFollowUpPrompt}6533 showPlanFollowUpPrompt={showPlanFollowUpPrompt}
6523 activeProposedPlan={activeProposedPlan}6534 activeProposedPlan={activeProposedPlan}
6535 activeTasksProgress={activeComposerTasksProgress}
6536 activeTaskSteps={activeComposerTaskSteps}
6524 runtimeMode={runtimeMode}6537 runtimeMode={runtimeMode}
6525 interactionMode={interactionMode}6538 interactionMode={interactionMode}
6526 lockedProvider={lockedProvider}6539 lockedProvider={lockedProvider}
apps/web/src/components/ComposerPromptEditor.tsx+3-3
...@@ -73,13 +73,13 @@ import { basenameOfPath } from "~/pierre-icons";...@@ -73,13 +73,13 @@ import { basenameOfPath } from "~/pierre-icons";
73import {73import {
74 COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME,74 COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME,
75 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,75 COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
76 COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME,
76 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,77 COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME,
77 COMPOSER_INLINE_SKILL_CHIP_LABEL_CLASS_NAME,
78 SKILL_CHIP_ICON_SVG,78 SKILL_CHIP_ICON_SVG,
79} from "./composerInlineChip";79} from "./composerInlineChip";
80import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";80import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip";
81import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";81import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts";
82import { formatProviderSkillDisplayName } from "~/providerSkillPresentation";82import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
83import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";83import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
84import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";84import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste";
8585
...@@ -257,7 +257,7 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s...@@ -257,7 +257,7 @@ function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: s
257 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}257 className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
258 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}258 dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }}
259 />259 />
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>
261 </span>261 </span>
262 );262 );
263263
apps/web/src/components/chat/ChatComposer.tsx+615-469
...@@ -10,6 +10,7 @@ import type {...@@ -10,6 +10,7 @@ import type {
10 ScopedThreadRef,10 ScopedThreadRef,
11 ServerProvider,11 ServerProvider,
12 ThreadId,12 ThreadId,
13 TurnId,
13} from "@t3tools/contracts";14} from "@t3tools/contracts";
14import {15import {
15 isProviderSendTurnSupportedImageMimeType,16 isProviderSendTurnSupportedImageMimeType,
...@@ -65,6 +66,12 @@ import {...@@ -65,6 +66,12 @@ import {
65} from "../../promptStashStore";66} from "../../promptStashStore";
66import { ComposerStashBadge } from "./ComposerStashBadge";67import { ComposerStashBadge } from "./ComposerStashBadge";
67import { ComposerStashMenu } from "./ComposerStashMenu";68import { ComposerStashMenu } from "./ComposerStashMenu";
69import {
70 ComposerTasksBadge,
71 ComposerTasksDrawer,
72 type ComposerTaskStep,
73 type ComposerTasksProgress,
74} from "./ComposerTasksBadge";
68import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression";75import { compressImageForStash, compressImageToByteLimit } from "../../lib/imageCompression";
69import { isCommandPaletteOpen } from "../../commandPaletteBus";76import { isCommandPaletteOpen } from "../../commandPaletteBus";
70import { getTerminalFocusOwner } from "../../lib/terminalFocus";77import { getTerminalFocusOwner } from "../../lib/terminalFocus";
...@@ -143,12 +150,27 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:...@@ -143,12 +150,27 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:
143 }150 }
144151
145 const updatePosition = () => {152 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;
147 const next = {169 const next = {
148 bottom: window.innerHeight - rect.top + 8,170 bottom: window.innerHeight - rect.top - composerOverlap,
149 left: rect.left,171 left: rect.left + drawerInset,
150 maxHeight: Math.max(96, rect.top - 24),172 maxHeight: Math.max(96, rect.top - 24 + composerOverlap),
151 width: rect.width,173 width: Math.max(0, rect.width - drawerInset * 2),
152 };174 };
153 setPosition((current) =>175 setPosition((current) =>
154 current && composerCommandMenuPositionsEqual(current, next) ? current : next,176 current && composerCommandMenuPositionsEqual(current, next) ? current : next,
...@@ -184,6 +206,7 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:...@@ -184,6 +206,7 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children:
184 return createPortal(206 return createPortal(
185 <div207 <div
186 className="pointer-events-auto fixed z-[70]"208 className="pointer-events-auto fixed z-[70]"
209 data-composer-drawer-layer="true"
187 style={{210 style={{
188 bottom: position.bottom,211 bottom: position.bottom,
189 left: position.left,212 left: position.left,
...@@ -228,7 +251,7 @@ import type { SessionPhase, Thread } from "../../types";...@@ -228,7 +251,7 @@ import type { SessionPhase, Thread } from "../../types";
228import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";251import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
229import type { PendingApproval, PendingUserInput } from "../../session-logic";252import type { PendingApproval, PendingUserInput } from "../../session-logic";
230import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";253import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
231import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";254import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
232import { searchProviderSkills } from "../../providerSkillSearch";255import { searchProviderSkills } from "../../providerSkillSearch";
233import { useMediaQuery } from "../../hooks/useMediaQuery";256import { useMediaQuery } from "../../hooks/useMediaQuery";
234import type { ReviewCommentContext } from "../../reviewCommentContext";257import type { ReviewCommentContext } from "../../reviewCommentContext";
...@@ -261,6 +284,7 @@ const runtimeModeConfig: Record<...@@ -261,6 +284,7 @@ const runtimeModeConfig: Record<
261284
262const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];285const runtimeModeOptions = Object.keys(runtimeModeConfig) as RuntimeMode[];
263const COMPOSER_FLOATING_LAYER_SELECTOR = [286const COMPOSER_FLOATING_LAYER_SELECTOR = [
287 '[data-composer-drawer-layer="true"]',
264 '[data-slot="popover-popup"]',288 '[data-slot="popover-popup"]',
265 '[data-slot="menu-popup"]',289 '[data-slot="menu-popup"]',
266 '[data-slot="select-popup"]',290 '[data-slot="select-popup"]',
...@@ -523,6 +547,7 @@ export interface ChatComposerProps {...@@ -523,6 +547,7 @@ export interface ChatComposerProps {
523 isSendBusy: boolean;547 isSendBusy: boolean;
524 sendDisabledReason: string | null;548 sendDisabledReason: string | null;
525 isPreparingWorktree: boolean;549 isPreparingWorktree: boolean;
550 externalDrawerAttached: boolean;
526 environmentUnavailable: {551 environmentUnavailable: {
527 readonly label: string;552 readonly label: string;
528 readonly connection: EnvironmentConnectionPresentation;553 readonly connection: EnvironmentConnectionPresentation;
...@@ -548,6 +573,8 @@ export interface ChatComposerProps {...@@ -548,6 +573,8 @@ export interface ChatComposerProps {
548 // Plan573 // Plan
549 showPlanFollowUpPrompt: boolean;574 showPlanFollowUpPrompt: boolean;
550 activeProposedPlan: Thread["proposedPlans"][number] | null;575 activeProposedPlan: Thread["proposedPlans"][number] | null;
576 activeTasksProgress: ComposerTasksProgress | null;
577 activeTaskSteps: readonly ComposerTaskStep[] | null;
551578
552 // Mode579 // Mode
553 runtimeMode: RuntimeMode;580 runtimeMode: RuntimeMode;
...@@ -642,6 +669,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -642,6 +669,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
642 respondingRequestIds,669 respondingRequestIds,
643 showPlanFollowUpPrompt,670 showPlanFollowUpPrompt,
644 activeProposedPlan,671 activeProposedPlan,
672 activeTasksProgress,
673 activeTaskSteps,
645 runtimeMode,674 runtimeMode,
646 interactionMode,675 interactionMode,
647 lockedProvider,676 lockedProvider,
...@@ -960,6 +989,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -960,6 +989,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
960 );989 );
961 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);990 const [composerMenuAnchor, setComposerMenuAnchor] = useState<HTMLDivElement | null>(null);
962 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);991 const [isStashMenuOpen, setIsStashMenuOpen] = useState(false);
992 const [isTasksDrawerOpen, setIsTasksDrawerOpen] = useState(false);
993 const [dismissedTasksTurnId, setDismissedTasksTurnId] = useState<TurnId | null>(null);
963 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({994 const [stashPulse, setStashPulse] = useState<{ key: number; active: boolean }>({
964 key: 0,995 key: 0,
965 active: false,996 active: false,
...@@ -1146,10 +1177,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1146,10 +1177,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
11461177
1147 const isComposerApprovalState = activePendingApproval !== null;1178 const isComposerApprovalState = activePendingApproval !== null;
1148 const activePendingUserInput = pendingUserInputs[0] ?? null;1179 const activePendingUserInput = pendingUserInputs[0] ?? null;
1149 const hasComposerHeader =1180 const showComposerTopDrawer =
1150 isComposerApprovalState ||1181 isComposerApprovalState ||
1151 pendingUserInputs.length > 0 ||1182 pendingUserInputs.length > 0 ||
1152 (showPlanFollowUpPrompt && activeProposedPlan !== null);1183 (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);
1153 const showCollapsedMobilePromptRow =1184 const showCollapsedMobilePromptRow =
1154 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;1185 isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0;
11551186
...@@ -2274,6 +2305,78 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2274,6 +2305,78 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2274 const toggleStashMenu = useCallback(() => {2305 const toggleStashMenu = useCallback(() => {
2275 setIsStashMenuOpen((open) => !open);2306 setIsStashMenuOpen((open) => !open);
2276 }, []);2307 }, []);
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
2278 // Close the stash menu whenever the trigger-driven command menu opens so2381 // Close the stash menu whenever the trigger-driven command menu opens so
2279 // the two popovers never stack in the same layer, and when the user2382 // 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)...@@ -2301,13 +2404,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2301 // even when the composer is in a state that can't stash.2404 // even when the composer is in a state that can't stash.
2302 event.preventDefault();2405 event.preventDefault();
2303 event.stopPropagation();2406 event.stopPropagation();
2304 if (2407 if (isCommandPaletteOpen()) {
2305 isCommandPaletteOpen() ||2408 return;
2306 isComposerApprovalState ||2409 }
2307 pendingUserInputs.length > 0 ||2410 if (pendingUserInputs.length > 0 && !isComposerApprovalState) {
2308 projectSelectionRequired ||2411 setIsStashMenuOpen((open) => !open);
2309 activePendingProgress !== null2412 return;
2310 ) {2413 }
2414 if (isComposerApprovalState || projectSelectionRequired || activePendingProgress !== null) {
2311 return;2415 return;
2312 }2416 }
2313 void stashCurrentPrompt();2417 void stashCurrentPrompt();
...@@ -2472,7 +2576,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2472,7 +2576,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2472 },2576 },
2473 });2577 });
24742578
2475 const onComposerMentionDragLeaveCapture = (event: React.DragEvent<HTMLDivElement>) => {2579 const onComposerMentionDragLeaveCapture = (event: React.DragEvent<HTMLFormElement>) => {
2476 if (!dataTransferHasComposerMention(event.dataTransfer.types)) return;2580 if (!dataTransferHasComposerMention(event.dataTransfer.types)) return;
2477 event.stopPropagation();2581 event.stopPropagation();
2478 const nextTarget = event.relatedTarget;2582 const nextTarget = event.relatedTarget;
...@@ -2514,14 +2618,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2514,14 +2618,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2514 return;2618 return;
2515 }2619 }
2516 const composerSurface = composerSurfaceRef.current;2620 const composerSurface = composerSurfaceRef.current;
2621 const composerForm = composerFormRef.current;
2517 const activeElement = document.activeElement;2622 const activeElement = document.activeElement;
2518 if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) {2623 if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) {
2519 return;2624 return;
2520 }2625 }
2521 if (2626 if (
2522 composerSurface &&
2523 activeElement instanceof Node &&2627 activeElement instanceof Node &&
2524 composerSurface.contains(activeElement)2628 ((composerSurface && composerSurface.contains(activeElement)) ||
2629 (composerForm && composerForm.contains(activeElement)))
2525 ) {2630 ) {
2526 return;2631 return;
2527 }2632 }
...@@ -2686,85 +2791,73 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2686,85 +2791,73 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2686 <form2791 <form
2687 ref={composerFormRef}2792 ref={composerFormRef}
2688 onSubmit={submitComposer}2793 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}
2689 className="mx-auto w-full min-w-0 max-w-3xl"2816 className="mx-auto w-full min-w-0 max-w-3xl"
2690 data-chat-composer-form="true"2817 data-chat-composer-form="true"
2691 >2818 >
2692 <div2819 {showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
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 >
2702 <div2820 <div
2703 ref={composerSurfaceRef}2821 className="chat-composer-top-drawer"
2704 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}2822 data-chat-composer-top-drawer="true"
2705 className={cn(2823 data-variant={activePendingApproval ? "warning" : "info"}
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 }}
2729 >2824 >
2730 {!isComposerCollapsedMobile &&2825 {!isComposerCollapsedMobile && activePendingApproval ? (
2731 (activePendingApproval ? (2826 <div className="flex min-w-0 flex-wrap items-center gap-1 px-3 py-1.5 sm:px-4">
2732 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">2827 <ComposerPendingApprovalPanel
2733 <ComposerPendingApprovalPanel2828 approval={activePendingApproval}
2734 approval={activePendingApproval}2829 pendingCount={pendingApprovals.length}
2735 pendingCount={pendingApprovals.length}2830 />
2736 />2831 <div className="flex min-w-0 flex-wrap items-center gap-0.5">
2737 </div>2832 <ComposerPendingApprovalActions
2738 ) : pendingUserInputs.length > 0 ? (2833 requestId={activePendingApproval.requestId}
2739 <div className="rounded-t-[19px] border-b border-border/65 bg-muted/20">2834 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
2740 <ComposerPendingUserInputPanel2835 onRespondToApproval={onRespondToApproval}
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}
2754 />2836 />
2755 </div>2837 </div>
2756 ) : null)}2838 </div>
27572839 ) : !isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
2758 {isComposerCollapsedMobile && activePendingApproval ? (2840 <ComposerPendingUserInputPanel
2759 <div2841 pendingUserInputs={pendingUserInputs}
2760 className="rounded-t-[19px] border-b border-border/65 bg-muted/20"2842 respondingRequestIds={respondingRequestIds}
2761 data-chat-composer-collapsed-controls="true"2843 answers={activePendingDraftAnswers}
2762 >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">
2763 <ComposerPendingApprovalPanel2855 <ComposerPendingApprovalPanel
2764 approval={activePendingApproval}2856 approval={activePendingApproval}
2765 pendingCount={pendingApprovals.length}2857 pendingCount={pendingApprovals.length}
2858 className="px-3 pt-2 sm:px-4"
2766 />2859 />
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">
2768 <ComposerPendingApprovalActions2861 <ComposerPendingApprovalActions
2769 requestId={activePendingApproval.requestId}2862 requestId={activePendingApproval.requestId}
2770 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}2863 isResponding={respondingRequestIds.includes(activePendingApproval.requestId)}
...@@ -2773,10 +2866,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2773,10 +2866,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2773 </div>2866 </div>
2774 </div>2867 </div>
2775 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (2868 ) : isComposerCollapsedMobile && pendingUserInputs.length > 0 ? (
2776 <div2869 <div data-chat-composer-collapsed-controls="true">
2777 className="rounded-t-[19px] border-b border-border/65 bg-muted/20"
2778 data-chat-composer-collapsed-controls="true"
2779 >
2780 <ComposerPendingUserInputPanel2870 <ComposerPendingUserInputPanel
2781 pendingUserInputs={pendingUserInputs}2871 pendingUserInputs={pendingUserInputs}
2782 respondingRequestIds={respondingRequestIds}2872 respondingRequestIds={respondingRequestIds}
...@@ -2806,6 +2896,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2806,6 +2896,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2806 >2896 >
2807 {activePendingProgress?.customAnswer || "Write custom answer"}2897 {activePendingProgress?.customAnswer || "Write custom answer"}
2808 </button>2898 </button>
2899 {inlineTasksBadge}
2900 {inlineStashBadge}
2809 {activePendingProgress?.activeQuestion?.multiSelect ? (2901 {activePendingProgress?.activeQuestion?.multiSelect ? (
2810 <ComposerPrimaryActions2902 <ComposerPrimaryActions
2811 compact2903 compact
...@@ -2833,269 +2925,450 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -2833,269 +2925,450 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
2833 </div>2925 </div>
2834 </div>2926 </div>
2835 ) : null}2927 ) : null}
28362928 </div>
2837 {showCollapsedMobilePromptRow ? (2929 ) : null}
2838 <div className="flex items-center justify-between gap-2 px-3 py-2">2930 {isTasksDrawerOpen &&
2839 <button2931 !hasBlockingComposerTopDrawer &&
2840 type="button"2932 visibleTasksProgress &&
2841 className={cn(2933 visibleTaskSteps ? (
2842 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",2934 <ComposerTasksDrawer
2843 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())2935 onDismiss={dismissTasks}
2844 ? "text-foreground"2936 onCollapse={toggleTasksDrawer}
2845 : "text-placeholder",2937 progress={visibleTasksProgress}
2846 )}2938 steps={visibleTaskSteps}
2847 onPointerDown={(event) => event.preventDefault()}2939 />
2848 onClick={expandMobileComposer}2940 ) : null}
2849 aria-label="Expand composer"2941 <div className="relative">
2850 >2942 {visibleTasksProgress &&
2851 {activePendingProgress2943 visibleTaskSteps &&
2852 ? activePendingProgress.customAnswer ||2944 !isTasksDrawerOpen &&
2853 "Type your own answer, or leave this blank to use the selected option"2945 !props.externalDrawerAttached &&
2854 : prompt.trim() ||2946 !showComposerTopDrawer &&
2855 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}2947 !isComposerCollapsedMobile ? (
2856 </button>2948 <ComposerTasksBadge
2857 <button2949 expanded={false}
2858 type="button"2950 hasTrailingShoulder={stashQueue.length > 0}
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"2951 onDismiss={dismissTasks}
2860 disabled={collapsedComposerPrimaryActionDisabled}2952 onToggle={toggleTasksDrawer}
2861 aria-label={collapsedComposerPrimaryActionLabel}2953 progress={visibleTasksProgress}
2862 onPointerDown={(event) => event.preventDefault()}2954 steps={visibleTaskSteps}
2863 onClick={(event) => {2955 />
2864 event.stopPropagation();2956 ) : null}
2865 submitComposer();2957 {!props.externalDrawerAttached &&
2866 }}2958 !showComposerTopDrawer &&
2867 >2959 !isTasksDrawerOpen &&
2868 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">2960 !isComposerCollapsedMobile ? (
2869 <path2961 <ComposerStashBadge
2870 d="M8 3L8 13M8 3L4 7M8 3L12 7"2962 count={stashQueue.length}
2871 stroke="currentColor"2963 menuOpen={isStashMenuOpen}
2872 strokeWidth="2"2964 pulseKey={stashPulse.key}
2873 strokeLinecap="round"2965 pulsing={stashPulse.active}
2874 strokeLinejoin="round"2966 onToggleMenu={toggleStashMenu}
2875 />2967 />
2876 </svg>2968 ) : null}
2877 </button>2969 <div
2878 </div>2970 data-chat-composer-main-surface="true"
2879 ) : null}2971 className={cn(
28802972 "group relative z-10 rounded-[22px] p-px transition-colors duration-200",
2973 composerProviderState.composerFrameClassName,
2974 )}
2975 >
2881 <div2976 <div
2882 ref={setComposerMenuAnchor}2977 ref={composerSurfaceRef}
2978 data-chat-composer-surface="true"
2979 data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
2883 className={cn(2980 className={cn(
2884 "relative px-3 pb-2 sm:px-4",2981 "rounded-[20px] transition-[background-color] duration-200",
2885 hasComposerHeader ? "pt-2.5 sm:pt-3" : "pt-3.5 sm:pt-4",2982 isDragOverComposer ? "bg-accent/45 ring-1 ring-primary/70" : null,
2886 isComposerCollapsedMobile && "hidden",2983 projectSelectionRequired ? "opacity-75" : null,
2984 composerProviderState.composerSurfaceClassName,
2887 )}2985 )}
2888 >2986 >
2889 <ComposerStashBadge2987 {showCollapsedMobilePromptRow ? (
2890 count={stashQueue.length}2988 <div className="flex items-center justify-between gap-2 px-3 py-2">
2891 pulseKey={stashPulse.key}2989 <button
2892 pulsing={stashPulse.active}2990 type="button"
2893 menuOpen={isStashMenuOpen}2991 className={cn(
2894 onToggleMenu={toggleStashMenu}2992 "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-[14px] focus:outline-none",
2895 />2993 (activePendingProgress ? activePendingProgress.customAnswer : prompt.trim())
28962994 ? "text-foreground"
2897 {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && (2995 : "text-placeholder",
2898 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>2996 )}
2899 <ComposerStashMenu2997 onPointerDown={(event) => event.preventDefault()}
2900 entries={stashQueue}2998 onClick={expandMobileComposer}
2901 onRestore={restoreStashEntry}2999 aria-label="Expand composer"
2902 onDelete={deleteStashEntry}3000 >
2903 onClose={() => setIsStashMenuOpen(false)}3001 {activePendingProgress
2904 />3002 ? activePendingProgress.customAnswer ||
2905 </ComposerCommandMenuLayer>3003 "Type your own answer, or leave this blank to use the selected option"
2906 )}3004 : prompt.trim() ||
29073005 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
2908 {composerMenuOpen && !isComposerApprovalState && (3006 </button>
2909 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>3007 {inlineTasksBadge}
2910 <ComposerCommandMenu3008 {inlineStashBadge}
2911 items={composerMenuItems}3009 <button
2912 resolvedTheme={resolvedTheme}3010 type="button"
2913 isLoading={isComposerMenuLoading}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"
2914 triggerKind={composerTriggerKind}3012 disabled={collapsedComposerPrimaryActionDisabled}
2915 groupSlashCommandSections={3013 aria-label={collapsedComposerPrimaryActionLabel}
2916 composerTrigger?.kind === "slash-command" &&3014 onPointerDown={(event) => event.preventDefault()}
2917 composerTrigger.query.trim().length === 03015 onClick={(event) => {
2918 }3016 event.stopPropagation();
2919 emptyStateText={composerMenuEmptyState}3017 submitComposer();
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);
2940 }}3018 }}
2941 className="mb-3"3019 >
2942 />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>
2943 )}3051 )}
29443052
2945 {!isComposerCollapsedMobile &&3053 {composerMenuOpen && !isComposerApprovalState && (
2946 !isComposerApprovalState &&3054 <ComposerCommandMenuLayer anchor={composerMenuAnchor}>
2947 pendingUserInputs.length === 0 &&3055 <ComposerCommandMenu
2948 composerReviewComments.length > 0 && (3056 items={composerMenuItems}
2949 <ComposerPendingReviewComments3057 resolvedTheme={resolvedTheme}
2950 comments={composerReviewComments}3058 isLoading={isComposerMenuLoading}
2951 onRemove={(commentId) =>3059 triggerKind={composerTriggerKind}
2952 removeComposerDraftReviewComment(composerDraftTarget, commentId)3060 emptyStateText={composerMenuEmptyState}
2953 }3061 activeItemId={activeComposerMenuItem?.id ?? null}
2954 className="mb-3"3062 onHighlightedItemChange={onComposerMenuItemHighlighted}
2955 />3063 onSelect={onSelectComposerItem}
3064 />
3065 </ComposerCommandMenuLayer>
2956 )}3066 )}
29573067
2958 {!isComposerCollapsedMobile &&3068 {!isComposerCollapsedMobile &&
2959 !isComposerApprovalState &&3069 !isComposerApprovalState &&
2960 pendingUserInputs.length === 0 &&3070 pendingUserInputs.length === 0 &&
2961 composerElementContexts.length > 0 && (3071 composerPreviewAnnotations.length > 0 && (
2962 <ComposerPendingElementContexts3072 <ComposerPreviewAnnotationCards
2963 contexts={composerElementContexts}3073 annotations={composerPreviewAnnotations}
2964 onRemove={(contextId) =>3074 images={composerImages}
2965 removeComposerDraftElementContext(composerDraftTarget, contextId)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
2966 }3199 }
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}
2968 />3229 />
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 &&3263 <ComposerPromptLengthValidation
2972 !isComposerApprovalState &&3264 message={providerInputSubmissionError ?? composerSubmissionError}
2973 pendingUserInputs.length === 0 &&3265 />
2974 composerImages.some(3266
2975 (image) =>3267 {/* Bottom toolbar */}
2976 !composerPreviewAnnotations.some((annotation) => annotation.id === image.id),3268 {isComposerCollapsedMobile || isComposerApprovalState ? null : (
2977 ) && (3269 <div
2978 <div className="mb-3 flex flex-wrap gap-2">3270 data-chat-composer-footer="true"
2979 {composerImages3271 data-chat-composer-footer-compact={isComposerFooterCompact ? "true" : "false"}
2980 .filter(3272 className={cn(
2981 (image) =>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",
2982 !composerPreviewAnnotations.some(3274 pendingUserInputs.length > 0 && "pt-2",
2983 (annotation) => annotation.id === image.id,3275 isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0",
2984 ),3276 showMobilePendingAnswerActions && "hidden sm:flex",
2985 )3277 )}
2986 .map((image) => (3278 >
2987 <div3279 <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">
2988 key={image.id}3280 {noProviderAvailable ? (
2989 className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background"3281 <Button
2990 >3282 type="button"
2991 {image.previewUrl ? (3283 size="sm"
2992 <button3284 variant="ghost"
2993 type="button"3285 disabled
2994 className="h-full w-full cursor-zoom-in"3286 data-chat-provider-unavailable="true"
2995 aria-label={`Preview ${image.name}`}3287 className="shrink-0 gap-2 px-2 text-secondary-label sm:px-3"
2996 onClick={() => {3288 >
2997 const preview = buildExpandedImagePreview(composerImages, image.id);3289 <CircleAlertIcon className="size-4" />
2998 if (!preview) return;3290 No provider available
2999 onExpandImage(preview);3291 </Button>
3000 }}3292 ) : (
3001 >3293 <ProviderModelPicker
3002 <img3294 compact={isComposerFooterCompact}
3003 src={image.previewUrl}3295 activeInstanceId={selectedInstanceId}
3004 alt={image.name}3296 model={selectedModelForPickerWithCustomFallback}
3005 className="h-full w-full object-cover"3297 lockedProvider={lockedProvider}
3006 />3298 lockedContinuationGroupKey={lockedContinuationGroupKey}
3007 </button>3299 instanceEntries={providerInstanceEntries}
3008 ) : (3300 keybindings={keybindings}
3009 <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-secondary-label">3301 modelOptionsByInstance={modelOptionsByInstance}
3010 {image.name}3302 triggerClassName="-ms-2.5"
3011 </div>3303 terminalOpen={terminalOpen}
3012 )}3304 open={isComposerModelPickerOpen}
3013 {nonPersistedComposerImageIdSet.has(image.id) && (3305 {...(composerProviderState.modelPickerIconClassName
3014 <Tooltip>3306 ? {
3015 <TooltipTrigger3307 activeProviderIconClassName:
3016 render={3308 composerProviderState.modelPickerIconClassName,
3017 <span3309 }
3018 role="img"3310 : {})}
3019 aria-label="Draft attachment may not persist"3311 onOpenChange={(open) => {
3020 className="absolute left-1 top-1 inline-flex items-center justify-center rounded bg-background/85 p-0.5 text-amber-600"3312 setIsComposerModelPickerOpen(open);
3021 >3313 }}
3022 <CircleAlertIcon className="size-3" />3314 getModelDisabledReason={getModelDisabledReason}
3023 </span>3315 onInstanceModelChange={onProviderModelSelect}
3024 }3316 />
3025 />3317 )}
3026 <TooltipPopup3318
3027 side="top"3319 {isComposerFooterCompact ? (
3028 className="max-w-64 whitespace-normal leading-tight"3320 <CompactComposerControlsMenu
3029 >3321 interactionMode={interactionMode}
3030 Draft attachment could not be saved locally and may be lost on3322 runtimeMode={runtimeMode}
3031 navigation.3323 showInteractionModeToggle={composerProviderControls.showInteractionModeToggle}
3032 </TooltipPopup>3324 traitsMenuContent={providerTraitsMenuContent}
3033 </Tooltip>3325 onToggleInteractionMode={toggleInteractionMode}
3034 )}3326 onRuntimeModeChange={handleRuntimeModeChange}
3035 <Button3327 />
3036 variant="ghost"3328 ) : (
3037 size="icon-xs"3329 <>
3038 className="absolute right-1 top-1 bg-background/80 hover:bg-background/90"3330 {providerTraitsPicker ? (
3039 onClick={() => removeComposerImage(image.id)}3331 <>
3040 aria-label={`Remove ${image.name}`}3332 <Separator
3041 >3333 orientation="vertical"
3042 <XIcon />3334 className="mx-0.5 hidden h-4 sm:block"
3043 </Button>3335 />
3044 </div>3336 {providerTraitsPicker}
3045 ))}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 )}
3046 </div>3350 </div>
3047 )}
30483351
3049 <div className="relative">3352 {/* Right side: send / stop button */}
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 ? (
3089 <div3353 <div
3090 data-chat-composer-mobile-pending-actions="true"3354 data-chat-composer-actions="right"
3091 className="absolute bottom-0 right-0 flex justify-end"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"
3092 >3359 >
3093 <ComposerPrimaryActions3360 {showMobilePendingAnswerActions ? null : inlineTasksBadge}
3094 compact3361 {showMobilePendingAnswerActions ? null : inlineStashBadge}
3362 <ComposerFooterPrimaryActions
3363 compact={isComposerPrimaryActionsCompact}
3364 activeContextWindow={activeContextWindow}
3365 activeThreadModelDisplayName={activeThreadModelDisplayName}
3095 pendingAction={pendingPrimaryAction}3366 pendingAction={pendingPrimaryAction}
3096 isRunning={false}3367 isRunning={phase === "running"}
3097 showPlanFollowUpPrompt={false}3368 showPlanFollowUpPrompt={
3098 promptHasText={false}3369 pendingUserInputs.length === 0 && showPlanFollowUpPrompt
3370 }
3371 promptHasText={prompt.trim().length > 0}
3099 isSendBusy={isSendBusy}3372 isSendBusy={isSendBusy}
3100 sendDisabledReason={sendDisabledReason}3373 sendDisabledReason={sendDisabledReason}
3101 isConnecting={isConnecting}3374 isConnecting={isConnecting}
...@@ -3104,145 +3377,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -3104,145 +3377,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
3104 noProviderAvailable ||3377 noProviderAvailable ||
3105 projectSelectionRequired3378 projectSelectionRequired
3106 }3379 }
3107 isPreparingWorktree={false}3380 isPreparingWorktree={isPreparingWorktree}
3108 hasSendableContent={false}3381 hasSendableContent={composerSendState.hasSendableContent}
3109 preserveComposerFocusOnPointerDown3382 preserveComposerFocusOnPointerDown={isMobileViewport}
3383 showSendWhileRunning={isMobileViewport}
3110 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}3384 onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
3111 onInterrupt={handleInterruptPrimaryAction}3385 onInterrupt={handleInterruptPrimaryAction}
3112 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}3386 onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
3113 />3387 />
3114 </div>3388 </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 />
3243 </div>3389 </div>
3244 </div>3390 )}
3245 )}3391 </div>
3246 </div>3392 </div>
3247 </div>3393 </div>
3248 </form>3394 </form>
apps/web/src/components/chat/ComposerBannerStack.test.tsx+7-3
...@@ -36,7 +36,9 @@ describe("ComposerBannerStack", () => {...@@ -36,7 +36,9 @@ describe("ComposerBannerStack", () => {
36 const neutralBehind = renderToStaticMarkup(36 const neutralBehind = renderToStaticMarkup(
37 <ComposerBannerStack items={[banner("front", "default"), banner("stacked", "default")]} />,37 <ComposerBannerStack items={[banner("front", "default"), banner("stacked", "default")]} />,
38 );38 );
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");
40 expect(neutralBehind).not.toContain("border-warning/24");42 expect(neutralBehind).not.toContain("border-warning/24");
4143
42 const warningBehind = renderToStaticMarkup(44 const warningBehind = renderToStaticMarkup(
...@@ -49,12 +51,14 @@ describe("ComposerBannerStack", () => {...@@ -49,12 +51,14 @@ describe("ComposerBannerStack", () => {
49 const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />);51 const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />);
5052
51 expect(markup).not.toContain("data-composer-banner-stack-expanded-items");53 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"');
53 expect(markup).toContain('data-variant="warning"');58 expect(markup).toContain('data-variant="warning"');
54 expect(markup).toContain("transform:none");59 expect(markup).toContain("transform:none");
55 expect(markup).not.toContain("will-change:transform");60 expect(markup).not.toContain("will-change:transform");
56 });61 });
57
58 it("applies item-specific surface and action layout classes", () => {62 it("applies item-specific surface and action layout classes", () => {
59 const markup = renderToStaticMarkup(63 const markup = renderToStaticMarkup(
60 <ComposerBannerStack64 <ComposerBannerStack
apps/web/src/components/chat/ComposerBannerStack.tsx+17-5
...@@ -26,7 +26,7 @@ const exitTransitionStyle = {...@@ -26,7 +26,7 @@ const exitTransitionStyle = {
26// banners are stacked behind it, so its border must match the severity of the26// banners are stacked behind it, so its border must match the severity of the
27// first hidden banner — a neutral banner must not masquerade as a warning.27// first hidden banner — a neutral banner must not masquerade as a warning.
28const stackCapBorderClass: Record<ComposerBannerStackItem["variant"], string> = {28const stackCapBorderClass: Record<ComposerBannerStackItem["variant"], string> = {
29 default: "border-border",29 default: "border-[var(--chat-composer-attached-outline)]",
30 error: "border-destructive/24",30 error: "border-destructive/24",
31 info: "border-info/24",31 info: "border-info/24",
32 success: "border-success/24",32 success: "border-success/24",
...@@ -98,7 +98,10 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -98,7 +98,10 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
98 };98 };
9999
100 return (100 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 >
102 <div105 <div
103 className={cn(106 className={cn(
104 "relative flex flex-col-reverse",107 "relative flex flex-col-reverse",
...@@ -108,8 +111,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -108,8 +111,8 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
108 {showCollapsedStackCap && firstStackedItem ? (111 {showCollapsedStackCap && firstStackedItem ? (
109 <div112 <div
110 className={cn(113 className={cn(
111 "pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-[22px]",114 "pointer-events-none absolute inset-x-0 -top-3 z-0 mx-auto h-3 rounded-t-2xl",
112 "border border-b-0 bg-background/96 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",115 "chat-composer-banner-stack-cap border border-b-0 shadow-[0_6px_18px_rgba(0,0,0,0.06)]",
113 stackCapBorderClass[firstStackedItem.variant],116 stackCapBorderClass[firstStackedItem.variant],
114 "transition-opacity duration-150 ease-out",117 "transition-opacity duration-150 ease-out",
115 "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0",118 "group-hover/banner-stack:opacity-0 group-focus-within/banner-stack:opacity-0",
...@@ -130,6 +133,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -130,6 +133,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
130 >133 >
131 <ComposerBannerStackAlert134 <ComposerBannerStackAlert
132 item={frontItem}135 item={frontItem}
136 attached
133 exiting={exitingItemId === frontItem.id}137 exiting={exitingItemId === frontItem.id}
134 onDismissRequest={() => requestDismiss(frontItem)}138 onDismissRequest={() => requestDismiss(frontItem)}
135 />139 />
...@@ -162,6 +166,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -162,6 +166,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
162 >166 >
163 <ComposerBannerStackAlert167 <ComposerBannerStackAlert
164 item={item}168 item={item}
169 attached={false}
165 exiting={exitingItemId === item.id}170 exiting={exitingItemId === item.id}
166 onDismissRequest={() => requestDismiss(item)}171 onDismissRequest={() => requestDismiss(item)}
167 />172 />
...@@ -178,10 +183,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro...@@ -178,10 +183,12 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
178183
179function ComposerBannerStackAlert({184function ComposerBannerStackAlert({
180 item,185 item,
186 attached,
181 exiting,187 exiting,
182 onDismissRequest,188 onDismissRequest,
183}: {189}: {
184 readonly item: ComposerBannerStackItem;190 readonly item: ComposerBannerStackItem;
191 readonly attached: boolean;
185 readonly exiting: boolean;192 readonly exiting: boolean;
186 readonly onDismissRequest: () => void;193 readonly onDismissRequest: () => void;
187}) {194}) {
...@@ -190,7 +197,12 @@ function ComposerBannerStackAlert({...@@ -190,7 +197,12 @@ function ComposerBannerStackAlert({
190 return (197 return (
191 <Alert198 <Alert
192 variant={item.variant}199 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 )}
194 data-variant={item.variant}206 data-variant={item.variant}
195 >207 >
196 {item.icon}208 {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 @@...@@ -1,23 +1,27 @@
1import {
2 resolveProviderSkillSourceKind,
3 type ProviderSkillSourceKind,
4} from "@t3tools/client-runtime/providerSkills";
1import {5import {
2 type ProjectEntry,6 type ProjectEntry,
3 type ProviderDriverKind,7 type ProviderDriverKind,
4 type ServerProviderSkill,8 type ServerProviderSkill,
5 type ServerProviderSlashCommand,9 type ServerProviderSlashCommand,
6} from "@t3tools/contracts";10} from "@t3tools/contracts";
7import { BotIcon } from "lucide-react";11import {
8import { memo, useLayoutEffect, useMemo, useRef } from "react";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
10import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";22import { type ComposerSlashCommand, type ComposerTriggerKind } from "../../composer-logic";
11import { formatProviderSkillInstallSource } from "~/providerSkillPresentation";
12import { cn } from "~/lib/utils";23import { cn } from "~/lib/utils";
13import {24import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
14 Command,
15 CommandGroup,
16 CommandGroupLabel,
17 CommandItem,
18 CommandList,
19 CommandSeparator,
20} from "../ui/command";
21import { PierreEntryIcon } from "./PierreEntryIcon";25import { PierreEntryIcon } from "./PierreEntryIcon";
2226
23export type ComposerCommandItem =27export type ComposerCommandItem =
...@@ -53,73 +57,17 @@ export type ComposerCommandItem =...@@ -53,73 +57,17 @@ export type ComposerCommandItem =
53 description: string;57 description: string;
54 };58 };
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
106export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {60export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
107 items: ComposerCommandItem[];61 items: ComposerCommandItem[];
108 resolvedTheme: "light" | "dark";62 resolvedTheme: "light" | "dark";
109 isLoading: boolean;63 isLoading: boolean;
110 triggerKind: ComposerTriggerKind | null;64 triggerKind: ComposerTriggerKind | null;
111 groupSlashCommandSections?: boolean;
112 emptyStateText?: string;65 emptyStateText?: string;
113 activeItemId: string | null;66 activeItemId: string | null;
114 onHighlightedItemChange: (itemId: string | null) => void;67 onHighlightedItemChange: (itemId: string | null) => void;
115 onSelect: (item: ComposerCommandItem) => void;68 onSelect: (item: ComposerCommandItem) => void;
116}) {69}) {
117 const listRef = useRef<HTMLDivElement>(null);70 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
124 useLayoutEffect(() => {72 useLayoutEffect(() => {
125 if (!props.activeItemId || !listRef.current) return;73 if (!props.activeItemId || !listRef.current) return;
...@@ -141,57 +89,38 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {...@@ -141,57 +89,38 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
141 >89 >
142 <div90 <div
143 ref={listRef}91 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"
145 >94 >
146 {props.items.length > 0 ? (95 {props.items.length > 0 ? (
147 <CommandList className="max-h-72 not-empty:py-3">96 <CommandList className="max-h-72 scroll-pb-6">
148 {groups.map((group, groupIndex) => (97 <CommandGroup>
149 <div key={group.id}>98 {props.items.map((item) => (
150 {groupIndex > 0 ? <CommandSeparator className="my-0.5" /> : null}99 <ComposerCommandMenuItem
151 <CommandGroup>100 key={item.id}
152 {group.label ? (101 item={item}
153 <CommandGroupLabel className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-secondary-label">102 resolvedTheme={props.resolvedTheme}
154 {group.label}103 isActive={props.activeItemId === item.id}
155 </CommandGroupLabel>104 onHighlight={props.onHighlightedItemChange}
156 ) : null}105 onSelect={props.onSelect}
157 {group.items.map((item) => (106 />
158 <ComposerCommandMenuItem107 ))}
159 key={item.id}108 </CommandGroup>
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 ))}
170 </CommandList>109 </CommandList>
171 ) : (110 ) : (
172 <div className="px-5 py-3.5">111 <div className="px-5 pt-3.5 pb-7">
173 {props.triggerKind === "skill" ? (112 <p className="text-secondary-label text-xs">
174 <CommandGroup>113 {props.isLoading
175 <CommandGroupLabel className="px-0 pt-0 pb-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-secondary-label">114 ? props.triggerKind === "skill"
176 Skills115 ? "Searching workspace skills..."
177 </CommandGroupLabel>116 : "Searching workspace files..."
178 <p className="text-secondary-label text-xs">117 : (props.emptyStateText ??
179 {props.isLoading118 (props.triggerKind === "skill"
180 ? "Searching workspace skills..."119 ? "No skills found. Try / to browse provider commands."
181 : (props.emptyStateText ??120 : props.triggerKind === "path"
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"
191 ? "No matching files or folders."121 ? "No matching files or folders."
192 : "No matching command."))}122 : "No matching command."))}
193 </p>123 </p>
194 )}
195 </div>124 </div>
196 )}125 )}
197 </div>126 </div>
...@@ -206,15 +135,15 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -206,15 +135,15 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
206 onHighlight: (itemId: string | null) => void;135 onHighlight: (itemId: string | null) => void;
207 onSelect: (item: ComposerCommandItem) => void;136 onSelect: (item: ComposerCommandItem) => void;
208}) {137}) {
209 const skillSourceLabel =138 const skillSourceKind =
210 props.item.type === "skill" ? formatProviderSkillInstallSource(props.item.skill) : null;139 props.item.type === "skill" ? resolveProviderSkillSourceKind(props.item.skill) : null;
211140
212 return (141 return (
213 <CommandItem142 <CommandItem
214 value={props.item.id}143 value={props.item.id}
215 data-composer-item-id={props.item.id}144 data-composer-item-id={props.item.id}
216 className={cn(145 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",
218 props.isActive && "bg-accent! text-accent-foreground!",147 props.isActive && "bg-accent! text-accent-foreground!",
219 )}148 )}
220 onMouseMove={() => {149 onMouseMove={() => {
...@@ -233,29 +162,43 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {...@@ -233,29 +162,43 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
233 kind={props.item.pathKind}162 kind={props.item.pathKind}
234 theme={props.resolvedTheme}163 theme={props.resolvedTheme}
235 />164 />
165 ) : skillSourceKind ? (
166 <SkillSourceIcon kind={skillSourceKind} />
236 ) : null}167 ) : null}
237 {props.item.type === "slash-command" ? (168 <span className="flex min-w-0 flex-1 items-baseline gap-3">
238 <BotIcon className="size-4 shrink-0 text-icon-muted" />169 <span className="shrink-0 font-sans text-xs font-medium">{props.item.label}</span>
239 ) : null}170 <span className="min-w-0 flex-1 truncate text-right text-secondary-label text-xs">
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">
253 {props.item.description}171 {props.item.description}
254 </span>172 </span>
255 </span>173 </span>
256 {skillSourceLabel ? (
257 <span className="shrink-0 pl-2 text-secondary-label text-xs">{skillSourceLabel}</span>
258 ) : null}
259 </CommandItem>174 </CommandItem>
260 );175 );
261});176});
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 {...@@ -11,6 +11,8 @@ interface ComposerPendingApprovalActionsProps {
11 ) => Promise<unknown>;11 ) => Promise<unknown>;
12}12}
1313
14const APPROVAL_ACTION_CLASS_NAME = "font-normal";
15
14export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({16export const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActions({
15 requestId,17 requestId,
16 isResponding,18 isResponding,
...@@ -19,36 +21,40 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro...@@ -19,36 +21,40 @@ export const ComposerPendingApprovalActions = memo(function ComposerPendingAppro
19 return (21 return (
20 <>22 <>
21 <Button23 <Button
22 size="sm"24 size="micro"
23 variant="ghost"25 variant="ghost-muted"
26 className={APPROVAL_ACTION_CLASS_NAME}
24 disabled={isResponding}27 disabled={isResponding}
25 onClick={() => void onRespondToApproval(requestId, "cancel")}28 onClick={() => void onRespondToApproval(requestId, "cancel")}
26 >29 >
27 Cancel turn30 Cancel
28 </Button>31 </Button>
29 <Button32 <Button
30 size="sm"33 size="micro"
31 variant="destructive-outline"34 variant="ghost-muted"
35 className={`${APPROVAL_ACTION_CLASS_NAME} text-destructive-foreground [:hover,[data-pressed]]:text-destructive-foreground`}
32 disabled={isResponding}36 disabled={isResponding}
33 onClick={() => void onRespondToApproval(requestId, "decline")}37 onClick={() => void onRespondToApproval(requestId, "decline")}
34 >38 >
35 Decline39 Decline
36 </Button>40 </Button>
37 <Button41 <Button
38 size="sm"42 size="micro"
39 variant="outline"43 variant="ghost-muted"
44 className={APPROVAL_ACTION_CLASS_NAME}
40 disabled={isResponding}45 disabled={isResponding}
41 onClick={() => void onRespondToApproval(requestId, "acceptForSession")}46 onClick={() => void onRespondToApproval(requestId, "acceptForSession")}
42 >47 >
43 Always allow this session48 Always allow this session
44 </Button>49 </Button>
45 <Button50 <Button
46 size="sm"51 size="micro"
47 variant="default"52 variant="ghost-muted"
53 className={`${APPROVAL_ACTION_CLASS_NAME} text-foreground`}
48 disabled={isResponding}54 disabled={isResponding}
49 onClick={() => void onRespondToApproval(requestId, "accept")}55 onClick={() => void onRespondToApproval(requestId, "accept")}
50 >56 >
51 Approve once57 Approve
52 </Button>58 </Button>
53 </>59 </>
54 );60 );
apps/web/src/components/chat/ComposerPendingApprovalPanel.test.tsx+25-3
...@@ -5,7 +5,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -5,7 +5,7 @@ import { describe, expect, it } from "vite-plus/test";
5import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";5import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
66
7describe("ComposerPendingApprovalPanel", () => {7describe("ComposerPendingApprovalPanel", () => {
8 it("renders complete multiline command details without hover or truncation", () => {8 it("keeps the complete command readable in the compact row", () => {
9 const detail = `bun run release -- ${"x".repeat(500)}\nsecond line`;9 const detail = `bun run release -- ${"x".repeat(500)}\nsecond line`;
10 const markup = renderToStaticMarkup(10 const markup = renderToStaticMarkup(
11 <ComposerPendingApprovalPanel11 <ComposerPendingApprovalPanel
...@@ -21,11 +21,33 @@ describe("ComposerPendingApprovalPanel", () => {...@@ -21,11 +21,33 @@ describe("ComposerPendingApprovalPanel", () => {
2121
22 expect(markup).toContain('data-approval-detail="complete"');22 expect(markup).toContain('data-approval-detail="complete"');
23 expect(markup).toContain('aria-label="Command"');23 expect(markup).toContain('aria-label="Command"');
24 expect(markup).toContain('role="group"');
25 expect(markup).toContain('tabindex="0"');
24 expect(markup).toContain(detail);26 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");
25 expect(markup).not.toContain("truncate");32 expect(markup).not.toContain("truncate");
26 expect(markup).not.toContain("line-clamp");33 expect(markup).not.toContain("line-clamp");
27 expect(markup).toContain("min-w-0");34 expect(markup).toContain("min-w-0");
28 expect(markup).toContain("max-w-full");35 expect(markup).not.toContain("Command approval requested");
29 expect(markup).toContain("[overflow-wrap:anywhere]");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");
30 });52 });
31});53});
apps/web/src/components/chat/ComposerPendingApprovalPanel.tsx+25-24
...@@ -1,22 +1,25 @@...@@ -1,22 +1,25 @@
1import { memo } from "react";1import { memo } from "react";
2import { type PendingApproval } from "../../session-logic";2import { type PendingApproval } from "../../session-logic";
3import { cn } from "~/lib/utils";
34
4interface ComposerPendingApprovalPanelProps {5interface ComposerPendingApprovalPanelProps {
5 approval: PendingApproval;6 approval: PendingApproval;
6 pendingCount: number;7 pendingCount: number;
8 className?: string;
7}9}
810
9export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprovalPanel({11export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprovalPanel({
10 approval,12 approval,
11 pendingCount,13 pendingCount,
14 className,
12}: ComposerPendingApprovalPanelProps) {15}: ComposerPendingApprovalPanelProps) {
13 const approvalSummary =16 const fallbackLabel =
14 approval.requestKind === "command"17 approval.requestKind === "command"
15 ? "Command approval requested"18 ? "Command approval"
16 : approval.requestKind === "file-read"19 : approval.requestKind === "file-read"
17 ? "File-read approval requested"20 ? "File read approval"
18 : "File-change approval requested";21 : "File change approval";
19 const detailLabel =22 const detailAriaLabel =
20 approval.requestKind === "command"23 approval.requestKind === "command"
21 ? "Command"24 ? "Command"
22 : approval.requestKind === "file-read"25 : approval.requestKind === "file-read"
...@@ -24,25 +27,23 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova...@@ -24,25 +27,23 @@ export const ComposerPendingApprovalPanel = memo(function ComposerPendingApprova
24 : "File change";27 : "File change";
2528
26 return (29 return (
27 <div className="min-w-0 px-4 py-3.5 sm:px-5 sm:py-4">30 <div
28 <div className="flex flex-wrap items-center gap-2">31 aria-label={fallbackLabel}
29 <span className="uppercase text-sm tracking-[0.2em]">PENDING APPROVAL</span>32 className={cn("flex min-w-0 flex-1 items-center gap-2", className)}
30 <span className="text-sm font-medium">{approvalSummary}</span>33 role="group"
31 {pendingCount > 1 ? (34 >
32 <span className="text-xs text-muted-foreground">1/{pendingCount}</span>35 <code
33 ) : null}36 aria-label={detailAriaLabel}
34 </div>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"
35 {approval.detail ? (38 data-approval-detail="complete"
36 <div className="mt-3 min-w-0 max-w-full rounded-lg border border-border/65 bg-background/70 p-3">39 tabIndex={0}
37 <p className="text-xs font-medium text-muted-foreground">{detailLabel}</p>40 >
38 <pre41 {approval.detail || fallbackLabel}
39 aria-label={detailLabel}42 </code>
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"43 {pendingCount > 1 ? (
41 data-approval-detail="complete"44 <span className="shrink-0 text-[10px] font-medium text-muted-foreground tabular-nums">
42 >45 1/{pendingCount}
43 {approval.detail}46 </span>
44 </pre>
45 </div>
46 ) : null}47 ) : null}
47 </div>48 </div>
48 );49 );
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+13-14
...@@ -167,7 +167,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -167,7 +167,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
167167
168 return (168 return (
169 <Collapsible169 <Collapsible
170 className="py-3"170 className="py-2"
171 open={!isCollapsed}171 open={!isCollapsed}
172 onOpenChange={(open) => {172 onOpenChange={(open) => {
173 setCollapsedQuestionId(open ? null : activeQuestion.id);173 setCollapsedQuestionId(open ? null : activeQuestion.id);
...@@ -179,19 +179,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -179,19 +179,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
179 header label and the chevron still line up with the left and right179 header label and the chevron still line up with the left and right
180 edges of the question text below. The negative block margin keeps the180 edges of the question text below. The negative block margin keeps the
181 taller hit area from pushing the panel down. */}181 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">
183 <CollapsibleTrigger183 <CollapsibleTrigger
184 title={184 title={
185 isCollapsed ? "Show the question and its options" : "Hide the question and its options"185 isCollapsed ? "Show the question and its options" : "Hide the question and its options"
186 }186 }
187 data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}187 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"
189 >189 >
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">
191 {activeQuestion.header}191 {activeQuestion.header}
192 </span>192 </span>
193 {prompt.questions.length > 1 ? (193 {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">
195 {questionIndex + 1}/{prompt.questions.length}195 {questionIndex + 1}/{prompt.questions.length}
196 </span>196 </span>
197 ) : null}197 ) : null}
...@@ -217,13 +217,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -217,13 +217,13 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
217 {/* The panel carries the horizontal padding itself: it clips its content217 {/* The panel carries the horizontal padding itself: it clips its content
218 while the height animates, so the option buttons have to sit inside218 while the height animates, so the option buttons have to sit inside
219 that padding or their focus rings get shaved off at the edges. */}219 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">
221 <div className="pt-2 pb-0.5">221 <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>
223 {activeQuestion.multiSelect ? (223 {activeQuestion.multiSelect ? (
224 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>224 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
225 ) : null}225 ) : null}
226 <div className="mt-3 space-y-1.5">226 <div className="mt-2 space-y-0.5">
227 {activeQuestion.options.map((option, index) => {227 {activeQuestion.options.map((option, index) => {
228 const isOptimisticallySelected =228 const isOptimisticallySelected =
229 optimisticSingleSelect?.questionId === activeQuestion.id &&229 optimisticSingleSelect?.questionId === activeQuestion.id &&
...@@ -233,10 +233,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -233,10 +233,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
233 (!customAnswerActive && progress.selectedOptionLabels.includes(option.label));233 (!customAnswerActive && progress.selectedOptionLabels.includes(option.label));
234 const shortcutKey = index < 9 ? index + 1 : null;234 const shortcutKey = index < 9 ? index + 1 : null;
235 const className = cn(235 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",
237 isSelected237 isSelected
238 ? "border-primary/30 bg-primary/8 text-foreground"238 ? "bg-muted/55 text-foreground"
239 : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34",239 : "bg-transparent text-foreground/85 hover:bg-muted/30",
240 isResponding && "opacity-50 cursor-not-allowed",240 isResponding && "opacity-50 cursor-not-allowed",
241 !isResponding && "cursor-pointer",241 !isResponding && "cursor-pointer",
242 );242 );
...@@ -245,7 +245,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -245,7 +245,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
245 <div className="min-w-0 flex-1 flex flex-col gap-0.5">245 <div className="min-w-0 flex-1 flex flex-col gap-0.5">
246 <span className="text-sm font-medium">{option.label}</span>246 <span className="text-sm font-medium">{option.label}</span>
247 {option.description && option.description !== option.label ? (247 {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>
249 ) : null}249 ) : null}
250 </div>250 </div>
251 {isSelected ? (251 {isSelected ? (
...@@ -253,8 +253,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(...@@ -253,8 +253,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
253 ) : shortcutKey !== null ? (253 ) : shortcutKey !== null ? (
254 <kbd254 <kbd
255 className={cn(255 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",256 "flex size-5 shrink-0 items-center justify-center text-[10px] font-medium text-muted-foreground tabular-nums",
257 "bg-background/35 text-secondary-label group-hover:border-border/70 group-hover:text-foreground",
258 )}257 )}
259 >258 >
260 {shortcutKey}259 {shortcutKey}
apps/web/src/components/chat/ComposerPlanFollowUpBanner.tsx+3-10
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import { memo } from "react";1import { memo } from "react";
2import { Badge } from "../ui/badge";
32
4export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({3export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBanner({
5 planTitle,4 planTitle,
...@@ -7,17 +6,11 @@ export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBann...@@ -7,17 +6,11 @@ export const ComposerPlanFollowUpBanner = memo(function ComposerPlanFollowUpBann
7 planTitle: string | null;6 planTitle: string | null;
8}) {7}) {
9 return (8 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">
11 <div className="flex flex-wrap items-center gap-2">10 <div className="flex flex-wrap items-center gap-2">
12 <Badge11 <span className="text-xs font-medium text-muted-foreground">Plan ready</span>
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>
19 {planTitle ? (12 {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>
21 ) : null}14 ) : null}
22 </div>15 </div>
23 {/* <div className="mt-2 text-xs text-muted-foreground">16 {/* <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";...@@ -2,10 +2,12 @@ import { BookmarkIcon } from "lucide-react";
2import { memo } from "react";2import { memo } from "react";
33
4import { cn } from "~/lib/utils";4import { cn } from "~/lib/utils";
5import { Button } from "../ui/button";
56
6/**7/**
7 * Bookmark pill perched on the composer's top-right shoulder. Shows the8 * Bookmark control that shows the stash count and opens the stash menu. It
8 * stash count and doubles as the click target for opening the stash menu.9 * sits behind the composer's shoulder unless another drawer needs that space,
10 * then moves into the composer's existing controls.
9 *11 *
10 * On save the badge gives one quiet acknowledgement: it lifts to full12 * On save the badge gives one quiet acknowledgement: it lifts to full
11 * opacity and the count ticks over. `pulseKey` changes per stash, remounting13 * opacity and the count ticks over. `pulseKey` changes per stash, remounting
...@@ -13,12 +15,49 @@ import { cn } from "~/lib/utils";...@@ -13,12 +15,49 @@ import { cn } from "~/lib/utils";
13 */15 */
14export const ComposerStashBadge = memo(function ComposerStashBadge(props: {16export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
15 count: number;17 count: number;
18 menuOpen: boolean;
19 placement?: "inline" | "tab";
16 pulseKey: number;20 pulseKey: number;
17 pulsing: boolean;21 pulsing: boolean;
18 menuOpen: boolean;
19 onToggleMenu: () => void;22 onToggleMenu: () => void;
20}) {23}) {
21 if (props.count === 0) return null;24 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
23 return (62 return (
24 <button63 <button
...@@ -27,11 +66,12 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {...@@ -27,11 +66,12 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
27 aria-label={`Stashed prompts: ${props.count}. Open stash.`}66 aria-label={`Stashed prompts: ${props.count}. Open stash.`}
28 aria-expanded={props.menuOpen}67 aria-expanded={props.menuOpen}
29 className={cn(68 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",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",
31 "transition-[color,border-color,opacity] duration-200",70 "transition-[color,border-color] duration-200",
71 props.menuOpen && "pointer-events-none",
32 props.menuOpen || props.pulsing72 props.menuOpen || props.pulsing
33 ? "border-border text-foreground opacity-100"73 ? "text-foreground"
34 : "border-border/70 text-muted-foreground opacity-70 hover:opacity-100 hover:text-foreground",74 : "text-muted-foreground hover:text-foreground",
35 )}75 )}
36 onPointerDown={(event) => {76 onPointerDown={(event) => {
37 // Keep composer focus so Escape/typing flows stay intact.77 // Keep composer focus so Escape/typing flows stay intact.
...@@ -39,19 +79,9 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {...@@ -39,19 +79,9 @@ export const ComposerStashBadge = memo(function ComposerStashBadge(props: {
39 }}79 }}
40 onClick={props.onToggleMenu}80 onClick={props.onToggleMenu}
41 >81 >
42 <BookmarkIcon className="size-3" aria-hidden="true" />82 <BookmarkIcon className="size-3 shrink-0" aria-hidden="true" />
43 Stash83 Stash
44 <span84 {count}
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>
55 </button>85 </button>
56 );86 );
57});87});
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 @@...@@ -1,10 +1,10 @@
1import { BookmarkIcon, XIcon } from "lucide-react";1import { XIcon } from "lucide-react";
2import { memo, useEffect, useState } from "react";2import { memo, useEffect, useRef, useState } from "react";
33
4import { formatRelativeTimeLabel } from "../../timestampFormat";4import { formatRelativeTimeLabel } from "../../timestampFormat";
5import { cn } from "~/lib/utils";5import { cn } from "~/lib/utils";
6import { type PromptStashEntry } from "../../promptStashStore";6import { type PromptStashEntry } from "../../promptStashStore";
7import { Command, CommandGroup, CommandGroupLabel, CommandItem, CommandList } from "../ui/command";7import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
8import { Button } from "../ui/button";8import { Button } from "../ui/button";
99
10const SNIPPET_MAX_CHARS = 90;10const SNIPPET_MAX_CHARS = 90;
...@@ -36,10 +36,27 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -36,10 +36,27 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
36 onClose: () => void;36 onClose: () => void;
37}) {37}) {
38 const { entries, onRestore, onDelete, onClose } = props;38 const { entries, onRestore, onDelete, onClose } = props;
39 const drawerRef = useRef<HTMLDivElement>(null);
39 const [highlightedId, setHighlightedId] = useState<string | null>(entries[0]?.id ?? null);40 const [highlightedId, setHighlightedId] = useState<string | null>(entries[0]?.id ?? null);
4041
41 const highlightedEntry = entries.find((entry) => entry.id === highlightedId) ?? entries[0];42 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
43 useEffect(() => {60 useEffect(() => {
44 if (entries.length === 0) return;61 if (entries.length === 0) return;
45 if (!entries.some((entry) => entry.id === highlightedId)) {62 if (!entries.some((entry) => entry.id === highlightedId)) {
...@@ -91,15 +108,26 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -91,15 +108,26 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
91108
92 return (109 return (
93 <Command autoHighlight={false} mode="none">110 <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%)]">111 <div
95 <CommandList className="max-h-72">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">
96 <CommandGroup>128 <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>
101 {entries.length === 0 ? (129 {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">
103 Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it.131 Nothing stashed yet. Press ⌘S with a prompt in the composer to stash it.
104 </p>132 </p>
105 ) : (133 ) : (
...@@ -108,7 +136,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -108,7 +136,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
108 key={entry.id}136 key={entry.id}
109 value={entry.id}137 value={entry.id}
110 className={cn(138 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",
112 highlightedId === entry.id && "bg-accent! text-accent-foreground!",140 highlightedId === entry.id && "bg-accent! text-accent-foreground!",
113 )}141 )}
114 onMouseMove={() => {142 onMouseMove={() => {
...@@ -121,22 +149,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -121,22 +149,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
121 onRestore(entry);149 onRestore(entry);
122 }}150 }}
123 >151 >
124 {entry.attachments.length > 0 ? (152 <span className="min-w-0 flex-1 truncate text-xs">
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">
140 {stashEntrySnippet(entry)}153 {stashEntrySnippet(entry)}
141 </span>154 </span>
142 {entry.pendingImageCount ? (155 {entry.pendingImageCount ? (
...@@ -150,20 +163,33 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {...@@ -150,20 +163,33 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: {
150 {missingImageCount(entry) === 1 ? "" : "s"} dropped163 {missingImageCount(entry) === 1 ? "" : "s"} dropped
151 </span>164 </span>
152 ) : null}165 ) : 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">
154 {formatRelativeTimeLabel(entry.createdAt)}180 {formatRelativeTimeLabel(entry.createdAt)}
155 </span>181 </span>
156 <Button182 <Button
157 variant="ghost"183 variant="ghost"
158 size="icon-xs"184 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"
160 aria-label="Delete stashed prompt"186 aria-label="Delete stashed prompt"
161 onClick={(event) => {187 onClick={(event) => {
162 event.stopPropagation();188 event.stopPropagation();
163 onDelete(entry);189 onDelete(entry);
164 }}190 }}
165 >191 >
166 <XIcon />192 <XIcon className="size-3.5 stroke-2" />
167 </Button>193 </Button>
168 </CommandItem>194 </CommandItem>
169 ))195 ))
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 @@...@@ -1,7 +1,7 @@
1import { Children, cloneElement, isValidElement, type ReactNode } from "react";1import { Children, cloneElement, isValidElement, type ReactNode } from "react";
2import type { ServerProviderSkill } from "@t3tools/contracts";2import type { ServerProviderSkill } from "@t3tools/contracts";
3import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
34
4import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
5import {5import {
6 CHAT_INLINE_CHIP_CLASS_NAME,6 CHAT_INLINE_CHIP_CLASS_NAME,
7 CHAT_INLINE_CHIP_LABEL_CLASS_NAME,7 CHAT_INLINE_CHIP_LABEL_CLASS_NAME,
apps/web/src/components/chat/ThreadSyncStatusPill.test.tsx+5
...@@ -11,6 +11,11 @@ describe("ThreadSyncStatusPill", () => {...@@ -11,6 +11,11 @@ describe("ThreadSyncStatusPill", () => {
11 const markup = renderToStaticMarkup(<ThreadSyncStatusPill phase={phase} />);11 const markup = renderToStaticMarkup(<ThreadSyncStatusPill phase={phase} />);
1212
13 expect(markup).toContain('role="status"');13 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)]");
14 expect(markup).toContain(label);19 expect(markup).toContain(label);
15 expect(markup).not.toContain("animate-");20 expect(markup).not.toContain("animate-");
16 });21 });
apps/web/src/components/chat/ThreadSyncStatusPill.tsx+2-1
...@@ -8,7 +8,8 @@ export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhas...@@ -8,7 +8,8 @@ export function ThreadSyncStatusPill({ phase }: { readonly phase: ThreadSyncPhas
8 return (8 return (
9 <div9 <div
10 aria-label={label}10 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"
12 role="status"13 role="status"
13 >14 >
14 <LoaderCircleIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />15 <LoaderCircleIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
apps/web/src/components/composerInlineChip.ts+9-10
...@@ -1,8 +1,10 @@...@@ -1,8 +1,10 @@
1// Chip metrics are in em so the pills scale with the text they sit in (the1// Chip metrics are in em so the pills scale with the text they sit in (the
2// composer honors the prompt font-size preference). The chat variant pins the2// composer honors the prompt font-size preference). The chat variant pins the
3// original 12px, where every em value resolves to the same pixels as before.3// original 12px, where every em value resolves to the same pixels as before.
4const INLINE_CHIP_CLASS_NAME =4const INLINE_CHIP_GEOMETRY_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";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
7export const CHAT_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[12px]`;9export 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-[...@@ -11,18 +13,15 @@ export const COMPOSER_INLINE_CHIP_CLASS_NAME = `${INLINE_CHIP_CLASS_NAME} text-[
11export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME =13export const COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME =
12 "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-['']";14 "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
16export const CHAT_INLINE_CHIP_LABEL_CLASS_NAME = "truncate leading-tight";19export 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`;21export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME =
1922 "block self-center truncate leading-tight select-none";
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]`;
2323
24export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME =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`;
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";
2625
27export 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>`;26export 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", () => {...@@ -31,6 +31,11 @@ describe("button geometry tokens", () => {
3131
32 it("owns shared compact and micro control geometry", () => {32 it("owns shared compact and micro control geometry", () => {
33 const compact = renderToStaticMarkup(<Button size="compact">Condition</Button>);33 const compact = renderToStaticMarkup(<Button size="compact">Condition</Button>);
34 const microLabel = renderToStaticMarkup(
35 <Button size="micro">
36 <FlaskConicalIcon /> Run
37 </Button>,
38 );
34 const micro = renderToStaticMarkup(39 const micro = renderToStaticMarkup(
35 <Button size="icon-micro" variant="ghost-muted" aria-label="Add action">40 <Button size="icon-micro" variant="ghost-muted" aria-label="Add action">
36 <span>+</span>41 <span>+</span>
...@@ -39,6 +44,9 @@ describe("button geometry tokens", () => {...@@ -39,6 +44,9 @@ describe("button geometry tokens", () => {
3944
40 expect(compact).toContain("h-7");45 expect(compact).toContain("h-7");
41 expect(compact).toContain("rounded-md");46 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");
42 expect(micro).toContain("size-5");50 expect(micro).toContain("size-5");
43 expect(micro).toContain("rounded-sm");51 expect(micro).toContain("rounded-sm");
44 expect(micro).toContain("text-muted-foreground");52 expect(micro).toContain("text-muted-foreground");
apps/web/src/components/ui/button.tsx+2
...@@ -29,6 +29,8 @@ const buttonVariants = cva(...@@ -29,6 +29,8 @@ const buttonVariants = cva(
29 "icon-xs":29 "icon-xs":
30 "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",30 "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",
31 lg: "h-10 px-[calc(--spacing(3.5)-1px)] sm:h-9",31 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",
32 sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7",34 sm: "h-8 gap-1.5 px-[calc(--spacing(2.5)-1px)] sm:h-7",
33 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",35 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",
34 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",36 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...@@ -680,7 +680,191 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
680 outline: none;680 outline: none;
681 }681 }
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
683 .chat-composer-glass-shell {866 .chat-composer-glass-shell {
867 --chat-composer-attachment-overlap: calc(1rem + 1px);
684 --chat-composer-glass-surface: var(--card);868 --chat-composer-glass-surface: var(--card);
685 --chat-composer-outline: rgb(0 0 0 / 8%);869 --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...@@ -775,7 +959,83 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
775 content: "";959 content: "";
776 }960 }
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 {
779 /* Keep the attachment seam open; the strip continues the outer outline below. */1039 /* Keep the attachment seam open; the strip continues the outer outline below. */
780 clip-path: polygon(1040 clip-path: polygon(
781 0 0,1041 0 0,
...@@ -822,6 +1082,36 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -822,6 +1082,36 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
822 content: "";1082 content: "";
823 }1083 }
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
825 @supports not (clip-path: shape(from 0 0, line to 1px 1px)) {1115 @supports not (clip-path: shape(from 0 0, line to 1px 1px)) {
826 .chat-composer-glass-shell-with-context::before {1116 .chat-composer-glass-shell-with-context::before {
827 inset-block-end: var(--chat-composer-context-extension);1117 inset-block-end: var(--chat-composer-context-extension);
...@@ -1255,6 +1545,26 @@ html[data-theme-id] .chat-composer-glass-shell {...@@ -1255,6 +1545,26 @@ html[data-theme-id] .chat-composer-glass-shell {
1255 }1545 }
1256}1546}
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
1258html[data-theme-id="t3-chat"] .chat-composer-glass-shell {1568html[data-theme-id="t3-chat"] .chat-composer-glass-shell {
1259 /* T3 Chat's visible composer edge is a dark plum, not the stock translucent1569 /* T3 Chat's visible composer edge is a dark plum, not the stock translucent
1260 white outline. Its highlight is derived from --chat-input-gradient. */1570 white outline. Its highlight is derived from --chat-input-gradient. */
...@@ -1264,6 +1574,16 @@ html[data-theme-id="t3-chat"] .chat-composer-glass-shell {...@@ -1264,6 +1574,16 @@ html[data-theme-id="t3-chat"] .chat-composer-glass-shell {
1264 }1574 }
1265}1575}
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
1267/* Theme-token dependency probes are restored synchronously, before paint. Keep1587/* Theme-token dependency probes are restored synchronously, before paint. Keep
1268 transitions from observing the temporary sentinel color in between. */1588 transitions from observing the temporary sentinel color in between. */
1269html[data-theme-token-probe],1589html[data-theme-token-probe],
...@@ -1933,7 +2253,6 @@ code {...@@ -1933,7 +2253,6 @@ code {
1933 #ff6b6b 100%2253 #ff6b6b 100%
1934 );2254 );
1935 position: relative;2255 position: relative;
1936 background: transparent;
1937}2256}
19382257
1939.ultrathink-frame::before {2258.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 @@...@@ -1,12 +1,11 @@
1import type { ServerProviderSkill } from "@t3tools/contracts";1import type { ServerProviderSkill } from "@t3tools/contracts";
2import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
2import {3import {
3 insertRankedSearchResult,4 insertRankedSearchResult,
4 normalizeSearchQuery,5 normalizeSearchQuery,
5 scoreQueryMatch,6 scoreQueryMatch,
6} from "@t3tools/shared/searchRanking";7} from "@t3tools/shared/searchRanking";
78
8import { formatProviderSkillDisplayName } from "./providerSkillPresentation";
9
10function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null {9function scoreProviderSkill(skill: ServerProviderSkill, query: string): number | null {
11 const normalizedName = skill.name.toLowerCase();10 const normalizedName = skill.name.toLowerCase();
12 const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase();11 const normalizedLabel = formatProviderSkillDisplayName(skill).toLowerCase();
apps/web/src/session-logic.test.ts+161-1
...@@ -408,6 +408,60 @@ describe("deriveActivePlanState", () => {...@@ -408,6 +408,60 @@ describe("deriveActivePlanState", () => {
408 steps: [{ step: "Write tests", status: "completed" }],408 steps: [{ step: "Write tests", status: "completed" }],
409 });409 });
410 });410 });
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 });
411});465});
412466
413describe("deriveTurnPlans", () => {467describe("deriveTurnPlans", () => {
...@@ -455,7 +509,9 @@ describe("deriveTurnPlans", () => {...@@ -455,7 +509,9 @@ describe("deriveTurnPlans", () => {
455 createdAt: "2026-02-23T00:00:01.000Z",509 createdAt: "2026-02-23T00:00:01.000Z",
456 turnId: "turn-1",510 turnId: "turn-1",
457 });511 });
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 ]);
459 expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]);515 expect(turnPlans[1]?.plan.steps).toEqual([{ step: "Ship it", status: "pending" }]);
460 });516 });
461517
...@@ -474,6 +530,110 @@ describe("deriveTurnPlans", () => {...@@ -474,6 +530,110 @@ describe("deriveTurnPlans", () => {
474 expect(deriveTurnPlans(activities)).toEqual([]);530 expect(deriveTurnPlans(activities)).toEqual([]);
475 });531 });
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
477 it("drops a turn's chip when a later snapshot clears the plan", () => {637 it("drops a turn's chip when a later snapshot clears the plan", () => {
478 const activities: OrchestrationThreadActivity[] = [638 const activities: OrchestrationThreadActivity[] = [
479 makeActivity({639 makeActivity({
apps/web/src/session-logic.ts+85-8
...@@ -123,6 +123,7 @@ export interface ActivePlanState {...@@ -123,6 +123,7 @@ export interface ActivePlanState {
123 turnId: TurnId | null;123 turnId: TurnId | null;
124 explanation?: string | null;124 explanation?: string | null;
125 steps: Array<{125 steps: Array<{
126 durationMs?: number;
126 step: string;127 step: string;
127 status: "pending" | "inProgress" | "completed";128 status: "pending" | "inProgress" | "completed";
128 }>;129 }>;
...@@ -579,6 +580,64 @@ function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePla...@@ -579,6 +580,64 @@ function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePla
579 };580 };
580}581}
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
582export function deriveActivePlanState(641export function deriveActivePlanState(
583 activities: ReadonlyArray<OrchestrationThreadActivity>,642 activities: ReadonlyArray<OrchestrationThreadActivity>,
584 latestTurnId: TurnId | undefined,643 latestTurnId: TurnId | undefined,
...@@ -596,7 +655,15 @@ export function deriveActivePlanState(...@@ -596,7 +655,15 @@ export function deriveActivePlanState(
596 if (!latest) {655 if (!latest) {
597 return null;656 return null;
598 }657 }
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));
600}667}
601668
602export interface TurnPlanEntry {669export interface TurnPlanEntry {
...@@ -617,7 +684,10 @@ export function deriveTurnPlans(...@@ -617,7 +684,10 @@ export function deriveTurnPlans(
617 activities: ReadonlyArray<OrchestrationThreadActivity>,684 activities: ReadonlyArray<OrchestrationThreadActivity>,
618): TurnPlanEntry[] {685): TurnPlanEntry[] {
619 const ordered = [...activities].toSorted(compareActivitiesByOrder);686 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 >();
621 for (const activity of ordered) {691 for (const activity of ordered) {
622 if (activity.kind !== "turn.plan.updated") {692 if (activity.kind !== "turn.plan.updated") {
623 continue;693 continue;
...@@ -632,17 +702,24 @@ export function deriveTurnPlans(...@@ -632,17 +702,24 @@ export function deriveTurnPlans(
632 }702 }
633 const existing = byTurn.get(key);703 const existing = byTurn.get(key);
634 if (existing) {704 if (existing) {
635 existing.plan = plan;705 existing.entry.plan = plan;
706 existing.activities.push(activity);
636 } else {707 } else {
637 byTurn.set(key, {708 byTurn.set(key, {
638 id: `turn-plan:${key}`,709 activities: [activity],
639 createdAt: activity.createdAt,710 entry: {
640 turnId: activity.turnId,711 id: `turn-plan:${key}`,
641 plan,712 createdAt: activity.createdAt,
713 turnId: activity.turnId,
714 plan,
715 },
642 });716 });
643 }717 }
644 }718 }
645 return [...byTurn.values()];719 return [...byTurn.values()].map(({ activities: planActivities, entry }) => ({
720 ...entry,
721 plan: addPlanStepDurations(entry.plan, planActivities),
722 }));
646}723}
647724
648export function findLatestProposedPlan(725export function findLatestProposedPlan(
packages/client-runtime/package.json+4
...@@ -35,6 +35,10 @@...@@ -35,6 +35,10 @@
35 "types": "./src/platform/index.ts",35 "types": "./src/platform/index.ts",
36 "default": "./src/platform/index.ts"36 "default": "./src/platform/index.ts"
37 },37 },
38 "./providerSkills": {
39 "types": "./src/providerSkills.ts",
40 "default": "./src/providerSkills.ts"
41 },
38 "./relay": {42 "./relay": {
39 "types": "./src/relay/index.ts",43 "types": "./src/relay/index.ts",
40 "default": "./src/relay/index.ts"44 "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}