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
90 changes: 55 additions & 35 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -3822,67 +3822,87 @@ tr.insights-row-clickable:hover {
color: var(--destructive);
}

/* Shared composer: transcript-matched text field on the left, a grouped
action rail (Send only — no turn cancellation exists yet) on the right. */
.chat-composer {
/* Shared composer: one lean full-width row (+, text, voice, send). */
.chat-composer-box {
display: flex;
align-items: flex-end;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-lg, 0.75rem);
background: var(--card, var(--background));
}
.chat-composer:focus-within {
border-color: var(--ring, var(--primary));
gap: 4px;
padding: 6px;
background: var(--surface);
border-radius: 22px;
box-shadow: var(--floating);
transition: box-shadow 150ms ease-out;
}
.chat-composer-box:focus-within {
box-shadow:
0 0 0 1px var(--line-strong),
0 6px 20px -6px rgb(0 0 0 / 18%);
}
.chat-composer-input {
flex: 1;
min-width: 0;
min-height: 32px;
max-height: 180px;
display: block;
resize: none;
border: 0;
background: transparent;
padding: 0.35rem 0;
padding: 5px 4px;
font: inherit;
font-size: max(var(--text-base), 16px);
font-weight: var(--font-weight-medium);
font-size: 15px;
line-height: 1.5;
color: var(--foreground);
color: var(--ink);
}
.chat-composer-input:focus {
outline: none;
}
.chat-composer-input::placeholder {
color: var(--muted-foreground);
color: var(--ink-3);
}
.chat-composer-input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.chat-composer-rail {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
padding-bottom: 0.35rem;
}
.chat-composer-btn,
.chat-composer-send {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
flex: 0 0 32px;
width: 32px;
height: 32px;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--primary);
color: var(--primary-foreground);
border-radius: 50%;
cursor: pointer;
}
.chat-composer-send:disabled {
.chat-composer-btn {
background: transparent;
color: var(--ink-2);
}
.chat-composer-voice {
background: var(--hover);
color: var(--ink);
}
.chat-composer-btn:hover:not(:disabled) {
background: var(--press);
}
.chat-composer-btn:disabled {
cursor: not-allowed;
background: var(--muted);
color: var(--muted-foreground);
opacity: 0.4;
}
.chat-composer-send {
background: var(--ink);
color: var(--action);
transition:
transform 160ms var(--ease-out),
opacity 150ms ease;
}
.chat-composer-send:disabled {
cursor: default;
opacity: 0.2;
}
.chat-composer-send:not(:disabled):active {
transform: scale(0.92);
}
.chat-composer-send-spinner {
animation: chat-composer-spin 0.8s linear infinite;
Expand Down
126 changes: 82 additions & 44 deletions apps/web/src/chat/composer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// No Stop action: neither `threads-api.ts` nor the hub API expose a way to
// cancel a running turn yet.
import { useCommandPaletteNavigation } from "@corbits/react-ui";
import { ArrowUp, CircleNotch } from "@/lib/icons";
import { useRef, useState } from "react";
import { ArrowUp, CircleNotch, Microphone, Plus } from "@/lib/icons";
import { useLayoutEffect, useRef, useState } from "react";

import { activeMention, applyMention, matchMentionQuery, type ActiveMention } from "./mentions";

Expand All @@ -19,13 +19,19 @@ export function Composer({
disabled,
mentionables = [],
onSend,
onAdd,
onVoice,
}: {
readonly placeholder: string;
readonly busy: boolean;
readonly disabled?: boolean;
/** Agents an `@` token can address; empty disables the popover. */
readonly mentionables?: readonly ComposerMention[];
readonly onSend: (text: string) => void;
/** Opens the attach / add actions; the `+` is hidden without it. */
readonly onAdd?: () => void;
/** Starts voice mode; the mic is hidden without it. */
readonly onVoice?: () => void;
}) {
const [text, setText] = useState("");
const [mention, setMention] = useState<ActiveMention | null>(null);
Expand All @@ -34,6 +40,14 @@ export function Composer({
const matches = mention === null ? [] : matchMentionQuery(mention.query, mentionables);
const open = mention !== null && matches.length > 0;

// Grow with the text up to the CSS max-height.
useLayoutEffect(() => {
const input = inputRef.current;
if (input === null) return;
input.style.height = "auto";
input.style.height = `${input.scrollHeight}px`;
}, [text]);

const send = () => {
const trimmed = text.trim();
if (trimmed === "" || busy || disabled) return;
Expand Down Expand Up @@ -95,48 +109,72 @@ export function Composer({
))}
</ul>
) : null}
<textarea
className="chat-composer-input"
ref={inputRef}
value={text}
rows={3}
placeholder={placeholder}
aria-label={placeholder}
disabled={disabled}
onChange={(event) => {
setText(event.target.value);
syncMention(event.target);
}}
onClick={(event) => syncMention(event.currentTarget)}
onBlur={() => setMention(null)}
onKeyDown={(event) => {
if (open && ["ArrowUp", "ArrowDown", "Enter", "Escape"].includes(event.key)) {
event.preventDefault();
navigation.onKeyDown(event);
return;
}
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
// The caret moves after this handler, so read it next tick.
requestAnimationFrame(() => {
if (inputRef.current !== null) syncMention(inputRef.current);
});
return;
}
if (event.key !== "Enter") return;
// Auto-repeat fires the same keydown many times while a key is
// held; without this guard that means many sends (plain Enter)
// or many newlines (Shift+Enter) from one keystroke.
if (event.repeat) {
event.preventDefault();
return;
}
if (!event.shiftKey) {
event.preventDefault();
send();
}
}}
/>
<div className="chat-composer-rail">
<div className="chat-composer-box">
{onAdd === undefined ? null : (
<button
type="button"
className="chat-composer-btn"
aria-label="Add"
title="Add"
disabled={disabled}
onClick={onAdd}
>
<Plus aria-hidden="true" />
</button>
)}
<textarea
className="chat-composer-input"
ref={inputRef}
value={text}
rows={1}
placeholder={placeholder}
aria-label={placeholder}
disabled={disabled}
onChange={(event) => {
setText(event.target.value);
syncMention(event.target);
}}
onClick={(event) => syncMention(event.currentTarget)}
onBlur={() => setMention(null)}
onKeyDown={(event) => {
if (open && ["ArrowUp", "ArrowDown", "Enter", "Escape"].includes(event.key)) {
event.preventDefault();
navigation.onKeyDown(event);
return;
}
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
// The caret moves after this handler, so read it next tick.
requestAnimationFrame(() => {
if (inputRef.current !== null) syncMention(inputRef.current);
});
return;
}
if (event.key !== "Enter") return;
// Auto-repeat fires the same keydown many times while a key is
// held; without this guard that means many sends (plain Enter)
// or many newlines (Shift+Enter) from one keystroke.
if (event.repeat) {
event.preventDefault();
return;
}
if (!event.shiftKey) {
event.preventDefault();
send();
}
}}
/>
{onVoice === undefined ? null : (
<button
type="button"
className="chat-composer-btn chat-composer-voice"
aria-label="Voice mode"
title="Voice mode"
disabled={disabled}
onClick={onVoice}
>
<Microphone aria-hidden="true" />
</button>
)}
<button
type="button"
className="chat-composer-send"
Expand Down
45 changes: 3 additions & 42 deletions apps/web/src/chat/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -926,7 +926,7 @@
/* Reading width, not viewport width: on a wide window an unbounded
line runs 200+ characters and long agent replies become a wall.
~72ch is the ceiling comfortable prose tolerates. */
max-width: 72ch;
max-width: 76ch;
}

/* Long agent replies are documents, not one-liners: give block-level
Expand Down Expand Up @@ -2123,7 +2123,7 @@
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
max-width: 640px;
font-size: 0.8125rem;
color: var(--muted-foreground);
}
Expand Down Expand Up @@ -2481,23 +2481,6 @@
border-color: var(--primary);
}

.chat-composer-input {
flex: 1;
min-height: 2.75rem;
max-height: 10rem;
overflow-y: auto;
resize: none;
border: 0;
border-radius: 0;
background: transparent;
padding: 0.1rem 0.2rem;
font: inherit;
font-size: max(var(--text-base), 16px);
font-weight: var(--font-weight-medium);
line-height: 1.5;
color: var(--foreground);
}

.chat-composer-actions {
display: grid;
grid-template-columns: 1fr auto 1fr;
Expand Down Expand Up @@ -2541,28 +2524,6 @@
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
instant reflow of the composer's flex siblings. Shrink-on-delete rides the same transition. */
@media (prefers-reduced-motion: no-preference) {
.chat-composer-input {
/* Grows/shrinks in place, not an entrance — DESIGN.md calls for
`--ease-in-out` here specifically, not the entrance curve every
other `--chat-ease` transition in this file uses. */
transition: height 120ms var(--ease-in-out, var(--chat-ease));
}
}

.chat-composer-input:focus {
outline: none;
}

.chat-composer-input::placeholder {
color: var(--muted-foreground);
opacity: 1;
}

.chat-composer-status {
min-height: 0;
padding: 0.3rem 0.15rem 0;
Expand Down Expand Up @@ -3701,7 +3662,7 @@
.chat-pr-reply-text {
font-size: 0.9375rem;
line-height: 1.55;
max-width: 72ch;
max-width: 76ch;
}

.chat-pr-role-badge {
Expand Down
Loading