Repository navigation
feat(chat): Enter twice sends a queued message now #950
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string[]> => 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<number> => page.locator(".chat-turn-agent", { hasText: "This is the demo, so nothing ran" }).count(); | ||
| const pendingRows = (page: Page): Promise<string[]> => page.locator(".pending-message-bubble").allTextContents(); | ||
| /** The agent works: the empty box's control is Stop. */ | ||
| const working = (page: Page): Promise<void> => 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, /<script type="module"/); | ||
| // Over the demo's sockets: window.submitDelayMs holds a submit back that long before the demo | ||
| // sees it, so its receipt comes after a quick second Enter | ||
| const delay = `<script>(() => { | ||
| const Demo = window.WebSocket; | ||
| window.submitDelayMs = 0; | ||
| const Delaying = function (url, protocols) { | ||
| const socket = new Demo(url, protocols); | ||
| const send = socket.send.bind(socket); | ||
| socket.send = (data) => { | ||
| const message = typeof data === "string" ? JSON.parse(data) : null; | ||
| if (window.submitDelayMs > 0 && message !== null && message.type === "submit") setTimeout(() => send(data), window.submitDelayMs); | ||
| else send(data); | ||
| }; | ||
| return socket; | ||
| }; | ||
| Delaying.prototype = Demo.prototype; | ||
| Object.setPrototypeOf(Delaying, Demo); | ||
| window.WebSocket = Delaying; | ||
| })();</script>`; | ||
| writeFileSync(index, html.replace(/<script type="module"/, () => `<script src="./demo-transport.js"></script>\n ${delay}\n <script type="module"`)); | ||
|
|
||
| const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { | ||
| const path = new URL(request.url).pathname; | ||
| if (!path.startsWith("/herdr-web-ui/demo/app/")) return new Response("not found", { status: 404 }); | ||
| let file: string; | ||
| try { file = decodeURIComponent(path.slice("/herdr-web-ui/demo/app/".length)); } | ||
| catch { return new Response("bad path", { status: 400 }); } | ||
| if (!file || file.endsWith("/")) file += "index.html"; | ||
| if (file.split("/").includes("..") || file.includes("\\")) return new Response("bad path", { status: 400 }); | ||
| const body = Bun.file(join(app, file)); | ||
| return (await body.exists()) ? new Response(body) : new Response("not found", { status: 404 }); | ||
| } }); | ||
| const url = `http://127.0.0.1:${server.port}/herdr-web-ui/demo/app/?pane=${encodeURIComponent(panes.infra)}`; | ||
| try { | ||
| const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? "/opt/google/chrome/chrome", headless: true, args: ["--no-sandbox"] }); | ||
| try { | ||
| const run = async (settings: Record<string, unknown>, check: (page: Page, send: (text: string) => Promise<void>) => Promise<void>): Promise<void> => { | ||
| const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, locale: "en-US" }); | ||
| try { | ||
| await context.addInitScript(([paneId, stored]) => { | ||
| localStorage.setItem("herdr-web-ui:settings", stored); | ||
| localStorage.setItem(`herdr-web-ui:view:${paneId}`, "chat"); | ||
| }, [panes.infra, JSON.stringify({ language: "en", ...settings })] as const); | ||
| const page = await context.newPage(); | ||
| const errors: string[] = []; | ||
| page.on("pageerror", (error) => errors.push(error.message)); | ||
| await page.goto(url); | ||
| await page.locator(".conn-live").waitFor({ state: "attached" }); | ||
| await page.locator(".terminal-stack.is-chat").waitFor(); | ||
| const composer = page.getByRole("textbox", { name: "Message", exact: true }); | ||
| await composer.focus(); | ||
| // a first message starts the demo's turn: what follows is sent while the agent works | ||
| await composer.fill("start the turn"); | ||
| await page.keyboard.press("Enter"); | ||
| await page.waitForFunction(() => [...document.querySelectorAll(".chat-turn-user")].some((turn) => turn.textContent?.includes("start the turn")), undefined, { timeout: 10_000 }); | ||
| await working(page); | ||
| await check(page, async (text) => { await composer.fill(text); await page.keyboard.press("Enter"); }); | ||
| assert.deepEqual(errors, []); | ||
| } finally { await context.close(); } | ||
| }; | ||
|
|
||
| await run({}, async (page, send) => { | ||
| const turnsBefore = (await userTurns(page)).length; | ||
| await send("steer this"); | ||
| await page.locator(".pending-message-bubble", { hasText: "steer this" }).waitFor(); | ||
| await page.keyboard.press("Enter"); | ||
| await page.waitForFunction(() => [...document.querySelectorAll(".chat-turn-user")].some((turn) => turn.textContent?.includes("steer this")), undefined, { timeout: 1_500 }); | ||
| assert.equal((await userTurns(page)).length, turnsBefore + 1); | ||
| assert.equal(await answers(page), 0, "it went while the turn still worked, not when the turn ended"); | ||
| assert.deepEqual(await pendingRows(page), [], "the row went with its message"); | ||
| assert.equal(await page.getByRole("textbox", { name: "Message", exact: true }).inputValue(), "", "the box stays empty"); | ||
| console.log("PASS Enter twice while the agent works sends the queued message now"); | ||
| }); | ||
|
|
||
| await run({}, async (page, send) => { | ||
| await page.evaluate(() => { (window as unknown as { submitDelayMs: number }).submitDelayMs = 400; }); | ||
| await send("before its receipt"); | ||
| // the second Enter lands while the first is still on its way: the box still holds the text | ||
| await page.keyboard.press("Enter"); | ||
| assert.equal(await page.getByRole("textbox", { name: "Message", exact: true }).inputValue(), "before its receipt", "the first send is still on its way"); | ||
| await page.waitForFunction(() => [...document.querySelectorAll(".chat-turn-user")].some((turn) => turn.textContent?.includes("before its receipt")), undefined, { timeout: 1_800 }); | ||
| assert.deepEqual(await pendingRows(page), []); | ||
| assert.equal((await userTurns(page)).filter((text) => text.includes("before its receipt")).length, 1, "sent once"); | ||
| assert.equal(await answers(page), 0, "it went while the turn still worked, not when the turn ended"); | ||
| console.log("PASS a second Enter before the bridge's receipt sends the message once it is held"); | ||
| }); | ||
|
|
||
| await run({}, async (page, send) => { | ||
| await send("one enter only"); | ||
| await page.locator(".pending-message-bubble", { hasText: "one enter only" }).waitFor(); | ||
| await page.waitForTimeout(1_100); | ||
| // too late to be the second of two: Enter on the empty box does nothing | ||
| await page.keyboard.press("Enter"); | ||
| await page.waitForTimeout(400); | ||
| assert.deepEqual(await pendingRows(page), ["one enter only"], "still waits for its turn"); | ||
| assert.ok((await userTurns(page)).every((text) => !text.includes("one enter only"))); | ||
| console.log("PASS one Enter, or a second one after a second, leaves the message queued for its turn"); | ||
| }); | ||
|
|
||
| await run({ enterTwiceSteers: false }, async (page, send) => { | ||
| await send("setting off"); | ||
| await page.locator(".pending-message-bubble", { hasText: "setting off" }).waitFor(); | ||
| await page.keyboard.press("Enter"); | ||
| await page.waitForTimeout(400); | ||
| assert.deepEqual(await pendingRows(page), ["setting off"]); | ||
| console.log("PASS with Enter twice sends now off, a second Enter leaves the message queued"); | ||
| }); | ||
|
|
||
| // with automatic sending off a message waits for Send now: Enter twice is that Send now | ||
| await run({ autoSendQueued: false }, async (page, send) => { | ||
| await send("held then sent"); | ||
| await page.locator(".pending-message-bubble", { hasText: "held then sent" }).waitFor(); | ||
| await page.keyboard.press("Enter"); | ||
| await page.waitForFunction(() => [...document.querySelectorAll(".chat-turn-user")].some((turn) => turn.textContent?.includes("held then sent")), undefined, { timeout: 1_500 }); | ||
| assert.deepEqual(await pendingRows(page), []); | ||
| console.log("PASS a message held for Send now goes with a second Enter"); | ||
| }); | ||
| } finally { await browser.close(); } | ||
| } finally { server.stop(); } | ||
| } finally { rmSync(app, { recursive: true, force: true }); } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,6 +58,8 @@ import { narrowPaneFontSize } from "../lib/narrowPaneFont.ts"; | |
| const RESIZE_SETTLE_MS = 120; | ||
| /** How long a tab is out of use before it lets go of its pane: a glance at another window keeps it. */ | ||
| const RELEASE_AFTER_MS = 1000; | ||
| /** A second Enter this soon after a message was queued sends it now (Settings → Enter twice sends now). */ | ||
| const SEND_QUEUED_AGAIN_MS = 1000; | ||
|
|
||
| // Only a tab the user is in drives the shared grid. A window left open behind another app | ||
| // still turns visible when the screen wakes, and reconnects, reloads or moves on to the next pane | ||
|
|
@@ -2139,6 +2141,10 @@ export function PaneTerminal({ | |
| return () => { observer.disconnect(); stack.style.removeProperty("--composer-lift"); stack.style.removeProperty("--composer-room"); setGreetingRoom(true); }; | ||
| }, [greetingDue, paneId]); | ||
|
|
||
| // the message the composer queued last, by time, and by ID once the bridge holds it: a second | ||
| // Enter sends it now, also when that Enter came before the bridge's receipt did | ||
| const lastQueuedRef = useRef<{ pane: string; at: number; id: string | null; now: boolean } | null>(null); | ||
| const actOnPendingRef = useRef<(id: string, action: "steer" | "discard") => Promise<void>>(async () => {}); | ||
| const composerSend = useCallback( | ||
| (text: string, clearDraft?: string): boolean | string | DraftRefusal | Promise<boolean | string | DraftRefusal> => { | ||
| const pane = paneRef.current; | ||
|
|
@@ -2170,7 +2176,16 @@ export function PaneTerminal({ | |
| const queued = composerDelivery(agent, agentStatus); | ||
| const delivery: SubmitDelivery = queued === "queue" && !settings.autoSendQueued ? "hold" : queued; | ||
| const result = submitComposerMessage(text, delivery, delivery === "immediate" ? clearDraft : undefined); | ||
| return result === null ? false : result.then((answer) => answer.ok ? true : submitRefusal(answer.code, answer.message, answer.draft)); | ||
| if (result === null) return false; | ||
| const queuedSend = delivery !== "immediate" && pane !== null ? { pane, at: Date.now(), id: null as string | null, now: false } : null; | ||
| if (queuedSend) lastQueuedRef.current = queuedSend; | ||
|
Comment on lines
+2180
to
+2181
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Arm Enter-twice only after a keyboard send. A quick-reply click also calls 🤖 Prompt for AI Agents |
||
| return result.then((answer) => { | ||
| if (queuedSend !== null && answer.ok && answer.pending) { | ||
| queuedSend.id = answer.pending.id; | ||
| if (queuedSend.now) void actOnPendingRef.current(answer.pending.id, "steer"); | ||
| } | ||
| return answer.ok ? true : submitRefusal(answer.code, answer.message, answer.draft); | ||
| }); | ||
| }, | ||
| [agent, agentStatus, answerPanePrompt, answering, heldByOpenQueue, submitComposerMessage, queueStore, machineId, settings.autoSendQueued], | ||
| ); | ||
|
|
@@ -2218,6 +2233,16 @@ export function PaneTerminal({ | |
| pendingMessages.fail(owner, id, { code: "disconnected", message: t("Not confirmed. Check the terminal before sending again.") }, true); | ||
| } finally { pendingMessages.end(owner, id); } | ||
| }, [answering, ended, heldByOpenQueue, paneId, queueOwner, submitComposerMessage, submitRetention]); | ||
| actOnPendingRef.current = actOnPending; | ||
|
|
||
| /** The second Enter of two: the message the first one queued is sent now, once, as its Send now would. */ | ||
| const sendQueuedNow = useCallback((): boolean => { | ||
| const last = lastQueuedRef.current; | ||
| if (!settings.enterTwiceSteers || last === null || last.now || last.pane !== paneRef.current || Date.now() - last.at > SEND_QUEUED_AGAIN_MS) return false; | ||
| last.now = true; | ||
| if (last.id !== null) void actOnPending(last.id, "steer"); | ||
| return true; | ||
| }, [actOnPending, settings.enterTwiceSteers]); | ||
|
|
||
|
|
||
| // Capture the owner's pane for the entire upload batch, even across a pane switch. | ||
|
|
@@ -2521,6 +2546,7 @@ export function PaneTerminal({ | |
| ) : null} | ||
| onSend={composerSend} | ||
| onAbort={abortTurn} | ||
| onSendQueuedNow={sendQueuedNow} | ||
| onUploadImage={uploadImage} | ||
| /> | ||
| )} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Ignore repeated Enter keydowns before steering.
When a user holds Enter to queue a message, keyboard auto-repeat can trigger this callback within one second. The repeated keydown then sends the queued message now, although the user did not press Enter twice. Require a distinct key press, such as by rejecting
event.repeat, before callingonSendQueuedNow.🤖 Prompt for AI Agents