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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 20 additions & 9 deletions frontend/src/components/pulls/PullRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
/** 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
Expand All @@ -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<ReviewEvent | null>(null)
const [edit, setEdit] = useState<MergeEdit | null>(null)
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -265,11 +276,11 @@ export function PullRequestView({
<Button
variant="ghost"
size="sm"
disabled={session.blocked !== null}
onClick={handOff}
disabled={handingOff || session.blocked !== null}
onClick={() => void handOff()}
>
<Wrench />
{handoff.label}
{handingOff ? "Opening session…" : handoff.label}
</Button>
</span>
)}
Expand Down
46 changes: 39 additions & 7 deletions frontend/src/components/pulls/Pulls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string> => {
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)
Expand Down Expand Up @@ -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}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/render-budget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ const workspace: ProjectsValue = {
closeProject: noop,
newSession: () => "",
newWorktreeSession: () => "",
reopenWorktreeSession: async () => {},
reopenWorktreeSession: async () => "",
resumeClosedSession: async () => {},
closeSession: noop,
discardSession: noop,
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/lib/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ export const Terminal = {
rows: number,
) => call<null>("terminal.Start", [id, projectID, cwd, kind, resume, name, setup, cols, rows]),
Write: (id: string, data: string) => call<null>("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<boolean>("terminal.Ready", [id]),
Resize: (id: string, cols: number, rows: number) =>
call<null>("terminal.Resize", [id, cols, rows]),
SetVisible: (id: string, visible: boolean) => call<null>("terminal.SetVisible", [id, visible]),
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/lib/terminal/write-at-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
48 changes: 48 additions & 0 deletions frontend/src/lib/terminal/write-at-prompt.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
5 changes: 3 additions & 2 deletions frontend/src/providers/projects-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
* 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<string>
/** Resume a session picked out of the history, reopening its project first
* when that project's tab is gone. */
resumeClosedSession: (closed: ClosedSession) => Promise<void>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/providers/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
)
Expand Down