diff --git a/docs/images/composer-after.jpg b/docs/images/composer-after.jpg
new file mode 100644
index 000000000..6b535f49f
Binary files /dev/null and b/docs/images/composer-after.jpg differ
diff --git a/docs/images/composer-before.png b/docs/images/composer-before.png
new file mode 100644
index 000000000..22cec3146
Binary files /dev/null and b/docs/images/composer-before.png differ
diff --git a/packages/chat-ui/src/composer.tsx b/packages/chat-ui/src/composer.tsx
index 534608694..54ff69920 100644
--- a/packages/chat-ui/src/composer.tsx
+++ b/packages/chat-ui/src/composer.tsx
@@ -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,
@@ -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[],
@@ -803,26 +803,6 @@ export const Composer = forwardRef<
)}
- {attachments.length > 0 && (
-
- {attachments.map((file) => (
- -
- {file.name}
-
-
- ))}
-
- )}
-
+ {attachments.length > 0 && (
+
+ {attachments.map((file) => (
+ -
+
+ {file.name}
+
+
+
+ ))}
+
+ )}
- {/* Always mounted so its reserved height never toggles the composer's
- * box size — only opacity/visibility change (CL-6250). */}
- 0}
- >
- {CHAT_STRINGS.composerKeyboardHint}
+
+
+
0}
+ >
+ {CHAT_STRINGS.composerKeyboardHint}
+
+
+
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
@@ -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;
@@ -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);
}
@@ -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 {
diff --git a/packages/chat-ui/test/composer.test.tsx b/packages/chat-ui/test/composer.test.tsx
index 518d18777..cb7fefbbc 100644
--- a/packages/chat-ui/test/composer.test.tsx
+++ b/packages/chat-ui/test/composer.test.tsx
@@ -1,8 +1,3 @@
-// The composer's Send button, in-flight: standardized on the same
-// label-swap/disabled pattern the create-agent and invite-agent dialogs
-// already use (CL-6019) — previously the icon-only button gave no visible
-// signal that a send was in progress beyond being disabled.
-
import { afterEach, describe, expect, test } from "bun:test";
import { act, createElement, createRef } from "react";
import { createRoot } from "react-dom/client";
@@ -11,6 +6,8 @@ import type { Root } from "react-dom/client";
import { Composer } from "../src/composer";
import type { ComposerHandle, ComposerSendPayload } from "../src/composer";
+// The in-flight send contract survives the icon-only UI: its accessible name
+// announces progress and the action remains unavailable until sending ends.
let container: HTMLDivElement | null = null;
let root: Root | null = null;
@@ -58,8 +55,12 @@ function sendButton(): HTMLButtonElement {
return button;
}
+function keyboardHint(): Element | null {
+ return container?.querySelector(".chat-composer-keyboard-hint") ?? null;
+}
+
describe("Composer send button", () => {
- test("shows a Sending… label and stays disabled while the send promise is unresolved", async () => {
+ test("updates its accessible label and stays disabled while sending", async () => {
let resolveSend: (value: boolean) => void = () => undefined;
const onSend = () =>
new Promise((resolve) => {
@@ -101,9 +102,13 @@ describe("Composer send button", () => {
});
});
-function hint(): Element | null {
- return container?.querySelector(".chat-composer-hint") ?? null;
-}
+test("labels the icon-only attachment action in the composer rail", () => {
+ mount(() => Promise.resolve(true));
+
+ expect(
+ container?.querySelector('[aria-label="Attach files"]')?.textContent,
+ ).toBe("");
+});
function textarea(): HTMLTextAreaElement {
const element = container?.querySelector("textarea");
@@ -255,49 +260,40 @@ describe("Composer mention popover — Agents and People (CL-5879)", () => {
});
});
-// The hint used to mount/unmount with its condition, which added and
-// removed a line from the composer's box on every focus/blur and
-// keystroke. It is now always in the DOM with a reserved height; only
-// `data-visible` (and the opacity/visibility CSS it drives) toggles, so
-// asserting on that attribute — never on presence/absence — is the
-// honest way to test the new mechanism (CL-6250).
describe("Composer keyboard hint", () => {
- test("is always mounted and stays visibility-hidden until focused with a non-empty draft", async () => {
+ test("uses the existing action rail and appears only for a focused non-empty draft", async () => {
mount(() => Promise.resolve(true));
- expect(hint()).not.toBeNull();
- expect(hint()?.getAttribute("data-visible")).toBe("false");
+ expect(keyboardHint()).not.toBeNull();
+ expect(keyboardHint()?.getAttribute("data-visible")).toBe("false");
act(() => {
textarea().focus();
});
- await settle();
- expect(hint()?.getAttribute("data-visible")).toBe("false");
-
typeInto(textarea(), "hello");
await settle();
- expect(hint()?.getAttribute("data-visible")).toBe("true");
- expect(hint()?.textContent).toBe("Enter to send");
- act(() => {
- textarea().blur();
- });
- await settle();
- expect(hint()?.getAttribute("data-visible")).toBe("false");
+ expect(keyboardHint()?.getAttribute("data-visible")).toBe("true");
+ expect(keyboardHint()?.textContent).toBe("Enter to send");
+ expect(
+ container?.querySelectorAll(".chat-composer-actions > button").length,
+ ).toBe(2);
+ expect(container?.querySelector(".chat-composer-row > textarea")).toBe(
+ textarea(),
+ );
});
- test("hides again once the draft is cleared while still focused, without unmounting", async () => {
+ test("hides when the focused draft is cleared", async () => {
mount(() => Promise.resolve(true));
act(() => {
textarea().focus();
});
- typeInto(textarea(), "hi");
+ typeInto(textarea(), "hello");
await settle();
- expect(hint()?.getAttribute("data-visible")).toBe("true");
+ expect(keyboardHint()?.getAttribute("data-visible")).toBe("true");
typeInto(textarea(), "");
await settle();
- expect(hint()).not.toBeNull();
- expect(hint()?.getAttribute("data-visible")).toBe("false");
+ expect(keyboardHint()?.getAttribute("data-visible")).toBe("false");
});
});