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], )