From d246203f397b590ff4d4fa92c8db615dc44f2e77 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Tue, 13 Oct 2026 17:21:45 +0900 Subject: [PATCH] feat(chat): Enter twice sends a queued message now While the agent works, Enter queues the message for the turn's end (or holds it for Send now). There was no keyboard way to steer: only the row's Send now button. A second Enter within a second of the one that queued it now does what Send now does, on the same pending ID and through the same `pending-action` steer, so nothing new crosses the wire. The second Enter counts on an empty box, or while the first send is still on its way (the box keeps its text until the bridge's receipt): then the message is sent now as soon as the receipt names its ID. It acts once, only on the message this tab queued last, and only in the pane it was queued in. Settings -> Chat -> Enter twice sends now (default on, this device's) turns it off; the setting is reported with the other safe values. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 + scripts/ci-browser.sh | 1 + scripts/ci-browser.test.ts | 3 +- scripts/enter-twice-demo-regression.ts | 155 +++++++++++++++++++++++++ shared/telemetry.ts | 2 + src/components/Composer.tsx | 8 +- src/components/PaneTerminal.tsx | 28 ++++- src/components/SettingsDialog.tsx | 3 + src/lib/api.test.ts | 2 +- src/lib/i18n.ja.ts | 2 + src/lib/i18n.ko.ts | 2 + src/lib/i18n.tr.ts | 2 + src/lib/i18n.zh.ts | 2 + src/lib/settings.test.ts | 7 ++ src/lib/settings.ts | 7 +- telemetry/worker.test.ts | 1 + 16 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 scripts/enter-twice-demo-regression.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cdd46c9c5..938b2fc1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] ### Added +- Enter twice sends a message now while the agent works: the first Enter queues it as before, and + a second within a second (on the empty box, or while the first is still on its way) sends it at + once, as its Send now does — steering the running turn instead of waiting for it to end. It + works the same with automatic sending off. **Settings → Chat → Enter twice sends now** turns + it off. - **Settings → Phone & devices → Portal** gives this PC a public HTTPS address through [Portal](https://github.com/gosuda/portal-tunnel), with no account and no domain of your own. With Portal v2.6.1 or later installed, **Start** runs it on a relay you enter, dropping any diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh index d35614132..66345e887 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -78,6 +78,7 @@ run_script scripts/chat-greeting-demo-regression.ts run_script scripts/restored-draft-demo-regression.ts run_script scripts/composer-fit-demo-regression.ts run_script scripts/held-rows-demo-regression.ts +run_script scripts/enter-twice-demo-regression.ts run_script scripts/sidebar-activity-demo-regression.ts run_script scripts/sidebar-spacing-demo-regression.ts run_script scripts/workspace-touch-reorder-demo-regression.ts diff --git a/scripts/ci-browser.test.ts b/scripts/ci-browser.test.ts index aaf927289..6929eb72f 100644 --- a/scripts/ci-browser.test.ts +++ b/scripts/ci-browser.test.ts @@ -69,7 +69,8 @@ describe("browser lane", () => { it("summarizes every script and exits zero on success without enabling local evidence", async () => { const { text, code, evidence } = await lane(); expect(code).toBe(0); - expect(rows(text)).toHaveLength(45); + expect(rows(text)).toHaveLength(46); + expect(rows(text).map((row) => row.script)).toContain("scripts/enter-twice-demo-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/portal-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/agy-chat-browser-qa.ts"); expect(rows(text).filter((row) => row.script === "scripts/ui-contexts-regression.ts")).toHaveLength(1); diff --git a/scripts/enter-twice-demo-regression.ts b/scripts/enter-twice-demo-regression.ts new file mode 100644 index 000000000..0cfe0c649 --- /dev/null +++ b/scripts/enter-twice-demo-regression.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Page } from "playwright-core"; +import panes from "../site/demo/fixtures/panes.json"; +import { buildDemoApp } from "./demo-build.ts"; + +// Enter twice while the agent works (Settings → Enter twice sends now), on the unmodified app over +// the demo's fixture transport: the first Enter queues the message, a second within a second sends +// it now, as its Send now does — also when it comes before the bridge's receipt for the first. A +// single Enter, a second one too late, or the setting off leaves it queued. The demo's mock turn +// takes 2.4 s and sends what is still queued when it ends, so a message sent now is one that joins +// the chat while the turn still works. All files and HTTP traffic stay in this disposable, +// loopback-only app; no herdr session is opened. +const app = mkdtempSync(join(tmpdir(), "herdr-enter-twice-demo-")); + +// playwright-core's bundle lazily requires packages the lockfile does not carry (chromium-bidi): +// fine at run time, unresolvable to `bun build` — so it is imported past the bundler's analysis +const playwrightCore = "playwright-core"; +const { chromium } = (await import(playwrightCore)) as typeof import("playwright-core"); + +/** The user turns the chat shows, in order. */ +const userTurns = (page: Page): Promise => page.locator(".chat-turn-user .chat-bubble").allTextContents(); +/** The demo turn's answers so far: a message sent now joins the chat before the running turn's answer. */ +const answers = (page: Page): Promise => page.locator(".chat-turn-agent", { hasText: "This is the demo, so nothing ran" }).count(); +const pendingRows = (page: Page): Promise => page.locator(".pending-message-bubble").allTextContents(); +/** The agent works: the empty box's control is Stop. */ +const working = (page: Page): Promise => page.getByRole("button", { name: "Stop agent", exact: true }).waitFor({ timeout: 10_000 }); + +try { + await buildDemoApp(app); + const index = join(app, "index.html"); + const html = readFileSync(index, "utf8"); + assert.match(html, /`; + writeFileSync(index, html.replace(/\n ${delay}\n