From 8f253eeca7a9ce815a42f36e2900355633a14292 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 4 Sep 2026 15:41:56 -0300 Subject: [PATCH] feat: hand a pull request's problem to a session on its own branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Resolve conflicts" and "Fix CI errors" buttons wrote their prompt through the screen's ordinary inject — into whichever session happened to be active. That is the session the user was last in, on another branch, with nothing on screen to say anything had happened; and with none active the click only answered "Open a session to send this to", which read as a button that needs another one pressed first. It works on the pull request's own branch now. openInSession answers with the session it opened — the live one, a parked one resumed, or a worktree created from the PR and a session on it — so the handoff reuses the whole of that flow, then goes there. What the click did is on screen, because the screen is now the session it went to. The prompt waits for a prompt to land at. A write issued the moment a session's card exists reaches whatever that PTY is up to: the checkout's setup script, a provider still taking over the tty, both of which read it and discard it. terminal.Ready is the question the relay already asks before it delivers, and it is polled from the page rather than waited on in Go: the wait can be minutes, and a held request is one of the browser's handful of connections to this backend. Enter stays the user's. The relay is still the one place lich presses it. Claude-Session: https://claude.ai/code/session_01V4L4uMYd3WoQWRraqo9FPa --- CHANGELOG.md | 12 +++++ docs/ceilings.md | 4 +- .../src/components/pulls/PullRequestView.tsx | 29 +++++++---- frontend/src/components/pulls/Pulls.tsx | 46 +++++++++++++++--- .../src/components/render-budget.test.tsx | 2 +- frontend/src/lib/rpc.ts | 6 +++ .../src/lib/terminal/write-at-prompt.test.ts | 37 ++++++++++++++ frontend/src/lib/terminal/write-at-prompt.ts | 48 +++++++++++++++++++ frontend/src/providers/projects-context.ts | 5 +- frontend/src/providers/projects.tsx | 4 +- 10 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 frontend/src/lib/terminal/write-at-prompt.test.ts create mode 100644 frontend/src/lib/terminal/write-at-prompt.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c2419eda1..ffe48afc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 above it is GitHub's own verdict, so the two disagree for a few seconds after a push — the row says which one is the fresh reading. +### Changed + +- **"Resolve conflicts" and "Fix CI errors" now open the session they hand the + work to.** The button wrote its prompt into whichever session happened to be + active — the one you were last in, on another branch, with nothing on screen + to say it had happened — or refused outright when there was none, which read + as having to press "Open in Session" first. It works on the pull request's own + branch now: the session already there, the parked one resumed, or the worktree + created and a session opened on it, and that session is brought into view. The + prompt is written once its agent is at a prompt to take it, rather than into a + setup script still installing the checkout. The Enter stays yours. + ## [0.44.0] - 2026-09-03 ### Added diff --git a/docs/ceilings.md b/docs/ceilings.md index ae7c87c85..48b27dc65 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -315,7 +315,9 @@ work when nobody knows it and that the call site never shows. The mechanism and `draftIdle` for nothing. The stale-draft release is what keeps that a delay instead of a wedged relay. Two gaps stay open: input arriving between the paste and its Enter still rides along — a window that is `defaultSubmitDelay` at best and lasts until the target's PTY goes quiet at worst (`internal/relay`, `awaitSettled`) — and a provider - that takes keystrokes through anything other than this PTY is invisible here. + that takes keystrokes through anything other than this PTY is invisible here. The pull request handoff waits on + the same answer (`frontend/src/lib/terminal/write-at-prompt.ts`) with no Enter of its own to justify it: hand a + conflict to a session you left half a sentence in, and nothing appears at that prompt until the draft goes stale. - **A relayed Enter is timed against silence, not against the target** (`internal/relay`, `awaitSettled`): lich presses Enter once the target's PTY has been quiet for `defaultSubmitDelay`, because nothing here can read a TUI's screen to know it has taken the paste in. On Windows that quiet is the whole instrument — ConPTY hands a child key diff --git a/frontend/src/components/pulls/PullRequestView.tsx b/frontend/src/components/pulls/PullRequestView.tsx index cc292aa78..e9a2e8771 100644 --- a/frontend/src/components/pulls/PullRequestView.tsx +++ b/frontend/src/components/pulls/PullRequestView.tsx @@ -96,6 +96,9 @@ interface PullRequestViewProps { onMerged: () => void /** Write into the session's terminal; false when no session took it. */ onInject: (text: string) => boolean + /** Hand the pull request's problem to a session on its branch — opening one + * and going there, so the prompt is written where it can be read. */ + onHandOff: (prompt: string) => Promise /** What has been said about this pull request, and how to re-read it after a * reply, a resolve or a submitted review. */ conversation: PullRequestConversation | null @@ -115,11 +118,13 @@ export function PullRequestView({ onRefresh, onMerged, onInject, + onHandOff, conversation, conversationLoading, onConversationRefresh, }: PullRequestViewProps) { const [merging, setMerging] = useState(false) + const [handingOff, setHandingOff] = useState(false) const [submitting, setSubmitting] = useState(false) const [verdict, setVerdict] = useState(null) const [edit, setEdit] = useState(null) @@ -148,12 +153,18 @@ export function PullRequestView({ // The problem in the way, written at the agent's prompt and nothing more: // lich pastes it and stops there — no run, no watch, no second attempt when - // the checks come back red again. The comments are the user's own text and - // survive a failed send (CommentBatch); this is derived from the pull request - // and can simply be asked for again, so a session-less click only says so. - const handOff = () => { - if (handoff && !onInject(handoff.prompt)) { - toast.error("Open a session to send this to") + // the checks come back red again. Where it goes, and what it takes to get a + // session there, is the screen's (Pulls, handOff); the flag is only so the + // button says the session is coming while a worktree is being made. + const handOff = async () => { + if (!handoff) { + return + } + setHandingOff(true) + try { + await onHandOff(handoff.prompt) + } finally { + setHandingOff(false) } } @@ -265,11 +276,11 @@ export function PullRequestView({ )} diff --git a/frontend/src/components/pulls/Pulls.tsx b/frontend/src/components/pulls/Pulls.tsx index 4305fc9cc..ea9941c1b 100644 --- a/frontend/src/components/pulls/Pulls.tsx +++ b/frontend/src/components/pulls/Pulls.tsx @@ -14,6 +14,7 @@ import { closePulls, openPulls } from "@/lib/pulls-card-store" import { sessionsOf } from "@/lib/session/sessions" import { useActiveSession } from "@/lib/session/use-active-session" import { queueSetup } from "@/lib/terminal/setup-queue" +import { writeAtPrompt } from "@/lib/terminal/write-at-prompt" import { useGitStatus } from "@/lib/git/use-git-status" import { useCheckouts } from "@/lib/git/use-checkouts" import { invalidatePullRequests } from "@/lib/pulls/pull-request-lookup" @@ -219,45 +220,75 @@ export function Pulls({ list = false }: PullsProps) { // git refuses to check one branch out twice, so a checkout that already holds // it is reused rather than recreated — and that includes the project's own // directory, which is where a branch usually is. - const openInSession = async () => { + // + // It answers with the session the work lands in, so a caller with something to + // hand that session knows where to write; "" when nothing was opened. + const openInSession = async (): Promise => { if (!projectId || !detail) { - return + return "" } const existing = checkedOut if (existing) { const live = sessionsOf(sessions, projectId).find((s) => s.path === existing.path) + let target: string if (live) { activateSession(projectId, live.id) + target = live.id } else if (existing.path === projectPath) { // The project's own checkout is not a worktree: it has no parked // session to resume and must never be handed to the worktree flows. - newSession(projectId) + target = newSession(projectId) } else { - await reopenWorktreeSession(projectId, existing) + target = await reopenWorktreeSession(projectId, existing) } openPulls(existing.path) navigate(`/projects/${projectId}`) - return + return target } setOpening(true) try { const wt = await ProjectService.CreateWorktreeFromPR(projectPath, projectId, detail.number) if (!wt) { - return + return "" } // A fresh checkout is the one moment the project's setup script runs, and // the pull request card rides along so the session carries its PR. - queueSetup(newWorktreeSession(projectId, wt)) + const target = newWorktreeSession(projectId, wt) + queueSetup(target) openPulls(wt.path) refreshCheckouts() navigate(`/projects/${projectId}`) + return target } catch (err: unknown) { toast.error(`Couldn’t open a session: ${errorText(err)}`) + return "" } finally { setOpening(false) } } + // The problem in the way, written at the prompt of the session that would fix + // it: the one on the pull request's own branch, opened here when there is + // none, and brought into view either way. The screen the click happened on + // cannot show what became of it, and a prompt filled in a session nobody is + // looking at is a click that did nothing — which is how this button was first + // used, back when it wrote into whichever session happened to be active. + // + // lich stops at the prompt. The text is there to read, edit and send, and the + // Enter is the user's: the relay is the one place lich presses it, for a + // sender that is not a person (internal/relay, submitDelay). + const handOff = async (prompt: string) => { + const target = await openInSession() + if (!target) { + return + } + try { + await writeAtPrompt(target, prompt) + } catch (err: unknown) { + toast.error(`Couldn’t hand this to the session: ${errorText(err)}`) + } + } + const session: SessionAction = { label: checkedOut && sessionsOf(sessions, projectId ?? "").some((s) => s.path === checkedOut.path) @@ -293,6 +324,7 @@ export function Pulls({ list = false }: PullsProps) { onRefresh={reload} onMerged={onMerged} onInject={inject} + onHandOff={handOff} conversation={conversation.conversation} conversationLoading={conversation.loading} onConversationRefresh={conversation.refresh} diff --git a/frontend/src/components/render-budget.test.tsx b/frontend/src/components/render-budget.test.tsx index 4ff172bce..3b7476f6f 100644 --- a/frontend/src/components/render-budget.test.tsx +++ b/frontend/src/components/render-budget.test.tsx @@ -152,7 +152,7 @@ const workspace: ProjectsValue = { closeProject: noop, newSession: () => "", newWorktreeSession: () => "", - reopenWorktreeSession: async () => {}, + reopenWorktreeSession: async () => "", resumeClosedSession: async () => {}, closeSession: noop, discardSession: noop, diff --git a/frontend/src/lib/rpc.ts b/frontend/src/lib/rpc.ts index 6b111d9aa..55b401e72 100644 --- a/frontend/src/lib/rpc.ts +++ b/frontend/src/lib/rpc.ts @@ -132,6 +132,12 @@ export const Terminal = { rows: number, ) => call("terminal.Start", [id, projectID, cwd, kind, resume, name, setup, cols, rows]), Write: (id: string, data: string) => call("terminal.Write", [id, data]), + /** Whether this session can be given work: its provider is the program + * reading the PTY — not the checkout's setup script, not a TUI still taking + * the tty over — and the prompt is not half-filled by the person at it. False + * for a session that is not running, including one whose card exists but + * whose terminal has never been opened (lib/terminal/write-at-prompt). */ + Ready: (id: string) => call("terminal.Ready", [id]), Resize: (id: string, cols: number, rows: number) => call("terminal.Resize", [id, cols, rows]), SetVisible: (id: string, visible: boolean) => call("terminal.SetVisible", [id, visible]), diff --git a/frontend/src/lib/terminal/write-at-prompt.test.ts b/frontend/src/lib/terminal/write-at-prompt.test.ts new file mode 100644 index 000000000..ef043a89e --- /dev/null +++ b/frontend/src/lib/terminal/write-at-prompt.test.ts @@ -0,0 +1,37 @@ +// The one thing this has to get right is the order: nothing is written into a +// session that has no prompt yet. So both calls are stubbed and what is asserted +// is that the write did not happen until Ready said so. +import { describe, expect, it, vi } from "vitest" +import { writeAtPrompt } from "./write-at-prompt" + +const ready = vi.fn() +const write = vi.fn() + +vi.mock("@/lib/rpc", () => ({ + Terminal: { + Ready: (id: string) => ready(id), + Write: (id: string, data: string) => write(id, data), + }, +})) + +describe("writeAtPrompt", () => { + it("waits for the prompt before writing", async () => { + ready.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValueOnce(true) + + await writeAtPrompt("s1", "resolve the conflicts") + + expect(ready).toHaveBeenCalledTimes(3) + expect(write).toHaveBeenCalledWith("s1", "resolve the conflicts") + }) + + it("writes straight away when the session is already at a prompt", async () => { + ready.mockReset() + write.mockReset() + ready.mockResolvedValue(true) + + await writeAtPrompt("s2", "fix CI") + + expect(ready).toHaveBeenCalledTimes(1) + expect(write).toHaveBeenCalledWith("s2", "fix CI") + }) +}) diff --git a/frontend/src/lib/terminal/write-at-prompt.ts b/frontend/src/lib/terminal/write-at-prompt.ts new file mode 100644 index 000000000..c90bf8209 --- /dev/null +++ b/frontend/src/lib/terminal/write-at-prompt.ts @@ -0,0 +1,48 @@ +import { Terminal } from "@/lib/rpc" + +// Text written into a session the moment its card exists lands wherever that +// PTY has got to — the checkout's setup script, a provider still taking the tty +// over — and both read it and throw it away. What is left on screen is a prompt +// that was never given anything, which is how this was found: a pull request +// handed to a session opened for it, and nothing at the prompt when the agent +// came up. +// +// So the write waits for a prompt to write at. `terminal.Ready` is the same +// question the relay asks before delivering a task, and it answers a session +// that has not spawned yet as well as one still installing its dependencies — +// so one call covers the session opened a moment ago and the one that has been +// sitting there all along, which answers immediately. +// +// It also answers no while the person at that prompt has unsent input, which is +// the relay's reason rather than this one — nothing is sent here. The wait is +// kept anyway: a paste landing in the middle of someone's half-typed sentence is +// the worse of the two, and the draft releases itself (`internal/terminal`, +// draftIdle). +// +// Polled from here rather than waited on in Go: the wait can be minutes, and a +// request held open for that long is one of the browser's handful of +// connections to this backend. A short call every quarter second is the cheaper +// half of that trade on loopback. + +const POLL_MS = 250 + +// How long a prompt has to appear. A worktree's first setup run is minutes of +// `pnpm install` before its provider starts, and the paste is worth waiting +// out; a session that never comes up must still stop being waited for. +const LIMIT_MS = 5 * 60 * 1000 + +/** + * Write text into a session's terminal once its prompt can take it. Rejects + * when no prompt appears in time — the caller is the one who knows what was + * being handed over, so it words what was lost. + */ +export async function writeAtPrompt(sessionId: string, text: string): Promise { + const deadline = Date.now() + LIMIT_MS + while (!(await Terminal.Ready(sessionId))) { + if (Date.now() >= deadline) { + throw new Error("the session never reached a prompt") + } + await new Promise((resolve) => setTimeout(resolve, POLL_MS)) + } + await Terminal.Write(sessionId, text) +} diff --git a/frontend/src/providers/projects-context.ts b/frontend/src/providers/projects-context.ts index a37080d3b..fec3eea11 100644 --- a/frontend/src/providers/projects-context.ts +++ b/frontend/src/providers/projects-context.ts @@ -31,8 +31,9 @@ export interface ProjectsValue { sandbox?: string, ) => string /** Resume a worktree: reopen its parked session when one exists, else open a - * fresh session on it. */ - reopenWorktreeSession: (projectId: string, wt: { name: string; path: string }) => Promise + * fresh session on it. Answers with the session either way, for a caller with + * something to hand it. */ + reopenWorktreeSession: (projectId: string, wt: { name: string; path: string }) => Promise /** Resume a session picked out of the history, reopening its project first * when that project's tab is gone. */ resumeClosedSession: (closed: ClosedSession) => Promise diff --git a/frontend/src/providers/projects.tsx b/frontend/src/providers/projects.tsx index 6ac875c37..669cb5739 100644 --- a/frontend/src/providers/projects.tsx +++ b/frontend/src/providers/projects.tsx @@ -403,10 +403,10 @@ export function ProjectsProvider({ children }: { children: ReactNode }) { // it as unspawned and asks before continuing. No parked row → start fresh. const restored = await Store.ReopenWorktreeSession(projectId, wt.path, newSessionId()) if (!restored) { - newWorktreeSession(projectId, wt) - return + return newWorktreeSession(projectId, wt) } commit(restoreSession(sessionsRef.current, projectId, cardFromStored(restored))) + return restored.id }, [newWorktreeSession], )