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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- On macOS, lich has a menu-bar item: a lich whose window is closed shows it is still running its sessions there, with Show lich, the count of running sessions and Quit, as the tray does on Linux and Windows.
- **Services: commands a project keeps running, started by lich.** Save a container, a tunnel or a dev server once in Settings › Services of a project, and start it from the Services row at the bottom of the sidebar, above the profile. Closed, the row says how many run (2/3); opened, it lists each service with how long it has run or how it ended, and Start, Stop and Restart on hover. A click opens its console, where it takes input and Ctrl+C stops it. A service is not a terminal: when its command exits, it has stopped, its last output stays readable, and Start runs it again. Tick Start with lich and it starts every time lich does with its project open. Services saved in Settings › Services with Global selected run in your home directory and show on lich's home screen, the place for a `lazydocker` you keep open whatever the project. A service lives as long as its project's tab: closing the tab stops it, and reopening the project starts the ones saved to start with lich. Closing the window leaves them running, and the tray lists them with Show output, Restart and Stop; quitting lich stops them, and the close dialog counts them. With the sidebar collapsed, the rail's Services button opens the same list.

- **Send a pull request's review threads to its session.** On the Pulls screen, an open review thread has Send to session beside Reply and Resolve, in the Conversation tab and in the diff, and the Conversation tab can send every open thread at once. The threads go as one prompt to the session in the branch's checkout, which lich opens when there is none, with each thread's file, lines, link and every comment in it. lich then answers each thread on GitHub with "Fixing this. It'll be in the next commit." and resolves it once the pull request is pushed again, replying with the commit it landed in; until then the thread reads Sent, waiting for a push. Any push resolves it, even one that did not touch it.
- **Send a pull request's review threads to its session.** On the Pulls screen, an open review thread has Send to session beside Reply and Resolve, in the Conversation tab and in the diff, and the Conversation tab can send every open thread at once. The threads go as one prompt to the session in the branch's checkout, which lich opens when there is none, with each thread's file, lines, link and every comment in it. Pulls stays on screen, and a toast with Open says when the session took them. lich then answers each thread on GitHub with "Fixing this. It'll be in the next commit." and resolves it once the pull request is pushed again, replying with the commit it landed in; until then the thread reads Sent, waiting for a push. Any push resolves it, even one that did not touch it.

### Changed

- Resolve conflicts and Fix CI errors in Pulls no longer take you to the session they hand the pull request to. Pulls stays on screen so you can move to the next pull request, the session starts in the sidebar, and a toast says when the agent got the task, with Open to go to it. A session whose start needs your answer first, such as whether to resume its last conversation, still comes up. Open in Session still goes to the session.
- The quick filters over the pull request list in Pulls (All, Ready, Drafts, Failing) are lighter: small text with the selected one shaded, as in the dock's tabs, sharing the list's width. A filter with nothing in it is faded and Failing's count turns red when a check fails. They no longer run past the list's edge with a monospace interface font.

### Fixed
Expand Down
115 changes: 88 additions & 27 deletions native/cmd/lich-native/pulls_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,67 +147,128 @@ func (u *window) approvePR(path string, number int) {
u.toaster.Show(ui.Toast{Variant: ui.ToastSuccess, Title: i18n.T("pulls.pullRequestView.approvedToast", i18n.Args{"number": number})})
}

// openPRSession opens the PR in a session, then sends prompt (when any) as
// the session's prompt: a checkout already holding the branch is reused, its live
// session brought up or a new one opened; else the PR is checked out into a
// new worktree. The checkouts are read fresh, never from what drew the button.
// openPRSession opens the PR in a session: a checkout already holding the
// branch is reused, its live session or a new one; else the PR is checked out
// into a new worktree. The checkouts are read fresh, never from what drew the
// button. A handoff (prompt not "") leaves the Pulls screen up, the session
// spawned off screen and an Open on the toast that says the prompt landed;
// only a spawn waiting on the user brings the session up.
func (u *window) openPRSession(project backend.Project, d backend.PRDetail, prompt string) {
u.pulls.view.setBusy("session")
defer u.pulls.view.setBusy("")
id, err := u.prSession(project, d)
u.pulls.view.setBusy("")
if err != nil {
u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pulls.openSessionFailed", i18n.Args{"error": backend.ErrorText(err)})})
return
}
u.pulls.hide()
u.model.invalidate()
if prompt != "" {
u.submitAtPrompt(id, prompt)
if prompt == "" {
u.showPRSession(project.ID, id)
return
}
if u.handOffPR(project.ID, id, prompt) {
u.toaster.Show(ui.Toast{Variant: ui.ToastSuccess, Title: i18n.T("pulls.pulls.handedOff", i18n.Args{"number": d.Number, "session": u.model.sessionLabel(id)}),
Action: u.openPRButton(project.ID, id)})
}
}

func (u *window) showPRSession(projectID, id string) {
u.pulls.hide()
u.openSession(projectID, id)
}

// openPRButton is a toast's Open, bringing session id up over Pulls.
func (u *window) openPRButton(projectID, id string) *ui.ToastButton {
return &ui.ToastButton{Label: i18n.T("shell.projectEvents.open"), OnClick: func() { u.showPRSession(projectID, id) }}
}

// handOffPR sends prompt to session id, spawned off screen unless its spawn
// needs the user, and reports whether it landed; a toast with Open says why
// not.
func (u *window) handOffPR(projectID, id, prompt string) bool {
if !u.startUnseen(id) {
u.showPRSession(projectID, id)
}
if err := u.handOff(id, prompt); err != nil {
t := handoffLostToast(err)
t.Action = u.openPRButton(projectID, id)
u.toaster.Show(t)
return false
}
return true
}

// startUnseen spawns session id as its first view would, false when it cannot
// start without the user and has to come up on screen; a failed Start is
// left for that view to report.
func (u *window) startUnseen(id string) bool {
s, ok := u.model.sessionViewOf(id)
if !ok {
return false
}
resume, ok := u.gate.unseen(u.ctx, s)
if !ok {
return false
}
if err := u.terms.start(u.ctx, s, resume, 0, 0); err != nil {
u.logErr("start session off screen", err)
return false
}
return true
}

// prSession is the session the PR opens in, its card loaded but not on
// screen.
func (u *window) prSession(project backend.Project, d backend.PRDetail) (string, error) {
checkouts, err := u.pulls.store.freshCheckouts(project.Path)
if err != nil {
return "", err
}
i := slices.IndexFunc(checkouts, func(c backend.Worktree) bool { return c.Name == d.HeadRefName })
if i < 0 {
var existing *backend.Worktree
if i := slices.IndexFunc(checkouts, func(c backend.Worktree) bool { return c.Name == d.HeadRefName }); i >= 0 {
existing = &checkouts[i]
if id, ok := u.model.sessionToOpenAt(project.ID, existing.Path); ok {
return id, nil
}
}
id, err := u.recordPRSession(project, d, existing)
if err != nil {
return "", err
}
return id, u.loadSessions()
}

// recordPRSession records a session for the PR at existing, a checkout with
// no session in it, or in a new worktree for nil.
func (u *window) recordPRSession(project backend.Project, d backend.PRDetail, existing *backend.Worktree) (string, error) {
r := worktreeRequest{project: project, kind: u.model.newSessionKind()}
if existing == nil {
wt, err := u.client.CreateWorktreeFromPR(u.ctx, project.Path, project.ID, d.Number)
if err != nil {
return "", err
}
return u.addWorktreeSession(worktreeRequest{project: project, kind: u.model.newSessionKind()}, wt, "")
}
existing := checkouts[i]
if id, ok := u.model.sessionToOpenAt(project.ID, existing.Path); ok {
u.openSession(project.ID, id)
return id, nil
return u.recordWorktreeSession(r, wt, "")
}
if existing.Path == project.Path {
return u.newSessionAt(project, existing.Path)
return u.recordSessionAt(project, existing.Path)
}
restored, err := u.client.ReopenWorktreeSession(u.ctx, project.ID, existing.Path, strings.ToLower(rand.Text()))
if err != nil {
return "", err
}
if restored == nil {
return u.addWorktreeSession(worktreeRequest{project: project, kind: u.model.newSessionKind()},
&backend.Worktree{Name: filepath.Base(existing.Path), Path: existing.Path}, "")
return u.recordWorktreeSession(r, &backend.Worktree{Name: filepath.Base(existing.Path), Path: existing.Path}, "")
}
return restored.ID, u.showNewSession(project.ID, restored.ID)
return restored.ID, nil
}

// newSessionAt opens a session of the kind a new one spawns in the project's
// own checkout and puts it on screen.
func (u *window) newSessionAt(project backend.Project, path string) (string, error) {
// recordSessionAt records a session of the kind a new one spawns in the
// project's own checkout.
func (u *window) recordSessionAt(project backend.Project, path string) (string, error) {
kind := u.model.newSessionKind()
id := strings.ToLower(rand.Text())
label := fmt.Sprintf("%s %d", strings.ToUpper(kind[:1])+kind[1:], project.NextSeq+1)
if err := u.client.AddSessionInFolder(u.ctx, project.ID, id, label, kind, path, project.NextSeq+1, ""); err != nil {
return "", err
}
return id, u.showNewSession(project.ID, id)
return id, u.client.AddSessionInFolder(u.ctx, project.ID, id, label, kind, path, project.NextSeq+1, "")
}

// mergePR merges with subject and body as the commit message (gh's own for an
Expand Down
65 changes: 64 additions & 1 deletion native/cmd/lich-native/pulls_session_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package main

import "testing"
import (
"strings"
"testing"

"github.com/lichdotdev/lich/native/backend"
"github.com/lichdotdev/lich/native/ui"
)

func TestSessionToOpenAtPrefersTheActiveSession(t *testing.T) {
m, _ := newLayoutsModel(t)
Expand All @@ -20,3 +26,60 @@ func TestSessionToOpenAtFallsBackToTheFirstSession(t *testing.T) {
t.Fatalf("opened %q in a checkout without sessions", id)
}
}

// prWindow is a window on the Pulls screen of project p, whose checkout holds
// the PR's branch and session s1.
func prWindow(t *testing.T) (*window, *gateBackend) {
t.Helper()
u, b := newGateWindow(t)
u.model.projects[0].Sessions = []backend.Session{{ID: "s1", Label: "Claude 1", Kind: "claude"}}
b.answer["project.ListCheckouts"] = []backend.Worktree{{Name: "feat", Path: "/repo"}}
b.answer["terminal.Live"], b.answer["terminal.Ready"] = true, true
u.gate = &spawnGate{probe: u.client, invalidate: func() {}, notify: func(t ui.Toast) { u.toaster.Show(t) }}
u.pulls = newPullsScreen(newPullsStore(u.ctx, u.client, func() {}), &u.root)
u.pulls.open = true
return u, b
}

func TestHandoffLeavesThePullsScreenUp(t *testing.T) {
u, b := prWindow(t)
u.openPRSession(u.model.projects[0], backend.PRDetail{Number: 7, HeadRefName: "feat"}, "fix it")
b.await(t, "terminal.Start")
if args := b.await(t, "relay.Submit"); !strings.Contains(args, `"fix it"`) {
t.Fatalf("submitted %s, want the handoff prompt", args)
}
if !u.pulls.open {
t.Fatal("the handoff took the Pulls screen down")
}
if active, ok := u.model.activeSession(); ok && active.ID == "s1" {
t.Fatal("the handoff brought its session up")
}
}

func TestOpenSessionBringsTheSessionUp(t *testing.T) {
u, _ := prWindow(t)
u.openPRSession(u.model.projects[0], backend.PRDetail{Number: 7, HeadRefName: "feat"}, "")
if u.pulls.open {
t.Fatal("Open session left the Pulls screen up")
}
if active, ok := u.model.activeSession(); !ok || active.ID != "s1" {
t.Fatalf("active session %q, want s1", active.ID)
}
}

func TestSendThreadsLeavesThePullsScreenUp(t *testing.T) {
u, b := prWindow(t)
thread := backend.PRReviewThread{ID: "T1", Path: "main.go", Line: 3, Comments: []backend.PRThreadComment{{ID: 9, Author: "rev", Body: "nit"}}}
u.sendThreads(u.model.projects[0], backend.PRDetail{Number: 7, HeadRefName: "feat", State: "OPEN"}, []backend.PRReviewThread{thread})
b.await(t, "terminal.Start")
b.await(t, "relay.Submit")
if args := b.await(t, "project.AwaitThreadFix"); !strings.Contains(args, `"T1"`) {
t.Fatalf("awaited %s, want thread T1", args)
}
if !u.pulls.open {
t.Fatal("sending threads took the Pulls screen down")
}
if active, ok := u.model.activeSession(); ok && active.ID == "s1" {
t.Fatal("sending threads brought its session up")
}
}
17 changes: 8 additions & 9 deletions native/cmd/lich-native/pulls_threads.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ func threadLines(t backend.PRReviewThread) string {
return fmt.Sprint(t.Line)
}

// sendThreads hands threads to the PR's session, opening one when its checkout
// has none, and, once the session took the prompt, has each answered on
// GitHub and waiting for the push that resolves it.
// sendThreads hands threads to the PR's session, opening one off screen when
// its checkout has none, as the PR handoffs do, and, once the session took the
// prompt, has each answered on GitHub and waiting for the push that resolves
// it.
func (u *window) sendThreads(project backend.Project, d backend.PRDetail, threads []backend.PRReviewThread) {
u.pulls.view.setBusy("threads")
defer u.pulls.view.setBusy("")
Expand All @@ -93,11 +94,8 @@ func (u *window) sendThreads(project backend.Project, d backend.PRDetail, thread
u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("pulls.pulls.openSessionFailed", i18n.Args{"error": backend.ErrorText(err)})})
return
}
if !u.awaitPrompt(id) {
return
}
if err := u.client.Submit(u.ctx, id, threadsPrompt(d, threads)); err != nil {
u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.worktreeDialog.handoffLost", i18n.Args{"error": backend.ErrorText(err)})})
u.model.invalidate()
if !u.handOffPR(project.ID, id, threadsPrompt(d, threads)) {
return
}
for _, t := range threads {
Expand All @@ -107,5 +105,6 @@ func (u *window) sendThreads(project backend.Project, d backend.PRDetail, thread
return
}
}
u.toaster.Show(ui.Toast{Variant: ui.ToastSuccess, Title: i18n.T("pulls.threadSend.sent", i18n.Args{"count": len(threads), "name": u.model.sessionLabel(id)})})
u.toaster.Show(ui.Toast{Variant: ui.ToastSuccess, Title: i18n.T("pulls.threadSend.sent", i18n.Args{"count": len(threads), "name": u.model.sessionLabel(id)}),
Action: u.openPRButton(project.ID, id)})
}
21 changes: 21 additions & 0 deletions native/cmd/lich-native/spawn_gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,27 @@ func (g *spawnGate) decide(ctx context.Context, s sessionView) {
g.invalidate()
}

// unseen is release for a spawn nobody is looking at: the resume id s starts
// with, false when the decision needs the user (the resume prompt, a checkout
// gone) and only s's first view may take it.
func (g *spawnGate) unseen(ctx context.Context, s sessionView) (resume string, ok bool) {
live, err := g.probe.Live(ctx, s.ID)
logGateErr("live check", err)
if live {
return "", true
}
switch spawnDecision(ctx, g.probe, s.path, s.Session) {
case spawnPlain:
return "", true
case spawnResume:
return s.ProviderSessionID, true
case spawnFresh:
g.notify(ui.Toast{Title: i18n.T("session.spawnGate.conversationGone")})
return "", true
}
return "", false
}

func (g *spawnGate) releaseLocked(id, resume string) {
if g.released == nil {
g.released = map[string]string{}
Expand Down
32 changes: 32 additions & 0 deletions native/cmd/lich-native/spawn_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,35 @@ func TestForgetSendsReturningIDThroughGate(t *testing.T) {
t.Error("forgotten session still released")
}
}

func TestUnseenSpawnsWhatTheFirstViewWould(t *testing.T) {
cases := []struct {
name string
probe fakeProbe
resume string
ok bool
}{
{"live", fakeProbe{live: true, available: true}, "", true},
{"plain", fakeProbe{}, "", true},
{"resume", fakeProbe{available: true, choice: restoreResume}, "conv-1", true},
{"fresh", fakeProbe{available: false}, "", true},
{"ask", fakeProbe{available: true}, "", false},
{"park", fakeProbe{missing: true}, "", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s := sessionView{Session: restored, path: "/repo"}
if c.name == "plain" {
s.ProviderSessionID = ""
}
g := &spawnGate{probe: &c.probe, notify: func(ui.Toast) {}}
resume, ok := g.unseen(context.Background(), s)
if resume != c.resume || ok != c.ok {
t.Errorf("unseen = %q, %v; want %q, %v", resume, ok, c.resume, c.ok)
}
if g.prompt() != nil {
t.Error("an unseen spawn raised the resume prompt")
}
})
}
}
Loading
Loading