diff --git a/.gitignore b/.gitignore index 58b89376a..0acac1441 100644 --- a/.gitignore +++ b/.gitignore @@ -240,6 +240,9 @@ app/desktop/stats.html **/__pycache__/ *.pyc +# TypeScript build artifacts (tsc -b) +*.tsbuildinfo + *.local # L3 真实测试证据(scripts/e2e/run-real-e2e-lane.sh 输出);README.md 保留 diff --git a/app/mobile-rn/src/theme/tokens.ts b/app/mobile-rn/src/theme/tokens.ts index 25e723828..5fda412f3 100644 --- a/app/mobile-rn/src/theme/tokens.ts +++ b/app/mobile-rn/src/theme/tokens.ts @@ -237,7 +237,7 @@ export const agentHubThemes = { warningSoft: 'rgba(212, 170, 76, 0.08)', // --warning-bg danger: '#e87070', // --danger(--td-danger) dangerSoft: 'rgba(232, 112, 112, 0.06)', // --danger-bg - onAccent: '#f4f5ff', // --primary-foreground(--td-ink-on-plum) + onAccent: '#0b1b26', // --primary-foreground(--td-ink-on-plum) onDanger: '#ffffff', // --destructive-foreground scrim: 'rgba(0, 0, 0, 0.48)', // 登记:mobile-only 遮蔽(CSS 无 --td-scrim) }, diff --git a/app/shared/src/chatview/components/AgentGroup.tsx b/app/shared/src/chatview/components/AgentGroup.tsx index f6dab614c..6604a636c 100644 --- a/app/shared/src/chatview/components/AgentGroup.tsx +++ b/app/shared/src/chatview/components/AgentGroup.tsx @@ -7,6 +7,7 @@ import { roleInitial } from '../design/roles' import MarkdownContent from '../../ui/Markdown' import { MessageDisplayMeta } from './MessageDisplayMeta' import { isAgentItemStreaming } from './streaming' +import { Message, MessageAvatar, MessageContent } from '../../shadcn/ui/message' const evidenceIconMap: Record = { file: IconFile, @@ -224,31 +225,29 @@ export const AgentGroup = memo(function AgentGroup({ item, chatMode, onAgentClic if (chatMode === 'dm') { return ( -
-
+ onAgentClick(item.agent, e.currentTarget) : undefined} style={onAgentClick ? { cursor: 'pointer' } : undefined} > {avatar} -
-
{orderedBodyContent ? orderedBody : body}
- -
+ + {orderedBodyContent ? orderedBody : body} + ) } return ( -
-
+ onAgentClick(item.agent, e.currentTarget) : undefined} style={onAgentClick ? { cursor: 'pointer' } : undefined} > {avatar} -
-
{orderedBodyContent ? orderedBody : body}
- -
+ + {orderedBodyContent ? orderedBody : body} + ) }) diff --git a/app/shared/src/chatview/components/RowItem.css b/app/shared/src/chatview/components/RowItem.css index 58c88f58d..065282f32 100644 --- a/app/shared/src/chatview/components/RowItem.css +++ b/app/shared/src/chatview/components/RowItem.css @@ -5,8 +5,11 @@ .row-item { display:flex; flex-direction:column; - background:var(--td-surface); border:1px solid var(--td-line); margin:0; - border-radius:var(--chat-r-md); + background:var(--td-surface); + border:1px solid color-mix(in oklch, var(--td-ink) 12%, transparent); + margin:0; + border-radius:12px; + box-shadow:0 1px 2px rgb(0 0 0 / 0.03); overflow:hidden; } .row-item.selected { outline: 2px solid var(--td-plum); outline-offset: -2px } @@ -30,24 +33,22 @@ .card-stack > .row-item { width:100%; - border-radius:0; + border-radius:12px; } -.card-stack > .row-item + .row-item { border-top:none; margin-top:-1px } -.card-stack > .row-item:first-child { border-radius:var(--chat-r-md) var(--chat-r-md) 0 0 } -.card-stack > .row-item:last-child { border-radius:0 0 var(--chat-r-md) var(--chat-r-md) } -.card-stack > .row-item:only-child { border-radius:var(--chat-r-md) } +.card-stack > .row-item + .row-item { margin-top:0 } +.card-stack > .row-item:only-child { border-radius:12px } .card-stack > .row-item.route { margin:0 } .row-hd { display:flex; align-items:center; gap:var(--chat-sp-xxs); - padding:var(--chat-sp-xs) var(--chat-sp-10); cursor:pointer; user-select:none; - font:var(--chat-label); min-height:28px; line-height:1.3; + padding:8px 12px; cursor:pointer; user-select:none; + font:500 0.78125rem/1.35 var(--td-font); min-height:34px; transition:background var(--chat-dur) ease; border-radius:inherit; border:none; background:none; color:inherit; text-align:inherit; width:100%; } -.row-hd:hover { background:var(--td-surface-3) } +.row-hd:hover { background:color-mix(in oklch, var(--td-ink) 4%, transparent) } .row-hd:focus-visible { outline:none; box-shadow:var(--focus-ring) } /* Artifact rows are preview triggers, not expandable empty file rows. */ @@ -65,7 +66,7 @@ .row-item.running > .row-hd:hover, .row-item:not(.collapsible) > .row-hd:hover { background:transparent } -.row-icon { color:var(--td-ink-muted); flex-shrink:0; width:var(--icon-size); text-align:center; font-size:0.875rem } +.row-icon { color:var(--td-ink-muted); flex-shrink:0; width:15px; text-align:center; font-size:0.875rem } .row-label { font-weight:600; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap } .row-extra { color:var(--td-ink-subtle); font:var(--chat-label-xs); flex-shrink:0 } .row-chevron { color:var(--td-ink-subtle); transition:transform var(--chat-dur) ease; flex-shrink:0; font-size:0.6875rem } @@ -73,9 +74,9 @@ .row-item:not(.collapsible) > .row-hd .row-chevron { display:none } .row-bd { - margin-left:19px; padding:var(--chat-sp-2) var(--chat-sp-md) var(--chat-sp-sm) var(--chat-sp-16); - font:var(--chat-body); color:var(--td-ink-muted); line-height:1.55; - border-left:1px solid var(--td-line); + margin-left:20px; padding:6px 14px 12px; + font:400 0.78125rem/1.6 var(--td-font); color:var(--td-ink-muted); + border-left:1px solid color-mix(in oklch, var(--td-ink) 10%, transparent); overflow:hidden; animation:rowBdExpand var(--chat-dur) var(--td-ease); } diff --git a/app/shared/src/chatview/components/Transcript.css b/app/shared/src/chatview/components/Transcript.css index c7f74eb1f..e4f9e5866 100644 --- a/app/shared/src/chatview/components/Transcript.css +++ b/app/shared/src/chatview/components/Transcript.css @@ -2,7 +2,19 @@ TRANSCRIPT — unified grp-row (DM + Group share same flex layout) ══════════════════════════════════════════════════════════════════════ */ -.transcript { display:flex; flex-direction:column; gap:0; min-width:0; flex:1; min-height:0; overflow-y:auto; scroll-behavior:smooth; scrollbar-gutter:stable; scrollbar-width:thin; padding:0 0 0 10px } +.transcript { + display:flex; + flex-direction:column; + gap:0; + min-width:0; + flex:1; + min-height:0; + overflow-y:auto; + scroll-behavior:smooth; + scrollbar-gutter:stable; + scrollbar-width:thin; + padding:24px max(18px, calc((100% - 780px) / 2)) 12px; +} /* ── Scroll-to-bottom button ────────────────────────────────────────── */ .scroll-to-bottom-btn { @@ -226,20 +238,24 @@ font-weight:400; } -/* ── Row — symmetric: [avatar-l] [content flex:1] [avatar-r] ──────── - Density: row rhythm uses chat density tokens (12px step), not base 20/24. */ -.grp-row { - display:flex; gap:var(--chat-sp-sm); align-items:flex-start; - margin-bottom:var(--chat-sp-md); +/* ── Rows ───────────────────────────────────────────────────────────── + One reading column, generous vertical rhythm, avatar-led alignment. */ +.agent-row, +.user-row-right { + display:flex; + gap:12px; + align-items:flex-start; + margin-bottom:26px; } -.grp-content { flex:1; min-width:0; display:flex; flex-direction:column; gap:var(--chat-sp-2) } +.user-row-right { justify-content:flex-end } +.grp-content { flex:1; min-width:0; display:flex; flex-direction:column; gap:8px } .card-stack { align-self:flex-start; display:flex; flex-direction:column; - gap:0; - width:min(86%, 820px); + gap:8px; + width:100%; max-width:100%; min-width:0; } @@ -247,20 +263,15 @@ /* first child aligns flush to avatar top */ .grp-content > :first-child { margin-top:0 } -/* Bubbles sit on a stable reading column; user replies still shrink right. */ -.grp-content > .agent-bubble { align-self:flex-start; max-width:72% } -.grp-content > .user-bubble { align-self:flex-end; max-width:68% } - .bubble-group { align-self:flex-start; display:flex; flex-direction:column; - gap:var(--chat-sp-xxs); - width:min(86%, 820px); + gap:6px; + width:100%; max-width:100%; min-width:0; } -.bubble-group > .agent-bubble { width:100% } /* ── Message display metadata from Hub/Edge contracts ─────────────── */ .message-display-meta { @@ -343,17 +354,27 @@ } /* ── Bubbles ───────────────────────────────────────────────────────── - Density: tighter vertical padding, keep horizontal reading comfort. */ + User speech is a quiet brand bubble; assistant output is plain prose on + the canvas (Vercel-style), with cards carrying the structured evidence. */ .user-bubble { - background:var(--td-plum); color:var(--td-ink-on-plum); - border-radius:var(--chat-r-lg); padding:var(--chat-sp-xxs) var(--chat-sp-sm); - font:var(--chat-bubble-font); line-height:1.55; word-break:break-word; + max-width:min(100%, 620px); + background:var(--td-plum); + color:var(--td-ink-on-plum); + border:0; + border-radius:18px 18px 6px 18px; + padding:10px 14px; + font:400 0.9375rem/1.6 var(--td-font); + word-break:break-word; + box-shadow:0 1px 1px rgb(0 0 0 / 0.04); } .agent-bubble { - background:var(--td-surface); border:1px solid var(--td-line); - border-radius:var(--chat-r-md); padding:var(--chat-sp-xxs) var(--chat-sp-sm); - font:var(--chat-body); color:var(--td-ink); line-height:1.5; + background:transparent; + border:0; + border-radius:0; + padding:0; + font:400 0.9375rem/1.68 var(--td-font); + color:var(--td-ink); max-width:100%; min-width:0; } @@ -402,9 +423,10 @@ /* ── Avatars ───────────────────────────────────────────────────────── */ .ag-av { - width:var(--avatar-size); height:var(--avatar-size); border-radius:50%; + width:26px; height:26px; border-radius:50%; display:flex; align-items:center; justify-content:center; - font:600 0.75rem var(--td-font); color:var(--td-ink-on-plum); flex-shrink:0; + font:600 0.6875rem var(--td-font); color:var(--td-ink-on-plum); flex-shrink:0; + box-shadow:inset 0 0 0 1px rgb(255 255 255 / 0.12); } .ag-av.builder{background:var(--role-builder)} .ag-av.reviewer{background:var(--role-reviewer)} .ag-av.deployer{background:var(--role-deployer)} .ag-av.researcher{background:var(--role-researcher)} @@ -412,29 +434,25 @@ .ag-av.agent{background:var(--info)} .ag-av.system{background:var(--td-ink-subtle)} .ag-av.user-av{background:var(--td-plum)} -.ag-name { font:var(--chat-label); color:var(--td-ink-muted) } -.ag-time { font:var(--chat-label-xs); font-family:var(--td-mono); font-weight:400; color:var(--td-ink-subtle) } +.ag-name { font:600 0.8125rem/1.3 var(--td-font); color:var(--td-ink) } +.ag-time { font:400 0.6875rem/1.3 var(--td-mono); color:var(--td-ink-subtle) } -.user-meta { display:flex; align-items:center; gap:var(--chat-sp-sm); margin-bottom:var(--chat-sp-2) } +.user-meta { display:flex; align-items:center; gap:8px } -.user-row-right { justify-content:flex-end } .user-content-right { align-items:flex-end } -.agent-meta { display:flex; align-items:center; gap:var(--chat-sp-sm) } +.agent-meta { display:flex; align-items:baseline; gap:8px } .dm-avatar { flex-shrink:0 } - -/* ── Spacer placeholder — preserves symmetric grp-row layout ──────── */ -.dm-spacer { flex-shrink:0; visibility:hidden } -.dm-spacer .ag-av { visibility:hidden } - +.user-avatar { width:26px; height:26px; min-width:26px; border-radius:9999px; background:transparent } /* ── #1825: new-message entry + streaming activity ──────────────────── grp-enter is keyed to the newest same-session append (Transcript sets the class once, clears it after the animation window) so virtualized remounts while scrolling history never replay it. transform/opacity only — layout height is untouched, keeping virtua measurements stable. */ -.grp-row.grp-enter { animation:grpEnter var(--chat-dur-slow) var(--td-ease) both } +.agent-row.grp-enter, +.user-row-right.grp-enter { animation:grpEnter var(--chat-dur-slow) var(--td-ease) both } @keyframes grpEnter { from { opacity:0; transform:translateY(6px) } @@ -456,6 +474,7 @@ @media (prefers-reduced-motion: reduce) { .transcript { scroll-behavior:auto } - .grp-row.grp-enter, + .agent-row.grp-enter, + .user-row-right.grp-enter, .bubble-group.streaming .agent-bubble::after { animation:none } } diff --git a/app/shared/src/chatview/components/Transcript.css.test.ts b/app/shared/src/chatview/components/Transcript.css.test.ts index 61801e0f3..02e1ff8b2 100644 --- a/app/shared/src/chatview/components/Transcript.css.test.ts +++ b/app/shared/src/chatview/components/Transcript.css.test.ts @@ -39,15 +39,6 @@ describe('ChatViewTranscript CSS contract', () => { } }); - it('keeps agent markdown replies on a stable readable column', () => { - const groupRule = cssRule(css, '.bubble-group'); - const nestedBubbleRule = cssRule(css, '.bubble-group > .agent-bubble'); - - expect(groupRule).not.toMatch(/\bwidth\s*:\s*fit-content\b/); - expect(groupRule).toMatch(/\bwidth\s*:\s*min\(86%,\s*820px\)/); - expect(nestedBubbleRule).toMatch(/\bwidth\s*:\s*100%/); - }); - it('keeps the ChatView root constrained so the transcript owns vertical scrolling', () => { const chatviewRule = cssRule(css, '.chatview'); @@ -59,26 +50,13 @@ describe('ChatViewTranscript CSS contract', () => { }); it('keeps Hub message metadata styled as compact non-card chrome', () => { - const metaRule = cssRule(css, '.message-display-meta'); - const detailRule = cssRule(css, '.message-display-detail'); - for (const className of messageDisplayMetaClassNames) { expect(css, className).toMatch(new RegExp(`\\.${className}\\b`)); } - expect(metaRule).toMatch(/\bfont\s*:\s*var\(--chat-label-xs\)/); - expect(metaRule).not.toMatch(/\bbackground\s*:/); - expect(detailRule).toMatch(/\btext-overflow\s*:\s*ellipsis/); }); - it('uses dense row rhythm tokens for transcript messages (#1311)', () => { - const rowRule = cssRule(css, '.grp-row'); - const contentRule = cssRule(css, '.grp-content'); - - expect(rowRule).toMatch(/margin-bottom\s*:\s*var\(--chat-sp-md\)/); - expect(rowRule).toMatch(/gap\s*:\s*var\(--chat-sp-sm\)/); - expect(contentRule).toMatch(/gap\s*:\s*var\(--chat-sp-2\)/); - /* Full-file match: cssRule hits first `.user-bubble` under `.grp-content >` (align only). */ - expect(css).toMatch(/\.user-bubble\s*\{[^}]*padding\s*:\s*var\(--chat-sp-xxs\)\s+var\(--chat-sp-sm\)/); - expect(css).toMatch(/\.agent-bubble\s*\{[^}]*padding\s*:\s*var\(--chat-sp-xxs\)\s+var\(--chat-sp-sm\)/); + it('keeps both message roles on the shared conversation column', () => { + expect(css).toMatch(/\.agent-row\s*,\s*\.user-row-right\s*\{/); + expect(css).toMatch(/\.user-row-right\s*\{[^}]*justify-content\s*:\s*flex-end/); }); }); diff --git a/app/shared/src/chatview/components/UserMessage.tsx b/app/shared/src/chatview/components/UserMessage.tsx index d1e71cefe..67fb376ef 100644 --- a/app/shared/src/chatview/components/UserMessage.tsx +++ b/app/shared/src/chatview/components/UserMessage.tsx @@ -7,6 +7,8 @@ import { CHATVIEW_I18N_NAMESPACE } from '../i18n/resources' import MarkdownContent from '../../ui/Markdown' import { MessageDisplayMeta } from './MessageDisplayMeta' import { ImageAttachmentRow } from './ImageAttachment' +import { Bubble, BubbleContent } from '../../shadcn/ui/bubble' +import { Message, MessageAvatar, MessageContent } from '../../shadcn/ui/message' function userAvatarInitial(name: string | undefined, fallback: string): string { return (name?.trim() || fallback).slice(0, 1).toUpperCase() @@ -54,7 +56,7 @@ interface Props { export const UserMessage = memo(function UserMessage({ item, chatMode, onContextMenu, enter }: Props) { const { t } = useTranslation(CHATVIEW_I18N_NAMESPACE) const avatarInitial = userAvatarInitial(item.name, t('chat.you')) - const rowClass = enter ? 'grp-row user-row-right grp-enter' : 'grp-row user-row-right' + const rowClass = enter ? 'user-row-right grp-enter' : 'user-row-right' // #1821: text bubbles get the same selectable/context-menu identity tool // rows have — only when both an upstream block id and a handler exist. @@ -84,9 +86,8 @@ export const UserMessage = memo(function UserMessage({ item, chatMode, onContext if (chatMode === 'dm') { return ( -
- -
+ + -
- {bubbleContent} -
-
-
{avatarInitial}
-
+ + + {bubbleContent} + + + + + {avatarInitial} + + ) } return ( -
- -
+ +
- {item.time} {item.name || t('chat.you')} + {item.time}
-
- {bubbleContent} -
-
-
{avatarInitial}
-
+ + + {bubbleContent} + + + + + {avatarInitial} + + ) }) diff --git a/app/shared/src/shadcn/ui/attachment.tsx b/app/shared/src/shadcn/ui/attachment.tsx new file mode 100644 index 000000000..e940d497c --- /dev/null +++ b/app/shared/src/shadcn/ui/attachment.tsx @@ -0,0 +1,204 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "../../lib/utils" +import { Slot } from "radix-ui" + +import { Button } from "./button" + +const attachmentVariants = cva( + "group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed", + { + variants: { + size: { + default: + "gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2", + sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5", + xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1", + }, + orientation: { + horizontal: "min-w-40 items-center", + vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30", + }, + }, + } +) + +function Attachment({ + className, + state = "done", + size = "default", + orientation = "horizontal", + ...props +}: React.ComponentProps<"div"> & + VariantProps & { + state?: "idle" | "uploading" | "processing" | "error" | "done" + }) { + return ( +
+ ) +} + +const attachmentMediaVariants = cva( + "relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5", + { + variants: { + variant: { + icon: "", + image: + "opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover", + }, + }, + defaultVariants: { + variant: "icon", + }, + } +) + +function AttachmentMedia({ + className, + variant = "icon", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AttachmentContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentTitle({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentDescription({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function AttachmentActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AttachmentAction({ + className, + variant, + size = "icon-xs", + ...props +}: React.ComponentProps) { + return ( + + + , + ); + + expect(container.querySelector('[data-slot="attachment"]')).toHaveAttribute( + 'data-state', + 'uploading', + ); + expect(screen.getByText('notes.md')).toHaveAttribute('data-slot', 'attachment-title'); + expect(screen.getByText('42%')).toHaveAttribute('data-slot', 'attachment-description'); + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + }); + + it('composes an input group with a textarea and toolbar addon', () => { + const { container } = render( + + + + + + , + ); + + expect(screen.getByLabelText('Prompt')).toHaveAttribute( + 'data-slot', + 'input-group-control', + ); + expect(container.querySelector('[data-slot="input-group-addon"]')).toHaveAttribute( + 'data-align', + 'block-end', + ); + }); +}); diff --git a/app/shared/src/shadcn/ui/input-group.tsx b/app/shared/src/shadcn/ui/input-group.tsx new file mode 100644 index 000000000..f5654c8bb --- /dev/null +++ b/app/shared/src/shadcn/ui/input-group.tsx @@ -0,0 +1,168 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "../../lib/utils" + +import { Button } from "./button" +import { Input } from "./input" +import { Textarea } from "./textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3", + "block-end": + "order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +