diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc76406c..f358685a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/native/cmd/lich-native/pulls_actions.go b/native/cmd/lich-native/pulls_actions.go index df057b8af..578b010e0 100644 --- a/native/cmd/lich-native/pulls_actions.go +++ b/native/cmd/lich-native/pulls_actions.go @@ -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 diff --git a/native/cmd/lich-native/pulls_session_test.go b/native/cmd/lich-native/pulls_session_test.go index 4baec25c3..f7f7e32ff 100644 --- a/native/cmd/lich-native/pulls_session_test.go +++ b/native/cmd/lich-native/pulls_session_test.go @@ -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) @@ -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") + } +} diff --git a/native/cmd/lich-native/pulls_threads.go b/native/cmd/lich-native/pulls_threads.go index ebea6bc8c..fc960b1db 100644 --- a/native/cmd/lich-native/pulls_threads.go +++ b/native/cmd/lich-native/pulls_threads.go @@ -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("") @@ -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 { @@ -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)}) } diff --git a/native/cmd/lich-native/spawn_gate.go b/native/cmd/lich-native/spawn_gate.go index 6c01d50d2..f97c4d9ea 100644 --- a/native/cmd/lich-native/spawn_gate.go +++ b/native/cmd/lich-native/spawn_gate.go @@ -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{} diff --git a/native/cmd/lich-native/spawn_gate_test.go b/native/cmd/lich-native/spawn_gate_test.go index 0d0ffafd7..2603afd09 100644 --- a/native/cmd/lich-native/spawn_gate_test.go +++ b/native/cmd/lich-native/spawn_gate_test.go @@ -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") + } + }) + } +} diff --git a/native/cmd/lich-native/worktree_dialog.go b/native/cmd/lich-native/worktree_dialog.go index 829a6e5b7..844cd7764 100644 --- a/native/cmd/lich-native/worktree_dialog.go +++ b/native/cmd/lich-native/worktree_dialog.go @@ -3,6 +3,7 @@ package main import ( "context" "crypto/rand" + "errors" "flag" "log" "strconv" @@ -398,13 +399,20 @@ func (u *window) worktreeSession(r worktreeRequest, wt *backend.Worktree) (strin // addWorktreeSession records the worktree's session, named after it, and // puts it on screen. func (u *window) addWorktreeSession(r worktreeRequest, wt *backend.Worktree, sandbox string) (string, error) { - id := strings.ToLower(rand.Text()) - if err := u.client.AddSessionWithSandbox(u.ctx, r.project.ID, id, wt.Name, r.kind, wt.Path, r.project.NextSeq+1, sandbox); err != nil { + id, err := u.recordWorktreeSession(r, wt, sandbox) + if err != nil { return "", err } return id, u.showNewSession(r.project.ID, id) } +// recordWorktreeSession records the worktree's session, named after it, +// without reloading the projects. +func (u *window) recordWorktreeSession(r worktreeRequest, wt *backend.Worktree, sandbox string) (string, error) { + id := strings.ToLower(rand.Text()) + return id, u.client.AddSessionWithSandbox(u.ctx, r.project.ID, id, wt.Name, r.kind, wt.Path, r.project.NextSeq+1, sandbox) +} + // reopenWorktree brings back the session parked on an existing worktree, or // opens a fresh one there. func (u *window) reopenWorktree(r worktreeRequest, path string) { @@ -432,29 +440,46 @@ func (u *window) reopenWorktree(r worktreeRequest, path string) { // showNewSession reloads the project and puts session id on screen. func (u *window) showNewSession(projectID, id string) error { - if err := u.model.reload(u.ctx); err != nil { + if err := u.loadSessions(); err != nil { return err } - u.logErr("load folds", u.model.loadFolds(u.ctx)) u.model.setActive(u.ctx, id) u.logErr("set active session", u.client.SetActiveSession(u.ctx, projectID, id)) u.model.invalidate() return nil } +// loadSessions reloads the projects, so a session just recorded has its card. +func (u *window) loadSessions() error { + if err := u.model.reload(u.ctx); err != nil { + return err + } + u.logErr("load folds", u.model.loadFolds(u.ctx)) + return nil +} + // submitAtPrompt sends message as the session's prompt once it sits at one. func (u *window) submitAtPrompt(id, message string) { - if !u.awaitPrompt(id) { - return + if err := u.handOff(id, message); err != nil { + u.toaster.Show(handoffLostToast(err)) } - if err := u.client.Submit(u.ctx, id, message); err != nil { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.worktreeDialog.handoffLost", i18n.Args{"error": backend.ErrorText(err)})}) +} + +func handoffLostToast(err error) ui.Toast { + return ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.worktreeDialog.handoffLost", i18n.Args{"error": backend.ErrorText(err)})} +} + +// handOff is submitAtPrompt without the toast: why message never landed, nil +// once it did. +func (u *window) handOff(id, message string) error { + if !u.awaitPrompt(id) { + return errors.New(i18n.T("sidebar.worktreeDialog.neverPrompted")) } + return u.client.Submit(u.ctx, id, message) } -// awaitPrompt waits for the session to sit at a prompt, and says so with a -// toast when it never does: text written before lands in the boot and is -// thrown away. +// awaitPrompt waits for the session to sit at a prompt, false when it never +// does: text written before lands in the boot and is thrown away. func (u *window) awaitPrompt(id string) bool { deadline := time.Now().Add(promptLimit) for { @@ -463,7 +488,6 @@ func (u *window) awaitPrompt(id string) bool { return true } if time.Now().After(deadline) { - u.toaster.Show(ui.Toast{Variant: ui.ToastError, Title: i18n.T("sidebar.worktreeDialog.handoffLost", i18n.Args{"error": i18n.T("sidebar.worktreeDialog.neverPrompted")})}) return false } time.Sleep(promptPoll) diff --git a/native/i18n/locales/en/pulls.json b/native/i18n/locales/en/pulls.json index 284dc71be..03d85bc73 100644 --- a/native/i18n/locales/en/pulls.json +++ b/native/i18n/locales/en/pulls.json @@ -220,6 +220,7 @@ "announceFailed": "Couldn’t tell the sessions in {name}: {error}", "removeWorktree": "Remove worktree", "openSessionFailed": "Couldn’t open a session: {error}", + "handedOff": "Handed #{number} to {session}", "goToSession": "Go to session", "openInSession": "Open in Session", "forkBlocked": "The head branch lives on a fork that does not allow edits by maintainers — its commits could not be pushed back", diff --git a/native/i18n/locales/es/pulls.json b/native/i18n/locales/es/pulls.json index 6272df59f..7afc7f2db 100644 --- a/native/i18n/locales/es/pulls.json +++ b/native/i18n/locales/es/pulls.json @@ -220,6 +220,7 @@ "announceFailed": "No se pudo avisar a las sesiones de {name}: {error}", "removeWorktree": "Eliminar worktree", "openSessionFailed": "No se pudo abrir una sesión: {error}", + "handedOff": "#{number} entregado a {session}", "goToSession": "Ir a la sesión", "openInSession": "Abrir en una sesión", "forkBlocked": "La rama de origen vive en un fork que no permite ediciones de los mantenedores: no se pudieron enviar sus commits de vuelta", diff --git a/native/i18n/locales/ja/pulls.json b/native/i18n/locales/ja/pulls.json index 8e8b59eec..961f182bc 100644 --- a/native/i18n/locales/ja/pulls.json +++ b/native/i18n/locales/ja/pulls.json @@ -219,6 +219,7 @@ "announceFailed": "{name} のセッションに通知できませんでした: {error}", "removeWorktree": "ワークツリーを削除", "openSessionFailed": "セッションを開けませんでした: {error}", + "handedOff": "#{number} を {session} に渡しました", "goToSession": "セッションへ移動", "openInSession": "セッションで開く", "forkBlocked": "head ブランチはメンテナーによる編集を許可していないフォークにあるため、コミットをプッシュし返せません", diff --git a/native/i18n/locales/ko/pulls.json b/native/i18n/locales/ko/pulls.json index 566e63eef..ff6f736bb 100644 --- a/native/i18n/locales/ko/pulls.json +++ b/native/i18n/locales/ko/pulls.json @@ -215,6 +215,7 @@ "announceFailed": "{name}의 세션에 알리지 못했습니다: {error}", "removeWorktree": "worktree 삭제", "openSessionFailed": "세션을 열지 못했습니다: {error}", + "handedOff": "#{number}을(를) {session}에 넘겼습니다", "goToSession": "세션으로 이동", "openInSession": "세션에서 열기", "forkBlocked": "헤드 브랜치가 메인테이너의 수정을 허용하지 않는 포크에 있어서, 커밋을 다시 푸시할 수 없습니다", diff --git a/native/i18n/locales/pt-br/pulls.json b/native/i18n/locales/pt-br/pulls.json index 96ab92eae..8d5de092d 100644 --- a/native/i18n/locales/pt-br/pulls.json +++ b/native/i18n/locales/pt-br/pulls.json @@ -220,6 +220,7 @@ "announceFailed": "Não foi possível avisar as sessões em {name}: {error}", "removeWorktree": "Remover worktree", "openSessionFailed": "Não foi possível abrir uma sessão: {error}", + "handedOff": "#{number} entregue a {session}", "goToSession": "Ir para a sessão", "openInSession": "Abrir em sessão", "forkBlocked": "A branch de origem está em um fork que não permite edições dos mantenedores, então os commits não poderiam ser enviados de volta", diff --git a/native/i18n/locales/ru/pulls.json b/native/i18n/locales/ru/pulls.json index ece92eccb..d60968c4a 100644 --- a/native/i18n/locales/ru/pulls.json +++ b/native/i18n/locales/ru/pulls.json @@ -234,6 +234,7 @@ "announceFailed": "Не удалось сообщить сессиям в {name}: {error}", "removeWorktree": "Удалить worktree", "openSessionFailed": "Не удалось открыть сессию: {error}", + "handedOff": "#{number} передан в {session}", "goToSession": "Перейти к сессии", "openInSession": "Открыть в сессии", "forkBlocked": "Головная ветка находится в форке, который не разрешает правки от мейнтейнеров: коммиты нельзя отправить обратно", diff --git a/native/i18n/locales/zh-cn/pulls.json b/native/i18n/locales/zh-cn/pulls.json index fc8f4468c..3b040119c 100644 --- a/native/i18n/locales/zh-cn/pulls.json +++ b/native/i18n/locales/zh-cn/pulls.json @@ -219,6 +219,7 @@ "announceFailed": "无法通知 {name} 中的会话:{error}", "removeWorktree": "移除工作树", "openSessionFailed": "无法打开会话:{error}", + "handedOff": "已将 #{number} 交给 {session}", "goToSession": "前往会话", "openInSession": "在会话中打开", "forkBlocked": "头部分支位于不允许维护者编辑的分叉上,无法把提交推回去",