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
45 changes: 26 additions & 19 deletions apps/web/src/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,11 @@ export function Chat(
</div>
)}

<div ref={composerRoot} className="chat-composer relative shrink-0 px-2.5 pb-2.5">
<div
ref={composerRoot}
className="chat-composer relative shrink-0 px-2.5 pb-2.5"
data-planner={agent ? undefined : "off"}
>
{referencesEnabled && (
<p className="sr-only" id={instructionsId}>
Type # to reference a document.
Expand Down Expand Up @@ -981,27 +985,30 @@ export function Chat(
)}
</div>
<div className="composer-actions">
{/* Always mounted, so the live region hears its first word. */}
<span className="composer-connection" id={connectionId} role="status">
{connectionLabel && (
<span
className="composer-connection-label"
data-offline={connectionLabel === "Offline" || undefined}
{/* A row of its own on a phone; see composer.css. */}
<span className="composer-connection-group">
{/* Always mounted, so the live region hears its first word. */}
<span className="composer-connection" id={connectionId} role="status">
{connectionLabel && (
<span
className="composer-connection-label"
data-offline={connectionLabel === "Offline" || undefined}
>
{connectionLabel}
</span>
)}
</span>
{/* Where the document header is out of view; see composer.css. */}
{connectionNotice === "offline" && wire && (
<button
className="composer-reconnect btn btn-sm btn-ghost"
onClick={() => wire.reconnect()}
type="button"
>
{connectionLabel}
</span>
Reconnect
</button>
)}
</span>
{/* Where the document header is out of view; see composer.css. */}
{connectionNotice === "offline" && wire && (
<button
className="composer-reconnect btn btn-sm btn-ghost"
onClick={() => wire.reconnect()}
type="button"
>
Reconnect
</button>
)}
<span className="composer-run-control">
{agent && (busy || counts.active > 0) && (
<button
Expand Down
161 changes: 161 additions & 0 deletions apps/web/src/chat/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@
}
.chat-composer [data-chat-reference-picker] {
right: calc(-1 * var(--edge-width));
max-height: min(16rem, 45dvh, 45vh);
}
/* At most eight rows show before the list scrolls: 8 × 2rem plus the 0.25rem padding. */
.chat-composer [data-chat-mention-picker] {
max-height: min(16.5rem, 45dvh, 45vh);
}
.chat-composer [data-chat-reference-picker] [role="option"] {
height: 2rem;
Expand Down Expand Up @@ -230,6 +235,9 @@
display: inline-flex;
min-width: 0;
}
.chat-composer .composer-connection-group {
display: contents;
}
.chat-composer .composer-connection-label {
display: inline-flex;
align-items: center;
Expand Down Expand Up @@ -272,3 +280,156 @@
[data-workspace-mode="split"] .chat-composer .composer-reconnect {
display: none;
}

/*
* Phones: one pill. The draft sits between a leading reference button and a
* trailing mode switch and send, so the composer rests at one line and the
* transcript keeps the space above the keyboard. Every phone-only rule lives
* in this block, behind this one condition.
*/
/* Landscape phones too; the shared `:root[data-phone]` hook can replace this condition. */
@media (pointer: coarse) and (max-width: 600px), (pointer: coarse) and (max-height: 500px) {
.chat-composer {
padding-inline: 0.5rem;
/* The home indicator's inset, but not while the keyboard covers it. */
padding-bottom: max(0.5rem, calc(env(safe-area-inset-bottom) - var(--keyboard-inset, 0px)));
}
.chat-composer .composer-surface {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
padding: 0.25rem;
border-radius: var(--radius-composer-pill);
}
.chat-composer :is(.composer-footer, .composer-left, .composer-actions) {
display: contents;
}
.chat-composer .composer-draft-input {
order: 2;
flex: 1 1 0;
min-width: 0;
/* One line at rest; five before it scrolls, fewer above an open keyboard. */
min-height: var(--composer-pill-control);
max-height: min(calc(5lh + 0.75rem), calc(var(--app-height, 100dvh) * 0.3));
padding: 0.375rem 0.5rem;
/* theme.css keeps touch text at 16px or more, so focus never zooms. */
line-height: 1.5;
}
/* A narrow pill shortens the hint rather than wrapping it out of view. */
.chat-composer .composer-draft-input[data-empty]::before {
inset-inline: 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-composer .composer-left > button {
order: 1;
}
.chat-composer .composer-mode-trigger {
order: 3;
}
.chat-composer .composer-run-control {
order: 4;
}
.chat-composer .composer-run-control:empty {
display: none;
}
/* Room enough that the mode switch and send keep separate 44px targets. */
.chat-composer .send-action {
order: 5;
margin-inline-start: calc(var(--spacing) * 2);
}
/* Without the Planner the switch can never be used; the line above says why. */
.chat-composer[data-planner="off"] .composer-mode-trigger {
display: none;
}
:root[data-plan-coarse-pointer] .chat-composer .composer-surface button:not([role="option"]) {
min-width: var(--composer-pill-control);
min-height: var(--composer-pill-control);
}
.chat-composer .composer-surface :is(.btn-icon, .composer-mode) {
width: var(--composer-pill-control);
height: var(--composer-pill-control);
border-radius: var(--radius-full);
}
.chat-composer .composer-surface :is(.btn-icon, .composer-mode-symbol) svg {
width: var(--composer-pill-icon);
height: var(--composer-pill-icon);
}
/* The switch keeps its accessible name and drops the visible word. */
.chat-composer .composer-mode {
column-gap: 0;
padding-inline: calc((var(--composer-pill-control) - var(--composer-pill-icon)) / 2);
}
.chat-composer .composer-mode-label {
width: 0;
overflow: hidden;
opacity: 0;
}
.chat-composer .send-action:disabled {
background-color: var(--color-inset);
color: var(--color-text-tertiary);
box-shadow: none;
/* The primary edge would ring a control that cannot be focused while disabled. */
outline-color: var(--color-inset);
opacity: 1;
}
/* Connection state becomes a slim first row inside the pill. */
.chat-composer .composer-connection-group:has(.composer-connection-label) {
display: flex;
order: 0;
flex-basis: 100%;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.125rem 0.25rem 0 0.5rem;
}
.chat-composer
.composer-connection-group:not(:has(.composer-connection-label))
.composer-connection {
position: absolute;
}
.chat-composer .composer-connection-group .composer-reconnect {
min-height: var(--control-height-compact);
}
/* Status above the pill is one quiet line rather than a tab behind it. */
.chat-composer .composer-notice {
min-height: 0;
margin-bottom: 0;
padding: 0.25rem 0.75rem 0.375rem;
border-radius: var(--radius-md);
background: transparent;
font-size: var(--text-xs);
line-height: var(--text-xs--line-height);
}
.chat-composer .composer-notice[data-error] {
margin-bottom: 0.375rem;
background: var(--color-danger-surface);
}
.chat-composer .composer-unavailable {
flex-basis: 100%;
min-height: 0;
}
/* Pickers become a list resting on the composer, as wide as it is. */
.chat-composer
:is([data-chat-reference-picker], [data-chat-mention-picker], [data-chat-command-picker]) {
/* Out to the pill's border, as the desktop dropdowns align. */
left: calc(-1 * var(--edge-width));
right: calc(-1 * var(--edge-width));
width: auto;
min-width: 0;
max-width: none;
max-height: calc(var(--app-height, 100dvh) * 0.4);
margin-bottom: 0.375rem;
overflow-y: auto;
overscroll-behavior: contain;
border-radius: var(--radius-lg);
}
.chat-composer
:is([data-chat-reference-picker], [data-chat-mention-picker], [data-chat-command-picker])
[role="option"] {
height: auto;
min-height: var(--control-target-coarse);
font-size: var(--text-base);
}
}
13 changes: 13 additions & 0 deletions apps/web/src/chat/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,16 @@
.chat-markdown[data-chat-mention-lead] > p:first-of-type {
display: inline;
}

/* Phones read Chat at the document's prose size, and a code block wraps rather than scrolling sideways. */
@media (pointer: coarse) and (max-width: 600px), (pointer: coarse) and (max-height: 500px) {
.chat-markdown {
font-size: var(--text-base);
line-height: var(--text-base--line-height);
}

.chat-markdown pre {
overflow-x: visible;
white-space: pre-wrap;
}
}
4 changes: 0 additions & 4 deletions apps/web/src/chat/mention-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ import { referenceOptionId } from "./reference-picker";

import type { MentionCandidate } from "./mentions";

/** At most this many rows show before the list scrolls: 8 × 2rem plus the 0.25rem padding. */
const LIST_HEIGHT = "16.5rem";

export function MentionPicker(
{
active,
Expand Down Expand Up @@ -36,7 +33,6 @@ export function MentionPicker(
data-chat-mention-picker=""
data-menu-enter=""
data-focus-boundary=""
style={{ maxHeight: `min(${LIST_HEIGHT}, 45dvh, 45vh)` }}
>
<div aria-label="Mentions" id={id} role="listbox">
{options.map((option, index) => (
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/chat/reference-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,6 @@ export function ReferencePicker(
data-animate={animate || undefined}
data-menu-enter=""
data-focus-boundary=""
style={{ maxHeight: "min(16rem, 45dvh, 45vh)" }}
>
<div aria-busy={state.status === "loading"} aria-label={label} id={id} role="listbox">
{state.status === "loading" && (
Expand Down
5 changes: 3 additions & 2 deletions e2e/chat-composer.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ test("touch chat input stays at least 16px to avoid focus zoom", async ({ join }
});

for (let touch of [false, true]) {
test(`chat input rests at two lines and grows to its cap on ${touch ? "touch" : "desktop"}`, async ({ join }) => {
test(`chat input rests at ${touch ? "one line" : "two lines"} and grows to its cap on ${touch ? "a touch phone" : "desktop"}`, async ({ join }) => {
let page = await join("ana", {
hasTouch: touch,
viewport: touch ? { width: 390, height: 844 } : { width: 1440, height: 900 },
Expand All @@ -41,7 +41,8 @@ for (let touch of [false, true]) {
maximum: parseFloat(style.maxHeight),
};
});
expect(initial.height).toBeCloseTo(initial.padding + 2 * initial.line, 0);
// A phone's pill composer rests at one line; desktop keeps room for two.
expect(initial.height).toBeCloseTo(initial.padding + (touch ? 1 : 2) * initial.line, 0);
await fillChat(input, "One\nTwo\nThree\nFour");
await expect.poll(() => input.evaluate(element => element.getBoundingClientRect().height))
.toBeGreaterThan(initial.height);
Expand Down
Loading
Loading