Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/images/composer-after.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/composer-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
132 changes: 67 additions & 65 deletions packages/chat-ui/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// does not compose with an inline mention popover.

import { Avatar, Button } from "@corbits/react-ui";
import { CircleNotch, Paperclip, PaperPlaneRight, X } from "@corbits/icons";
import { ArrowUp, CircleNotch, Paperclip, X } from "@corbits/icons";
import {
forwardRef,
useImperativeHandle,
Expand Down Expand Up @@ -240,7 +240,7 @@ export function attachmentValidationMessage(
}
}

/** PaperPlaneRight/Enter stay blocked while a send or file read is in flight. */
/** ArrowUp/Enter stay blocked while a send or file read is in flight. */
export function canSendComposerAction(
text: string,
attachments: readonly ComposerAttachment[],
Expand Down Expand Up @@ -803,26 +803,6 @@ export const Composer = forwardRef<
</div>
</div>
)}
{attachments.length > 0 && (
<ul
className="chat-composer-attachments"
aria-label={CHAT_STRINGS.composerAttachmentsLabel}
>
{attachments.map((file) => (
<li key={file.id} className="chat-composer-attachment">
<span className="chat-composer-attachment-name">{file.name}</span>
<button
type="button"
className="chat-composer-attachment-remove"
aria-label={CHAT_STRINGS.composerRemoveAttachment(file.name)}
onClick={() => removeAttachment(file.id)}
>
<X aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
<div className="chat-composer-row">
<input
ref={fileInputRef}
Expand All @@ -833,17 +813,28 @@ export const Composer = forwardRef<
tabIndex={-1}
aria-hidden="true"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="chat-composer-icon-button"
disabled={!canAttach}
onClick={() => fileInputRef.current?.click()}
aria-label={CHAT_STRINGS.composerAttach}
>
<Paperclip />
</Button>
{attachments.length > 0 && (
<ul
className="chat-composer-attachments"
aria-label={CHAT_STRINGS.composerAttachmentsLabel}
>
{attachments.map((file) => (
<li key={file.id} className="chat-composer-attachment">
<span className="chat-composer-attachment-name">
{file.name}
</span>
<button
type="button"
className="chat-composer-attachment-remove"
aria-label={CHAT_STRINGS.composerRemoveAttachment(file.name)}
onClick={() => removeAttachment(file.id)}
>
<X aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
<textarea
ref={textareaRef}
className="chat-composer-input"
Expand All @@ -861,38 +852,49 @@ export const Composer = forwardRef<
onBlur={() => setFocused(false)}
rows={1}
/>
<Button
type="button"
variant={sendVisualState === "empty" ? "ghost" : "primary"}
size="icon"
className="chat-composer-icon-button"
disabled={!canSend}
data-send-state={sendVisualState}
onClick={() => void send()}
aria-label={
sending ? CHAT_STRINGS.composerSending : CHAT_STRINGS.composerSend
}
title={
sending ? CHAT_STRINGS.composerSending : CHAT_STRINGS.composerSend
}
>
{sendVisualState === "sending" ? (
<CircleNotch
className="chat-composer-send-spinner"
aria-hidden="true"
/>
) : (
<PaperPlaneRight aria-hidden="true" />
)}
</Button>
</div>
{/* Always mounted so its reserved height never toggles the composer's
* box size — only opacity/visibility change (CL-6250). */}
<div
className="chat-composer-hint"
data-visible={focused && value.trim().length > 0}
>
{CHAT_STRINGS.composerKeyboardHint}
<div className="chat-composer-actions">
<Button
type="button"
variant="ghost"
size="sm"
className="chat-composer-icon-button"
disabled={!canAttach}
onClick={() => fileInputRef.current?.click()}
aria-label={CHAT_STRINGS.composerAttach}
>
<Paperclip />
</Button>
<span
className="chat-composer-keyboard-hint"
data-visible={focused && value.trim().length > 0}
>
{CHAT_STRINGS.composerKeyboardHint}
</span>
<Button
type="button"
variant={sendVisualState === "empty" ? "ghost" : "primary"}
size="sm"
className="chat-composer-icon-button"
disabled={!canSend}
data-send-state={sendVisualState}
onClick={() => void send()}
aria-label={
sending ? CHAT_STRINGS.composerSending : CHAT_STRINGS.composerSend
}
title={
sending ? CHAT_STRINGS.composerSending : CHAT_STRINGS.composerSend
}
>
{sendVisualState === "sending" ? (
<CircleNotch
className="chat-composer-send-spinner"
aria-hidden="true"
/>
) : (
<ArrowUp aria-hidden="true" />
)}
</Button>
</div>
</div>
<div
className="chat-composer-status"
Expand Down
116 changes: 73 additions & 43 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1822,8 +1822,7 @@
align-items: stretch;
flex-shrink: 0;
width: 100%;
border-top: 1px solid var(--border);
padding: 0.45rem 0.75rem 0.7rem;
padding: 0.9rem clamp(0.9rem, 3vw, 1.75rem);
box-sizing: border-box;
}

Expand Down Expand Up @@ -1891,7 +1890,7 @@
flex-wrap: wrap;
gap: 0.35rem;
list-style: none;
margin: 0 0 0.4rem;
margin: 0;
padding: 0;
}

Expand All @@ -1901,8 +1900,12 @@
gap: 0.25rem;
max-width: 100%;
border: 1px solid var(--border);
border-radius: 0;
background: var(--card, var(--background));
border-radius: var(--ui-radius-md, 0.5rem);
background: color-mix(
in srgb,
var(--foreground) 3%,
var(--card, var(--background))
);
padding: 0.2rem 0.35rem 0.2rem 0.5rem;
font-size: 0.75rem;
line-height: 1.3;
Expand Down Expand Up @@ -2317,42 +2320,69 @@

.chat-composer-row {
display: flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--border);
border-radius: 0;
min-height: 6.75rem;
flex-direction: column;
align-items: stretch;
gap: 0.55rem;
border: 1.5px solid var(--border);
border-radius: var(--ui-radius-lg, 0.75rem);
background: var(--card, var(--background));
padding: 0.45rem 0.55rem;
padding: 0.8rem 0.85rem 0.75rem;
transition:
border-color 120ms var(--chat-ease),
box-shadow 120ms var(--chat-ease);
background-color 120ms var(--chat-ease);
}

/* Hairline -> foreground-strength on focus: color and an inset hairline
reinforce, never a border-width or padding change, so the row never
resizes (CL-6250). The accent color stays out of this — it is reserved
for inset/text emphasis elsewhere, not a full input outline. */
.chat-composer-row:focus-within {
border-color: var(--foreground);
box-shadow: inset 0 0 0 1px var(--foreground);
border-color: var(--primary);
}

.chat-composer-input {
flex: 1;
min-height: 2.5rem;
min-height: 2.75rem;
max-height: 10rem;
overflow-y: auto;
resize: none;
border: 0;
border-radius: 0;
background: transparent;
padding: 0.5rem 0.4rem;
padding: 0.1rem 0.2rem;
font: inherit;
font-size: 0.875rem;
line-height: 1.45;
color: var(--foreground);
}

.chat-composer-actions {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
margin-top: auto;
padding: 0;
}

.chat-composer-actions > :first-child {
justify-self: start;
}

.chat-composer-actions > :last-child {
justify-self: end;
}

.chat-composer-keyboard-hint {
color: var(--muted-foreground);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.01em;
opacity: 0;
pointer-events: none;
transition: opacity 120ms var(--chat-ease);
}

.chat-composer-keyboard-hint[data-visible="true"] {
opacity: 1;
}

/* Growth is measured in JS (scrollHeight, capped at max-height above) and
applied as an inline `height`; this transition is what turns that
per-keystroke instant write into a contained ~120ms glide instead of an
Expand All @@ -2376,24 +2406,6 @@
opacity: 1;
}

.chat-composer-hint {
min-height: 1rem;
padding: 0.3rem 0.15rem 0;
font-size: 0.7rem;
color: var(--muted-foreground);
text-align: right;
opacity: 0;
visibility: hidden;
transition: opacity 120ms var(--chat-ease);
}

/* Always mounted (see composer.tsx) so this space is reserved whether or
not the hint is showing — visibility toggles, box height never does. */
.chat-composer-hint[data-visible="true"] {
opacity: 1;
visibility: visible;
}

.chat-composer-status {
min-height: 0;
padding: 0.3rem 0.15rem 0;
Expand All @@ -2412,12 +2424,13 @@
color: var(--destructive);
}

/* Send button states: "empty" reads as a muted, inert glyph in both themes
rather than inheriting the ambient text color at half opacity; "ready"
and "sending" both keep the primary fill, so the spinner swap is what
tells them apart. Press feedback (brightness dip + scale-down) is the
Button base variant's own `active:` state (react-ui, not duplicated
here) — it already fires on both of these. */
/* Send button states: empty stays quietly available as a neutral circular
affordance; ready and sending keep the primary fill, with the spinner
communicating the in-progress state. */
[data-send-state="empty"] {
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}

[data-send-state="empty"] svg {
color: var(--muted-foreground);
}
Expand All @@ -2427,6 +2440,23 @@
it's still inside the button's own hit-test region (CL-6250). */
.chat-composer-icon-button {
position: relative;
display: inline-flex;
width: 2.25rem;
height: 2.25rem;
min-width: 2.25rem;
min-height: 2.25rem;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 0;
}

.chat-composer-actions > :first-child {
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}

.chat-composer-actions > :first-child:hover:not(:disabled) {
background: color-mix(in srgb, var(--foreground) 8%, transparent);
}

.chat-composer-icon-button::after {
Expand Down
Loading
Loading