From 857473835e0f3a718f77ee57fa5138f16e3b975f Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:05:22 +0800 Subject: [PATCH 1/3] chore(app): ignore tsc incremental build info Co-authored-by: Codex --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 保留 From 673477799f3c341faa046540068105415bd6f90b Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:05:37 +0800 Subject: [PATCH 2/3] feat(app): rebuild chat surfaces with shadcn primitives Replace the bespoke composer and message chrome with vendored shadcn primitives (InputGroup, Message, Bubble, Attachment) whose colors and radii resolve from the existing theme tokens. The transcript runtime, Hub protocol and reducer stay untouched; only the presentation layer changes. - Composer collapses into one InputGroup card; reply, quote, edit, mention, attachment and upload rows become block-start addons. - UserMessage and AgentGroup render through Message/Bubble; tool cards, rail, header and inspector drop glass and heavy shadows. - Inspector auto-collapses at 768px so the conversation column keeps a readable width. - Deletes about 1.1k lines of dead composer CSS instead of layering more. Co-authored-by: Codex --- .../src/chatview/components/AgentGroup.tsx | 27 +- .../src/chatview/components/RowItem.css | 29 +- .../src/chatview/components/Transcript.css | 92 +- .../components/Transcript.css.test.ts | 28 +- .../src/chatview/components/UserMessage.tsx | 48 +- app/shared/src/shadcn/ui/attachment.tsx | 204 ++++ app/shared/src/shadcn/ui/bubble.tsx | 124 ++ .../src/shadcn/ui/chat-primitives.test.tsx | 81 ++ app/shared/src/shadcn/ui/input-group.tsx | 168 +++ app/shared/src/shadcn/ui/input.tsx | 20 + app/shared/src/shadcn/ui/message.tsx | 91 ++ app/shared/src/shadcn/ui/textarea.tsx | 17 + app/shared/src/styles/shadcn.css | 14 + app/shared/src/styles/themes.css | 4 +- app/shared/src/ui/EmptyState.module.css | 68 +- app/shared/src/ui/Markdown.module.css | 3 +- .../src/AgentHubWorkbench.module.css | 1077 +++-------------- .../src/AgentHubWorkbench.module.test.ts | 38 +- app/workbench/src/ComposerAttachmentParts.tsx | 143 ++- app/workbench/src/ComposerContextParts.tsx | 107 +- app/workbench/src/ComposerControlParts.tsx | 64 +- app/workbench/src/ComposerMentionPopover.tsx | 17 +- app/workbench/src/UnifiedComposer.tsx | 223 ++-- app/workbench/src/UnifiedComposerParts.tsx | 4 +- .../src/inspector/OverviewPanel.module.css | 23 +- .../src/useWorkbenchPanelLayout.test.ts | 8 +- app/workbench/src/useWorkbenchPanelLayout.ts | 32 +- docs/architecture/07-design-system-ssot.md | 9 +- docs/component-acceptance.md | 4 +- 29 files changed, 1471 insertions(+), 1296 deletions(-) create mode 100644 app/shared/src/shadcn/ui/attachment.tsx create mode 100644 app/shared/src/shadcn/ui/bubble.tsx create mode 100644 app/shared/src/shadcn/ui/chat-primitives.test.tsx create mode 100644 app/shared/src/shadcn/ui/input-group.tsx create mode 100644 app/shared/src/shadcn/ui/input.tsx create mode 100644 app/shared/src/shadcn/ui/message.tsx create mode 100644 app/shared/src/shadcn/ui/textarea.tsx 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..1876c23d1 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,26 @@ .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 +475,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 ( +