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
40 changes: 40 additions & 0 deletions client/lib/composerKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Enter-to-send policy for the composer, kept pure so every branch is unit
// testable. The coarse-pointer branch in particular cannot be exercised in the
// e2e suite: Playwright launches Chromium with a browser-level
// `--blink-settings=primaryPointerType=fine`, so per-context touch emulation
// does not move the `(pointer: coarse)` media query.

export interface ComposerKeyPress {
key: string;
shiftKey: boolean;
metaKey: boolean;
ctrlKey: boolean;
/** True while an IME is composing a candidate. */
isComposing: boolean;
/** Safari reports composition as keyCode 229 rather than `isComposing`. */
keyCode: number;
}

/**
* Enter submits on a fine pointer; on a coarse one it stays a newline, because
* Enter is the key users reach for on a soft keyboard. Cmd/Ctrl+Enter submits
* anywhere. Shift+Enter is always a newline.
*
* Returns whether the key press should submit and whether the browser default
* (inserting a newline) must be suppressed. Those differ for an empty draft:
* Enter is swallowed rather than silently adding a blank line the user did not
* ask for.
*/
export function composerEnterAction(
press: ComposerKeyPress,
options: { coarsePointer: boolean; canSubmit: boolean },
): { submit: boolean; preventDefault: boolean } {
const inert = { submit: false, preventDefault: false };
if (press.key !== "Enter" || press.shiftKey) return inert;
if (press.isComposing || press.keyCode === 229) return inert;

const submitModifier = press.metaKey || press.ctrlKey;
if (!submitModifier && options.coarsePointer) return inert;

return { submit: options.canSubmit, preventDefault: true };
}
157 changes: 104 additions & 53 deletions client/pages/Conversation.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { Link, useParams, useSearchParams } from "react-router-dom";

import { Alert } from "../ds/alert.js";
Expand All @@ -15,6 +15,7 @@ import { QuestionRequest } from "../components/question-request.js";
import { api, formatCost, type ReminderSummary, type SessionSummary } from "../lib/api.js";
import { latestModeMessageID, modeFromMessages, type AgentMode } from "../lib/agentMode.js";
import { MAX_IMAGE_ATTACHMENTS, readImageAttachment, selectImageFiles, type ImageAttachment } from "../lib/attachments.js";
import { composerEnterAction } from "../lib/composerKeys.js";
import { collapseActionGroups, mergeEvents, runningActivity } from "../lib/derive.js";
import { normalizeTranscript, type RawMessage } from "../lib/events.js";
import { useSessionStream } from "../lib/useSessionStream.js";
Expand Down Expand Up @@ -67,6 +68,7 @@ export function ConversationPage() {
const [modelError, setModelError] = useState<string | null>(null);
const derivedModelMarker = useRef<string | undefined>(undefined);
const modelSelectionDirty = useRef(false);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const transcriptScrollerRef = useRef<HTMLDivElement | null>(null);
const transcriptContentRef = useRef<HTMLDivElement | null>(null);
const followingTranscript = useRef(true);
Expand Down Expand Up @@ -256,6 +258,16 @@ export function ConversationPage() {
scroller.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
};

// The composer grows with its content instead of showing a resize grabber.
// `min-h-24` still floors the box, so a one-line draft keeps the same 96px
// target the mobile layout is measured against.
useLayoutEffect(() => {
const textarea = composerRef.current;
if (!textarea) return;
textarea.style.height = "auto";
textarea.style.height = `${textarea.scrollHeight}px`;
}, [draft]);

const send = async () => {
const text = draft.trim();
if (!text) return;
Expand Down Expand Up @@ -285,6 +297,29 @@ export function ConversationPage() {
}
};

// Policy lives in composerKeys.ts so the coarse-pointer and IME branches are
// unit tested; this only wires it to the DOM event.
const submitOnEnter = (event: KeyboardEvent<HTMLTextAreaElement>) => {
const action = composerEnterAction(
{
key: event.key,
shiftKey: event.shiftKey,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
isComposing: event.nativeEvent.isComposing,
keyCode: event.nativeEvent.keyCode,
},
{
coarsePointer: window.matchMedia("(pointer: coarse)").matches,
// `send()` has no re-entry guard and prompt_async returns as soon as
// the turn is queued, so a fast double Enter would post two turns.
canSubmit: !sending && draft.trim().length > 0,
},
);
if (action.preventDefault) event.preventDefault();
if (action.submit) void send();
};

const replyToPermission = async (requestId: string, reply: "once" | "always" | "reject") => {
setReplyingPermission(requestId);
setPermissionError(null);
Expand Down Expand Up @@ -483,7 +518,7 @@ export function ConversationPage() {
testId="opencode-composer-model"
label="Model"
/>
<span className="min-w-0 flex-1 truncate text-right text-[11px] text-[var(--color-text-muted)]" data-testid="opencode-current-model">
<span className="basis-full text-[11px] text-[var(--color-text-muted)]" data-testid="opencode-current-model">
{mode === "plan" ? "Read-only analysis" : "Can modify files"}
{selectedModel ? ` · ${sameModel(selectedModel, currentModel) ? "current" : "switches next message"}` : ""}
</span>
Expand All @@ -495,57 +530,73 @@ export function ConversationPage() {
</p>
)}
{attachmentError && <p className="mb-2 text-xs text-[var(--color-text-danger)]" role="alert" data-testid="opencode-attachment-error">{attachmentError}</p>}
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] gap-2 sm:flex">
<label className="row-start-2 inline-flex min-h-11 cursor-pointer items-center justify-center rounded-md border border-[var(--color-border-default)] px-3 text-xs font-semibold sm:min-h-0" data-testid="opencode-attach-label">
Attach
<input type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple className="sr-only" data-testid="opencode-attach" onChange={(event) => {
addAttachments(event.target.files ?? []);
event.target.value = "";
}} />
</label>
{reminderCatalogue.length > 0 && (
<select
value={selectedReminder}
onChange={(event) => setSelectedReminder(event.target.value)}
className={`row-start-2 min-h-11 min-w-0 w-full max-w-36 rounded-md border px-2 text-base sm:min-h-0 sm:max-w-40 sm:text-xs ${
selectedReminder
? "border-[var(--color-border-focus)] bg-[var(--color-background-surface)] text-[var(--color-text-default)]"
: "border-[var(--color-border-default)] bg-transparent text-[var(--color-text-muted)]"
}`}
data-testid="composer-reminder-select"
aria-label="Attach a reminder to this message"
title="Attach one reminder to the next message only. Cleared after sending."
>
<option value="">+ reminder</option>
{reminderCatalogue.map((reminder) => (
<option key={reminder.id} value={reminder.id} title={reminder.description}>
{reminder.title}{reminder.triggers.length ? " (triggers ignored)" : ""}
</option>
))}
</select>
)}
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
onPaste={(event) => {
const images = [...event.clipboardData.items]
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (images.length) addAttachments(images);
}}
rows={4}
enterKeyHint="enter"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="Send a follow-up…"
className="col-span-3 row-start-1 min-h-24 min-w-0 resize-y rounded-md border border-[var(--color-border-default)] bg-transparent p-2 text-base sm:order-none sm:min-h-16 sm:flex-1 sm:text-sm"
data-testid="opencode-composer"
/>
<Button className="row-start-2 min-h-11 sm:min-h-0" onClick={() => void send()} disabled={sending || !draft.trim()} data-testid="opencode-send">
{sending ? "Sending…" : "Send"}
</Button>
{/* One card owns the border so the textarea and its controls share a
frame. Laying the controls out on their own rail is what keeps
them aligned: as flex siblings of the textarea they stretched to
its height, while the fixed-height Send button did not. */}
<div
className="min-w-0 rounded-xl border border-[var(--color-border-default)] bg-[var(--color-background-surface)] transition-colors focus-within:border-[var(--color-border-focus)]"
data-testid="opencode-composer-card"
>
<textarea
ref={composerRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={submitOnEnter}
onPaste={(event) => {
const images = [...event.clipboardData.items]
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (images.length) addAttachments(images);
}}
rows={1}
enterKeyHint="enter"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="Send a follow-up…"
className="thin-scrollbar block max-h-64 min-h-24 w-full resize-none border-0 bg-transparent p-3 text-base text-[var(--color-text-default)] outline-none placeholder:text-[var(--color-text-muted)] sm:min-h-16 sm:p-2.5 sm:text-sm"
data-testid="opencode-composer"
/>
{/* Kept deliberately short: a session showing the auto-permission,
interrupted, permission and question banners at once leaves the
transcript only a sliver of a 720px viewport, so every pixel the
footer takes comes straight out of readable transcript. */}
<div className="flex min-w-0 items-center gap-2 border-t border-[var(--color-border-default)] px-2 py-2 sm:py-1">
<label className="inline-flex min-h-11 shrink-0 cursor-pointer items-center rounded-md px-2.5 text-xs font-semibold text-[var(--color-text-muted)] hover:bg-[var(--hh-row-hover)] hover:text-[var(--color-text-default)] sm:min-h-8" data-testid="opencode-attach-label">
Attach
<input type="file" accept="image/png,image/jpeg,image/gif,image/webp" multiple className="sr-only" data-testid="opencode-attach" onChange={(event) => {
addAttachments(event.target.files ?? []);
event.target.value = "";
}} />
</label>
{reminderCatalogue.length > 0 && (
<select
value={selectedReminder}
onChange={(event) => setSelectedReminder(event.target.value)}
className={`min-h-11 min-w-0 max-w-36 shrink rounded-md border px-2 text-base sm:min-h-8 sm:max-w-40 sm:text-xs ${
selectedReminder
? "border-[var(--color-border-focus)] bg-[var(--color-background-surface)] text-[var(--color-text-default)]"
: "border-transparent bg-transparent text-[var(--color-text-muted)] hover:border-[var(--color-border-default)]"
}`}
data-testid="composer-reminder-select"
aria-label="Attach a reminder to this message"
title="Attach one reminder to the next message only. Cleared after sending."
>
<option value="">+ reminder</option>
{reminderCatalogue.map((reminder) => (
<option key={reminder.id} value={reminder.id} title={reminder.description}>
{reminder.title}{reminder.triggers.length ? " (triggers ignored)" : ""}
</option>
))}
</select>
)}
<span className="flex-1" aria-hidden="true" />
<Button size="sm" className="min-h-11 shrink-0 sm:min-h-8" onClick={() => void send()} disabled={sending || !draft.trim()} data-testid="opencode-send">
{sending ? "Sending…" : "Send"}
</Button>
</div>
</div>
</div>
</footer>
Expand Down
75 changes: 75 additions & 0 deletions tests/composer-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";

import { composerEnterAction, type ComposerKeyPress } from "../client/lib/composerKeys.js";

const press = (overrides: Partial<ComposerKeyPress> = {}): ComposerKeyPress => ({
key: "Enter",
shiftKey: false,
metaKey: false,
ctrlKey: false,
isComposing: false,
keyCode: 13,
...overrides,
});

const desktop = { coarsePointer: false, canSubmit: true };
const phone = { coarsePointer: true, canSubmit: true };

describe("composerEnterAction", () => {
it("submits on a bare Enter with a fine pointer", () => {
expect(composerEnterAction(press(), desktop)).toEqual({ submit: true, preventDefault: true });
});

it("leaves Shift+Enter to insert a newline", () => {
expect(composerEnterAction(press({ shiftKey: true }), desktop)).toEqual({
submit: false,
preventDefault: false,
});
});

it("ignores every key other than Enter", () => {
for (const key of ["a", "Tab", "Escape", "ArrowDown"]) {
expect(composerEnterAction(press({ key }), desktop)).toEqual({
submit: false,
preventDefault: false,
});
}
});

// An IME commits its candidate with Enter. Submitting there would post a
// half-typed message on the first word of any Japanese/Chinese/Korean input.
it("never submits while an IME is composing", () => {
expect(composerEnterAction(press({ isComposing: true }), desktop)).toEqual({
submit: false,
preventDefault: false,
});
expect(composerEnterAction(press({ keyCode: 229 }), desktop)).toEqual({
submit: false,
preventDefault: false,
});
});

it("keeps Enter as a newline on a coarse pointer", () => {
expect(composerEnterAction(press(), phone)).toEqual({ submit: false, preventDefault: false });
});

it("still submits on Cmd/Ctrl+Enter on a coarse pointer", () => {
expect(composerEnterAction(press({ metaKey: true }), phone)).toEqual({
submit: true,
preventDefault: true,
});
expect(composerEnterAction(press({ ctrlKey: true }), phone)).toEqual({
submit: true,
preventDefault: true,
});
});

// Swallowing the key matters: without preventDefault an empty draft would
// gain a blank line every time the user tapped Enter looking for a send.
it("swallows Enter for a draft that cannot be sent", () => {
expect(composerEnterAction(press(), { coarsePointer: false, canSubmit: false })).toEqual({
submit: false,
preventDefault: true,
});
});
});
44 changes: 44 additions & 0 deletions tests/e2e/smoke.ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,37 @@ test.describe("composer", () => {
await expect(page.getByTestId("opencode-composer")).toHaveValue("");
});

test("submits on Enter and keeps Shift+Enter as a newline", async ({ page }) => {
await page.goto(`/sessions/ses_mock_done?directory=${encodeURIComponent(DIR)}`);
const composer = page.getByTestId("opencode-composer");

await composer.click();
await composer.type("first line");
await composer.press("Shift+Enter");
await composer.type("second line");
await expect(composer).toHaveValue("first line\nsecond line");

await composer.press("Enter");
await expect(composer).toHaveValue("");
});

test("does not submit an empty or whitespace-only draft on Enter", async ({ page }) => {
await page.goto(`/sessions/ses_mock_done?directory=${encodeURIComponent(DIR)}`);
const composer = page.getByTestId("opencode-composer");

await composer.click();
await composer.press("Enter");
await composer.type(" ");
await composer.press("Enter");

// Enter is swallowed rather than inserting a newline, and the draft is kept
// rather than cleared, which is what sending would do. The transcript is
// deliberately not asserted on: this mock session is shared with the other
// composer tests, so its contents change underneath a parallel worker.
await expect(composer).toHaveValue(" ");
await expect(page.getByTestId("opencode-send")).toBeDisabled();
});

test("accepts an image attachment", async ({ page }) => {
await page.goto(`/sessions/ses_mock_done?directory=${encodeURIComponent(DIR)}`);
await page.getByTestId("opencode-attach").setInputFiles({ name: "pixel.png", mimeType: "image/png", buffer: Buffer.from("89504e470d0a1a0a", "hex") });
Expand Down Expand Up @@ -603,6 +634,19 @@ test.describe("mobile", () => {
await expect(composer).toHaveAttribute("autocapitalize", "none");
});

// The Enter-vs-newline decision itself is covered in tests/composer-keys.test.ts:
// Playwright launches Chromium with a browser-level primaryPointerType of
// "fine", so `hasTouch` does not move `(pointer: coarse)` and this suite
// cannot faithfully emulate the soft-keyboard branch.
test("submits with Cmd/Ctrl+Enter regardless of pointer type", async ({ page }) => {
await page.goto(`/sessions/ses_mock_mobile?directory=${encodeURIComponent(DIR)}`);
const composer = page.getByTestId("opencode-composer");
await composer.click();
await composer.type("send from a phone");
await composer.press("ControlOrMeta+Enter");
await expect(composer).toHaveValue("");
});

test("contains hostile markdown width inside local code and table scrollers", async ({ page }) => {
await page.goto(`/sessions/ses_mock_mobile?directory=${encodeURIComponent(DIR)}`);
const transcript = page.getByTestId("opencode-transcript");
Expand Down
Loading